Skip to content

Commit f2b05ea

Browse files
authored
Merge pull request #99 from rymorale/internal-fixes
Internal fixes + add UI tests
2 parents 762b0c1 + 6337eff commit f2b05ea

14 files changed

Lines changed: 322 additions & 31 deletions

File tree

Documentation/style-guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ Feature toggles and interaction configuration.
262262

263263
| JSON Key | Type | Default | Description |
264264
|----------|------|---------|-------------|
265-
| `behavior.chat.messageAlignment` | string | `"left"` | Message alignment (`"left"`, `"center"`, `"right"`) |
265+
| `behavior.chat.messageAlignment` | string | `"start"` | Agent message alignment (`"start"`, `"center"`, `"end"`). Note: theme JSONs using legacy `"left"` values are treated as `"start"`. |
266266
| `behavior.chat.messageWidth` | string | `"100%"` | Max message width (e.g., `"100%"`, `"768px"`) |
267267
| `behavior.chat.userMessageBubbleStyle` | string | `"default"` | User message bubble shape. `"default"` = all corners rounded; `"balloon"` = rounded except bottom-right corner is square (speech balloon style). Corner radius is controlled by `--message-border-radius` (default `12dp`). |
268268

@@ -1151,7 +1151,7 @@ This section documents which properties are fully implemented, partially impleme
11511151
| `behavior.input.sendButtonStyle` || `"default"` (paper airplane) or `"arrow"` (filled circle with upward arrow) | `SendButton` |
11521152
| `behavior.input.disableMultiline` || Restricts input to a single line when `true` | `ChatTextField` |
11531153
| `behavior.input.showAiChatIcon` | ⚠️ | Parsed but not implemented | - |
1154-
| `behavior.chat.messageAlignment` | ⚠️ | Parsed but not implemented | - |
1154+
| `behavior.chat.messageAlignment` | | `"start"` (default, full-width), `"center"`, or `"end"` alignment for agent message bubbles | `ChatMessageItem` |
11551155
| `behavior.chat.messageWidth` | ⚠️ | Parsed but not implemented | - |
11561156
| `behavior.chat.userMessageBubbleStyle` || `"default"` (all corners rounded) or `"balloon"` (square bottom-right corner) | `ChatMessageItem` |
11571157
| `behavior.privacyNotice.title` | ⚠️ | Parsed but not implemented | - |

code/concierge/src/androidTest/kotlin/com/adobe/marketing/mobile/concierge/ui/components/card/RecommendationCardsTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import androidx.compose.ui.test.junit4.createComposeRule
1818
import androidx.compose.ui.test.onNodeWithText
1919
import androidx.compose.ui.test.performClick
2020
import com.adobe.marketing.mobile.concierge.network.MultimodalElement
21+
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeProductCardBehavior
2122
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeTheme
23+
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeThemeBehavior
24+
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeThemeConfig
25+
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeThemeData
26+
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeThemeTokens
27+
import com.adobe.marketing.mobile.concierge.ui.theme.ProductCardStyle
2228
import com.adobe.marketing.mobile.concierge.utils.image.DefaultImageProvider
2329
import com.adobe.marketing.mobile.concierge.utils.image.LocalImageProvider
2430
import org.junit.Rule
@@ -87,6 +93,38 @@ class RecommendationCardsTest {
8793
composeTestRule.waitForIdle()
8894
}
8995

96+
@Test
97+
fun recommendationCards_singleExtendedProductCard_rendersWithCenterAlignment() {
98+
// Verifies that a single extended product card renders correctly when
99+
// horizontalAlignment = CenterHorizontally is applied to the outer Column.
100+
val theme = ConciergeThemeData(
101+
config = ConciergeThemeConfig(),
102+
tokens = ConciergeThemeTokens(
103+
behavior = ConciergeThemeBehavior(
104+
productCard = ConciergeProductCardBehavior(cardStyle = ProductCardStyle.PRODUCT_DETAIL)
105+
)
106+
)
107+
)
108+
val elements = listOf(
109+
MultimodalElement(
110+
id = "ext-1",
111+
title = "Extended Product",
112+
url = "https://example.com/img.jpg"
113+
)
114+
)
115+
116+
composeTestRule.setContent {
117+
ConciergeTheme(theme = theme) {
118+
CompositionLocalProvider(LocalImageProvider provides DefaultImageProvider()) {
119+
RecommendationCards(elements = elements)
120+
}
121+
}
122+
}
123+
124+
composeTestRule.waitForIdle()
125+
composeTestRule.onNodeWithText("Extended Product").assertIsDisplayed()
126+
}
127+
90128
@Test
91129
fun recommendationCards_onActionClick_triggersCallback() {
92130
var clickedButton: ProductActionButton? = null

code/concierge/src/androidTest/kotlin/com/adobe/marketing/mobile/concierge/ui/components/image/LocalAssetImageTest.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212

1313
package com.adobe.marketing.mobile.concierge.ui.components.image
1414

15+
import android.graphics.Bitmap
1516
import androidx.compose.runtime.CompositionLocalProvider
17+
import androidx.compose.ui.test.assertIsDisplayed
1618
import androidx.compose.ui.test.junit4.createComposeRule
19+
import androidx.compose.ui.test.onNodeWithContentDescription
20+
import androidx.compose.ui.graphics.asImageBitmap
1721
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeTheme
1822
import com.adobe.marketing.mobile.concierge.utils.image.DefaultImageProvider
1923
import com.adobe.marketing.mobile.concierge.utils.image.LocalImageProvider
@@ -106,6 +110,26 @@ class LocalAssetImageTest {
106110
}
107111
}
108112

113+
@Test
114+
fun localAssetImage_withCachedBitmap_rendersImage() {
115+
// Pre-seed the cache with a valid bitmap to exercise the render branch
116+
// (bitmap?.let { Image(...) }) without requiring a real asset file on disk.
117+
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888).asImageBitmap()
118+
assetBitmapCache["cached-icon"] = bitmap
119+
120+
composeTestRule.setContent {
121+
ConciergeTheme {
122+
LocalAssetImage(
123+
source = "cached-icon",
124+
contentDescription = "Cached icon"
125+
)
126+
}
127+
}
128+
129+
composeTestRule.waitForIdle()
130+
composeTestRule.onNodeWithContentDescription("Cached icon").assertIsDisplayed()
131+
}
132+
109133
@Test
110134
fun localAssetImage_withEmptySource_rendersWithoutCrashing() {
111135
// An empty string is not a URL, so it routes to LocalFileImage which will

code/concierge/src/androidTest/kotlin/com/adobe/marketing/mobile/concierge/ui/components/messages/ChatMessageItemTest.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,58 @@ class ChatMessageItemTest {
410410
.assertIsDisplayed()
411411
}
412412

413+
// --- RenderMixedMessage + BotMessageSuffix ---
414+
415+
@Test
416+
fun chatMessageItem_botMixedMessage_withCtaButton_displaysBothTextAndCta() {
417+
// RenderMixedMessage was refactored to use BotMessageSuffix for prompt suggestions
418+
// and CTA button. Verify the CTA is rendered via BotMessageSuffix in the mixed path.
419+
val message = ChatMessage(
420+
content = MessageContent.Mixed(
421+
text = "Here are some options.",
422+
multimodalElements = listOf(
423+
MultimodalElement(id = "p1", title = "Product One", url = "https://example.com/1.jpg")
424+
)
425+
),
426+
isFromUser = false,
427+
timestamp = System.currentTimeMillis(),
428+
ctaButton = NetworkCtaButton(label = "View All", url = "https://example.com/all")
429+
)
430+
431+
composeTestRule.setContent {
432+
ConciergeTheme {
433+
CompositionLocalProvider(LocalImageProvider provides DefaultImageProvider()) {
434+
ChatMessageItem(message = message)
435+
}
436+
}
437+
}
438+
439+
composeTestRule.onNodeWithText("Here are some options.").assertIsDisplayed()
440+
composeTestRule.onNodeWithText("View All").assertIsDisplayed()
441+
}
442+
443+
@Test
444+
fun chatMessageItem_botMixedMessage_withPromptSuggestions_displaysSuggestions() {
445+
val message = ChatMessage(
446+
content = MessageContent.Mixed(
447+
text = "Try one of these.",
448+
multimodalElements = emptyList()
449+
),
450+
isFromUser = false,
451+
timestamp = System.currentTimeMillis(),
452+
promptSuggestions = listOf("Tell me more", "Show deals")
453+
)
454+
455+
composeTestRule.setContent {
456+
ConciergeTheme {
457+
ChatMessageItem(message = message)
458+
}
459+
}
460+
461+
composeTestRule.onNodeWithText("Tell me more").assertIsDisplayed()
462+
composeTestRule.onNodeWithText("Show deals").assertIsDisplayed()
463+
}
464+
413465
// -----------------------------------------------------------------------
414466
// Thinking state
415467
// -----------------------------------------------------------------------

code/concierge/src/main/kotlin/com/adobe/marketing/mobile/concierge/ui/components/image/LocalAssetImage.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ import androidx.compose.ui.graphics.ImageBitmap
2424
import androidx.compose.ui.graphics.asImageBitmap
2525
import androidx.compose.ui.layout.ContentScale
2626
import androidx.compose.ui.platform.LocalContext
27+
import java.util.Collections
2728
import kotlinx.coroutines.Dispatchers
2829
import kotlinx.coroutines.withContext
2930

3031
private const val ASSET_ICONS_FOLDER = "icons"
3132
private val SUPPORTED_EXTENSIONS = listOf(".png", ".webp", ".jpg", ".jpeg")
3233

3334
@VisibleForTesting
34-
internal val assetBitmapCache = HashMap<String, ImageBitmap?>()
35+
internal val assetBitmapCache: MutableMap<String, ImageBitmap?> = Collections.synchronizedMap(HashMap())
3536

3637
/**
3738
* Composable that loads and displays a company icon from either a remote URL or the app's

code/concierge/src/main/kotlin/com/adobe/marketing/mobile/concierge/ui/components/messages/ChatMessageItem.kt

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,22 @@ import com.adobe.marketing.mobile.concierge.ui.components.suggestions.PromptSugg
4343
import com.adobe.marketing.mobile.concierge.ui.state.ChatMessage
4444
import com.adobe.marketing.mobile.concierge.ui.state.FeedbackEvent
4545
import com.adobe.marketing.mobile.concierge.ui.state.MessageContent
46+
import com.adobe.marketing.mobile.concierge.ui.theme.ChatMessageAlignment
4647
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeStyles
4748
import com.adobe.marketing.mobile.concierge.ui.theme.ConciergeTheme
4849

50+
/**
51+
* Maps a [ChatMessageAlignment] to the Compose [Modifier] that produces the correct
52+
* horizontal alignment for a bot message card. The pattern is consistent across all
53+
* render functions: [fillMaxWidth] reserves the full layout slot so the list is stable,
54+
* and [wrapContentWidth] then shrinks the card to its content and anchors it.
55+
*/
56+
private fun ChatMessageAlignment.toModifier(): Modifier = when (this) {
57+
ChatMessageAlignment.CENTER -> Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally)
58+
ChatMessageAlignment.END -> Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
59+
ChatMessageAlignment.START -> Modifier.fillMaxWidth()
60+
}
61+
4962
/**
5063
* Component that displays a single chat message.
5164
*/
@@ -109,12 +122,15 @@ private fun RenderTextMessage(
109122
val thinkingStyle = ConciergeStyles.thinkingAnimationStyle
110123
val isThinking = message.isThinking
111124
val companyIconName = if (!message.isFromUser) ConciergeTheme.tokens?.assets?.icons?.company?.takeIf { it.isNotEmpty() } else null
125+
val messageAlignment = ConciergeTheme.behavior?.chat?.messageAlignment ?: ChatMessageAlignment.START
112126

113127
if (companyIconName != null) {
114128
RenderTextMessageWithIcon(
115129
message = message,
116130
companyIconName = companyIconName,
131+
isThinking = isThinking,
117132
style = style,
133+
thinkingStyle = thinkingStyle,
118134
onFeedback = onFeedback,
119135
onSuggestionClick = onSuggestionClick,
120136
handleLink = handleLink,
@@ -132,8 +148,8 @@ private fun RenderTextMessage(
132148
.then(
133149
when {
134150
message.isFromUser -> Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
135-
isThinking -> Modifier
136-
else -> Modifier.fillMaxWidth()
151+
isThinking -> Modifier.fillMaxWidth().wrapContentWidth(Alignment.Start)
152+
else -> messageAlignment.toModifier()
137153
}
138154
)
139155
.padding(style.padding),
@@ -198,7 +214,9 @@ private fun RenderTextMessage(
198214
private fun RenderTextMessageWithIcon(
199215
message: ChatMessage,
200216
companyIconName: String,
217+
isThinking: Boolean,
201218
style: ConciergeStyles.MessageBubbleStyle,
219+
thinkingStyle: ConciergeStyles.ThinkingAnimationStyle,
202220
onFeedback: (FeedbackEvent) -> Unit,
203221
onSuggestionClick: (String) -> Unit,
204222
handleLink: (String) -> Unit,
@@ -230,6 +248,9 @@ private fun RenderTextMessageWithIcon(
230248
Card(
231249
modifier = Modifier
232250
.fillMaxWidth()
251+
.then(
252+
if (isThinking) Modifier.wrapContentWidth(Alignment.Start) else Modifier
253+
)
233254
.padding(
234255
top = style.padding,
235256
bottom = style.padding,
@@ -239,22 +260,28 @@ private fun RenderTextMessageWithIcon(
239260
containerColor = style.botMessageBackgroundColor
240261
),
241262
elevation = CardDefaults.cardElevation(defaultElevation = style.elevation),
242-
shape = style.shape
263+
shape = if (isThinking) thinkingStyle.bubbleShape else style.shape
243264
) {
244265
Box(
245266
modifier = Modifier.padding(
246-
top = style.innerPadding,
247-
bottom = style.innerPadding,
248-
end = style.innerPadding
267+
if (isThinking) thinkingStyle.bubblePadding else PaddingValues(
268+
top = style.innerPadding,
269+
bottom = style.innerPadding,
270+
end = style.innerPadding
271+
)
249272
)
250273
) {
251-
Column(modifier = Modifier.fillMaxWidth()) {
252-
AgentResponseContent(
253-
message = message,
254-
handleLink = handleLink,
255-
onFeedback = onFeedback,
256-
feedbackState = feedbackState
257-
)
274+
if (isThinking) {
275+
ConciergeThinking()
276+
} else {
277+
Column(modifier = Modifier.fillMaxWidth()) {
278+
AgentResponseContent(
279+
message = message,
280+
handleLink = handleLink,
281+
onFeedback = onFeedback,
282+
feedbackState = feedbackState
283+
)
284+
}
258285
}
259286
}
260287
}
@@ -282,13 +309,14 @@ private fun RenderMixedMessage(
282309
) {
283310
val style = ConciergeStyles.messageBubbleStyle
284311
val content = message.content as MessageContent.Mixed
312+
val messageAlignment = ConciergeTheme.behavior?.chat?.messageAlignment ?: ChatMessageAlignment.START
285313

286314
Column(
287315
modifier = Modifier.fillMaxWidth()
288316
) {
289317
Card(
290318
modifier = Modifier
291-
.fillMaxWidth()
319+
.then(messageAlignment.toModifier())
292320
.padding(style.padding),
293321
colors = CardDefaults.cardColors(
294322
containerColor = style.botMessageBackgroundColor

code/concierge/src/main/kotlin/com/adobe/marketing/mobile/concierge/ui/components/messages/ConciergeThinking.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ internal fun ConciergeThinking(
4646
) {
4747
if (style.thinkingText.isNotEmpty()) {
4848
Text(
49-
modifier = Modifier.weight(1f),
5049
text = style.thinkingText,
5150
style = style.textStyle,
5251
color = style.textColor

code/concierge/src/main/kotlin/com/adobe/marketing/mobile/concierge/ui/theme/ConciergeStyles.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ internal object ConciergeStyles {
220220
@Composable get() {
221221
val themeColors = ConciergeTheme.colors
222222
val cssLayout = ConciergeTheme.tokens?.cssLayout
223-
val cornerRadius = (ConciergeTheme.tokens?.cssLayout?.messageBorderRadius?.dp ?: 12.dp)
223+
val cornerRadius = cssLayout?.messageBorderRadius?.dp ?: 12.dp
224224
val defaultShape = RoundedCornerShape(cornerRadius)
225225
val userMessageShape = when (ConciergeTheme.behavior?.chat?.userMessageBubbleStyle) {
226226
UserMessageBubbleStyle.BALLOON -> RoundedCornerShape(

code/concierge/src/main/kotlin/com/adobe/marketing/mobile/concierge/ui/theme/ConciergeThemeTokens.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,29 @@ data class ConciergePromptSuggestionsBehavior(
209209
* Chat behavior configuration from `behavior.chat` in theme JSON.
210210
*/
211211
data class ConciergeChatBehavior(
212-
val messageAlignment: String? = null,
212+
val messageAlignment: ChatMessageAlignment = ChatMessageAlignment.START,
213213
val messageWidth: String? = null,
214214
val userMessageBubbleStyle: UserMessageBubbleStyle = UserMessageBubbleStyle.DEFAULT
215215
)
216216

217+
/**
218+
* Horizontal alignment for chat messages from `behavior.chat.messageAlignment` in theme JSON.
219+
*
220+
* - `"start"` — messages align to the leading edge (default).
221+
* - `"center"` — messages are horizontally centered.
222+
* - `"end"` — messages align to the trailing edge.
223+
*/
224+
enum class ChatMessageAlignment(val value: String) {
225+
START("start"),
226+
CENTER("center"),
227+
END("end");
228+
229+
companion object {
230+
fun fromString(value: String): ChatMessageAlignment =
231+
values().firstOrNull { it.value.equals(value, ignoreCase = true) } ?: START
232+
}
233+
}
234+
217235
/**
218236
* User message bubble shape from `behavior.chat.userMessageBubbleStyle` in theme JSON.
219237
*

code/concierge/src/main/kotlin/com/adobe/marketing/mobile/concierge/ui/theme/ThemeParser.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ internal object ThemeParser {
440440
val chatTyped = chatMap as? MutableMap<String?, Any?>
441441
val chat = chatTyped?.let {
442442
ConciergeChatBehavior(
443-
messageAlignment = DataReader.optString(it, "messageAlignment", null),
443+
messageAlignment = ChatMessageAlignment.fromString(DataReader.optString(it, "messageAlignment", "start") ?: "start"),
444444
messageWidth = DataReader.optString(it, "messageWidth", null),
445445
userMessageBubbleStyle = UserMessageBubbleStyle.fromString(DataReader.optString(it, "userMessageBubbleStyle", "default") ?: "default")
446446
)

0 commit comments

Comments
 (0)