Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Documentation/style-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ Feature toggles and interaction configuration.
|----------|------|---------|-------------|
| `behavior.chat.messageAlignment` | string | `"left"` | Message alignment (`"left"`, `"center"`, `"right"`) |
| `behavior.chat.messageWidth` | string | `"100%"` | Max message width (e.g., `"100%"`, `"768px"`) |
| `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`). |

### Privacy Notice

Expand Down Expand Up @@ -324,7 +325,8 @@ Feature toggles and interaction configuration.
},
"chat": {
"messageAlignment": "left",
"messageWidth": "100%"
"messageWidth": "100%",
"userMessageBubbleStyle": "balloon"
},
"privacyNotice": {
"title": "Privacy Notice",
Expand Down Expand Up @@ -833,7 +835,8 @@ When `behavior.productCard.cardStyle` is `"productDetail"`, product recommendati
},
"chat": {
"messageAlignment": "left",
"messageWidth": "100%"
"messageWidth": "100%",
"userMessageBubbleStyle": "balloon"
},
"privacyNotice": {
"title": "Privacy Notice",
Expand Down Expand Up @@ -1108,6 +1111,7 @@ This section documents which properties are fully implemented, partially impleme
| `behavior.input.showAiChatIcon` | ⚠️ | Parsed but not implemented | - |
| `behavior.chat.messageAlignment` | ⚠️ | Parsed but not implemented | - |
| `behavior.chat.messageWidth` | ⚠️ | Parsed but not implemented | - |
| `behavior.chat.userMessageBubbleStyle` | ✅ | `"default"` (all corners rounded) or `"balloon"` (square bottom-right corner) | `ChatMessageItem` |
| `behavior.privacyNotice.title` | ⚠️ | Parsed but not implemented | - |
| `behavior.privacyNotice.text` | ⚠️ | Parsed but not implemented | - |
| `behavior.welcomeCard.closeButtonAlignment` | ✅ | `"start"` or `"end"` close button position | `ChatHeader` |
Expand Down Expand Up @@ -1266,7 +1270,7 @@ Note: The feedback dialog checkbox uses `--color-primary` for the check box fill
| `--input-button-width` | ⚠️ | Parsed but not used in composables | - |
| `--input-button-border-radius` | ⚠️ | Parsed but not used in composables | - |
| `--input-box-shadow` | ⚠️ | Parsed but shadows not rendered | - |
| `--message-border-radius` | ⚠️ | Parsed but not used in composables | - |
| `--message-border-radius` | | Corner radius for all message bubbles; applies to both user and agent bubbles | `ChatMessageItem` |
| `--message-padding` | ⚠️ | Parsed but not used in composables | - |
| `--message-max-width` | ⚠️ | Parsed but not used in composables | - |
| `--chat-interface-max-width` | ⚠️ | Parsed but not used in composables | - |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

package com.adobe.marketing.mobile.concierge.ui.theme

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -68,4 +69,82 @@ class ConciergeStylesTest {
assertEquals(14.0, style!!.textStyle.fontSize.value.toDouble(), 0.1)
assertEquals(700, style!!.textStyle.fontWeight?.weight ?: 0)
}

@Test
fun messageBubbleStyle_defaultStyle_allCornersRounded() {
var style: ConciergeStyles.MessageBubbleStyle? = null

composeTestRule.setContent {
ConciergeTheme {
style = ConciergeStyles.messageBubbleStyle
}
}

composeTestRule.waitForIdle()
assertNotNull(style)
assertEquals(style!!.shape, style!!.userMessageShape)
assertEquals(RoundedCornerShape(12.dp), style!!.userMessageShape)
}

@Test
fun messageBubbleStyle_balloonStyle_squaresBottomRightCorner() {
var style: ConciergeStyles.MessageBubbleStyle? = null
val themeData = ConciergeThemeData(
config = ConciergeThemeConfig(),
tokens = ConciergeThemeTokens(
behavior = ConciergeThemeBehavior(
chat = ConciergeChatBehavior(userMessageBubbleStyle = UserMessageBubbleStyle.BALLOON)
)
)
)

composeTestRule.setContent {
ConciergeTheme(theme = themeData) {
style = ConciergeStyles.messageBubbleStyle
}
}

composeTestRule.waitForIdle()
assertNotNull(style)
val expected = RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
bottomStart = 12.dp,
bottomEnd = 0.dp
)
assertEquals(expected, style!!.userMessageShape)
// Agent message shape is always fully rounded regardless of userMessageBubbleStyle
assertEquals(RoundedCornerShape(12.dp), style!!.shape)
}

@Test
fun messageBubbleStyle_customBorderRadius_appliedToBothShapes() {
var style: ConciergeStyles.MessageBubbleStyle? = null
val themeData = ConciergeThemeData(
config = ConciergeThemeConfig(),
tokens = ConciergeThemeTokens(
behavior = ConciergeThemeBehavior(
chat = ConciergeChatBehavior(userMessageBubbleStyle = UserMessageBubbleStyle.BALLOON)
),
cssLayout = ConciergeLayout(messageBorderRadius = 20.0)
)
)

composeTestRule.setContent {
ConciergeTheme(theme = themeData) {
style = ConciergeStyles.messageBubbleStyle
}
}

composeTestRule.waitForIdle()
assertNotNull(style)
assertEquals(RoundedCornerShape(20.dp), style!!.shape)
val expectedUserShape = RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = 20.dp,
bottomEnd = 0.dp
)
assertEquals(expectedUserShape, style!!.userMessageShape)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ private fun RenderTextMessage(
}
),
elevation = CardDefaults.cardElevation(defaultElevation = style.elevation),
shape = style.shape
shape = if (message.isFromUser) style.userMessageShape else style.shape
) {
Box(
modifier = Modifier.padding(style.innerPadding)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ internal object ConciergeStyles {
val padding: Dp,
val innerPadding: Dp,
val shape: Shape,
val userMessageShape: Shape,
val elevation: Dp,
val userMessageBackgroundColor: Color,
val botMessageBackgroundColor: Color,
Expand All @@ -215,10 +216,22 @@ internal object ConciergeStyles {
val messageBubbleStyle: MessageBubbleStyle
@Composable get() {
val themeColors = ConciergeTheme.colors
val cornerRadius = (ConciergeTheme.tokens?.cssLayout?.messageBorderRadius?.dp ?: 12.dp)
val defaultShape = RoundedCornerShape(cornerRadius)
val userMessageShape = when (ConciergeTheme.behavior?.chat?.userMessageBubbleStyle) {
UserMessageBubbleStyle.BALLOON -> RoundedCornerShape(
topStart = cornerRadius,
topEnd = cornerRadius,
bottomStart = cornerRadius,
bottomEnd = 0.dp
)
else -> defaultShape
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this default to 12, but use the value from messageBorderRadius if it is provided?

}
return MessageBubbleStyle(
padding = 8.dp,
innerPadding = 16.dp,
shape = RoundedCornerShape(12.dp),
shape = defaultShape,
userMessageShape = userMessageShape,
elevation = 0.dp,
userMessageBackgroundColor = themeColors.userMessageBackground ?: themeColors.primary,
botMessageBackgroundColor = themeColors.conciergeMessageBackground ?: themeColors.container,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ data class ConciergeThemeBehavior(
val citations: ConciergeCitationsBehavior? = null,
val productCard: ConciergeProductCardBehavior? = null,
val multimodalCarousel: ConciergeMultimodalCarouselBehavior? = null,
val chat: ConciergeChatBehavior? = null,
val promptSuggestions: ConciergePromptSuggestionsBehavior? = null
)

Expand All @@ -194,6 +195,31 @@ data class ConciergePromptSuggestionsBehavior(
val alignToMessage: Boolean = false
)

/**
* Chat behavior configuration from `behavior.chat` in theme JSON.
*/
data class ConciergeChatBehavior(
val messageAlignment: String? = null,
val messageWidth: String? = null,
val userMessageBubbleStyle: UserMessageBubbleStyle = UserMessageBubbleStyle.DEFAULT
)

/**
* User message bubble shape from `behavior.chat.userMessageBubbleStyle` in theme JSON.
*
* - `"default"` — all corners rounded.
* - `"balloon"` — rounded except bottom-right corner is square (speech balloon style).
*/
enum class UserMessageBubbleStyle(val value: String) {
DEFAULT("default"),
BALLOON("balloon");

companion object {
fun fromString(value: String): UserMessageBubbleStyle =
values().firstOrNull { it.value.equals(value, ignoreCase = true) } ?: DEFAULT
}
}

/**
* Display mode for the feedback dialog from `behavior.feedback.displayMode` in theme JSON.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,16 @@ internal object ThemeParser {
)
}

val chatMap = typedMap?.get("chat") as? Map<*, *>
@Suppress("UNCHECKED_CAST")
val chatTyped = chatMap as? MutableMap<String?, Any?>
val chat = chatTyped?.let {
ConciergeChatBehavior(
messageAlignment = DataReader.optString(it, "messageAlignment", null),
messageWidth = DataReader.optString(it, "messageWidth", null),
userMessageBubbleStyle = UserMessageBubbleStyle.fromString(DataReader.optString(it, "userMessageBubbleStyle", "default") ?: "default")
)
}
val promptSuggestionsMap = typedMap?.get("promptSuggestions") as? Map<*, *>
@Suppress("UNCHECKED_CAST")
val promptSuggestionsTyped = promptSuggestionsMap as? MutableMap<String?, Any?>
Expand Down Expand Up @@ -463,6 +473,7 @@ internal object ThemeParser {
productCard = productCard,
multimodalCarousel = multimodalCarousel,
welcomeCard = welcomeCard,
chat = chat,
promptSuggestions = promptSuggestions
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,69 @@ class ConciergeThemeTokensTest {
assertEquals(1000, updated.typingIndicatorDelay)
}

// ========== UserMessageBubbleStyle Tests ==========

@Test
fun `UserMessageBubbleStyle fromString returns DEFAULT for default`() {
assertEquals(UserMessageBubbleStyle.DEFAULT, UserMessageBubbleStyle.fromString("default"))
}

@Test
fun `UserMessageBubbleStyle fromString returns BALLOON for balloon`() {
assertEquals(UserMessageBubbleStyle.BALLOON, UserMessageBubbleStyle.fromString("balloon"))
}

@Test
fun `UserMessageBubbleStyle fromString is case insensitive`() {
assertEquals(UserMessageBubbleStyle.BALLOON, UserMessageBubbleStyle.fromString("BALLOON"))
assertEquals(UserMessageBubbleStyle.BALLOON, UserMessageBubbleStyle.fromString("Balloon"))
}

@Test
fun `UserMessageBubbleStyle fromString returns DEFAULT for unknown value`() {
assertEquals(UserMessageBubbleStyle.DEFAULT, UserMessageBubbleStyle.fromString("unknown"))
}

// ========== ConciergeChatBehavior Tests ==========

@Test
fun `ConciergeChatBehavior creates with defaults`() {
val chat = ConciergeChatBehavior()

assertNull(chat.messageAlignment)
assertNull(chat.messageWidth)
assertEquals(UserMessageBubbleStyle.DEFAULT, chat.userMessageBubbleStyle)
}

@Test
fun `ConciergeChatBehavior creates with custom values`() {
val chat = ConciergeChatBehavior(
messageAlignment = "left",
messageWidth = "100%",
userMessageBubbleStyle = UserMessageBubbleStyle.BALLOON
)

assertEquals("left", chat.messageAlignment)
assertEquals("100%", chat.messageWidth)
assertEquals(UserMessageBubbleStyle.BALLOON, chat.userMessageBubbleStyle)
}

@Test
fun `ConciergeThemeBehavior chat is null by default`() {
val behavior = ConciergeThemeBehavior()

assertNull(behavior.chat)
}

@Test
fun `ConciergeThemeBehavior supports chat configuration`() {
val behavior = ConciergeThemeBehavior(
chat = ConciergeChatBehavior(userMessageBubbleStyle = UserMessageBubbleStyle.BALLOON)
)

assertEquals(UserMessageBubbleStyle.BALLOON, behavior.chat?.userMessageBubbleStyle)
}

// ========== Asset Data Classes Tests ==========

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,43 @@ class ThemeParserTest {
)
}

@Test
fun `parseThemeTokens should parse behavior chat section`() {
val json = """
{
"behavior": {
"chat": {
"messageAlignment": "left",
"messageWidth": "100%",
"userMessageBubbleStyle": "balloon"
}
}
}
""".trimIndent()

val tokens = ThemeParser.parseThemeTokens(json)

assertNotNull(tokens?.behavior?.chat)
assertEquals("left", tokens?.behavior?.chat?.messageAlignment)
assertEquals("100%", tokens?.behavior?.chat?.messageWidth)
assertEquals(UserMessageBubbleStyle.BALLOON, tokens?.behavior?.chat?.userMessageBubbleStyle)
}

@Test
fun `parseThemeTokens should return null chat when behavior chat is absent`() {
val json = """
{
"behavior": {
"enableDarkMode": true
}
}
""".trimIndent()

val tokens = ThemeParser.parseThemeTokens(json)

assertNull(tokens?.behavior?.chat)
}

@Test
fun `parseThemeTokens should parse assets section with all icons`() {
val json = """
Expand Down
7 changes: 4 additions & 3 deletions code/testapp/src/main/assets/themeDemo.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
},
"chat": {
"messageAlignment": "left",
"messageWidth": "100%"
"messageWidth": "100%",
"userMessageBubbleStyle": "balloon"
},
"privacyNotice": {
"title": "Privacy Notice",
Expand Down Expand Up @@ -218,7 +219,7 @@
"--main-container-bottom-background": "#F2ECE3",
"--message-blocker-background": "#00000066",
"--message-blocker-height": "105px",
"--message-border-radius": "10px",
"--message-border-radius": "15px",
"--message-concierge-background": "#F0E8DC",
"--message-concierge-link-color": "#8B6914",
"--message-concierge-text": "#2C2419",
Expand Down Expand Up @@ -264,7 +265,7 @@
"--welcome-content-padding": "16px",
"--welcome-input-order": "3",
"--welcome-prompt-background-color": "#E5D9C8",
"--welcome-prompt-corner-radius": "20px",
"--welcome-prompt-corner-radius": "40px",
"--welcome-prompt-image-size": "32px",
"--welcome-prompt-padding": "12px",
"--welcome-prompt-spacing": "8px",
Expand Down
Loading