diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 41634ea..daead2f 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -11,7 +11,7 @@ plugins { alias(libs.plugins.vanniktech.maven.publish) } -version = "0.1.0" +version = "0.1.1" kotlin { explicitApi() diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt index a3680aa..79f8b4b 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt @@ -1,26 +1,37 @@ package com.chatwoot.android.sdk +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import com.chatwoot.android.sdk.style.DefaultStyle import com.chatwoot.android.sdk.style.StyleConfig import com.chatwoot.android.sdk.ui.ChatScreen /** - * The Chatwoot chat screen. + * The Chatwoot chat screen — plain, embeddable content. * * Requires the SDK to be configured first — see [Chatwoot.configure]. * - * @param show Whether the page is visible. When false, nothing is composed and the - * chat session (including the websocket) is torn down. - * @param onFinish Called when the user closes the chat via the header close button. + * The SDK deliberately does **not** own a window/sheet. To present the chat in a bottom sheet, + * dialog, side panel, etc., wrap this composable in the host's own container and size it with + * [modifier]; the chat then lives in the host's window and inherits its keyboard/inset handling + * (no separate window to fight). When the host's container already lifts itself above the keyboard + * (e.g. `adjustResize`), set [StyleConfig.insetsHandledByHost]; otherwise leave it off. + * + * @param show Whether the page is visible. When false, nothing is composed and the chat session + * (including the websocket) is torn down. + * @param onFinish Called when the user closes the chat. Map this to your container's dismiss. * @param styleConfig Visual customisation; defaults to the stock Chatwoot look. + * @param modifier Sizes/positions the chat within the host container. Defaults to filling the + * parent; pass e.g. `Modifier.fillMaxWidth().fillMaxHeight(0.9f)` for a bottom sheet. */ @Composable public fun ChatPage( show: Boolean, onFinish: () -> Unit, styleConfig: StyleConfig = DefaultStyle, + modifier: Modifier = Modifier.fillMaxSize(), ) { if (!show) return - ChatScreen(onFinish = onFinish, style = styleConfig) + ChatScreen(onFinish = onFinish, style = styleConfig, modifier = modifier) } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt index 4e88807..5459d5b 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt @@ -1,41 +1,95 @@ package com.chatwoot.android.sdk.style import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** * Visual customisation for [com.chatwoot.android.sdk.ChatPage]. All theming flows through * this one object; pass a copy of [DefaultStyle] with overrides. * - * @property primaryColor Brand color — fills the header bar and tints the send button. + * The stock look is a dark, translucent-bubble theme with a vertical-gradient backdrop, a + * dashed outline around each bubble, and a rounded "pill" input bar with a circular send + * button — designed to sit inside a dark host UI. + * + * @property primaryColor Header bar fill. * @property onPrimaryColor Content (title, close icon) drawn on top of [primaryColor]. - * @property backgroundColor Background of the message list area. - * @property surfaceColor Background of the input bar at the bottom. + * @property backgroundColor Solid fallback for the message list when [backgroundGradient] is empty. + * @property backgroundGradient Top→bottom gradient painted behind the message list. Empty = use + * [backgroundColor]. + * @property surfaceColor Background of the input-bar area at the bottom. * @property outgoingBubbleColor Bubble fill for messages sent by the contact (the app user). * @property onOutgoingBubbleColor Text color inside outgoing bubbles. * @property incomingBubbleColor Bubble fill for agent/bot messages. * @property onIncomingBubbleColor Text color inside incoming bubbles. + * @property bubbleBorderColor Dashed outline drawn around every bubble; use [Color.Transparent] + * to disable. * @property textColor Primary text color, used for the message input. * @property secondaryTextColor De-emphasised text: input placeholder, typing indicator, * activity messages (e.g. "Conversation was resolved"). + * @property inputFieldColor Fill of the rounded input "pill". + * @property inputBorderColor Outline of the input pill. + * @property inputShape Shape clipping the input pill. + * @property inputMinHeight Resting height of the input "pill". It still grows for multi-line input. + * @property inputHint Placeholder text shown in the message field while it is empty. + * @property accentColor Fill of the circular send button (and the text cursor). + * @property onAccentColor Content (the send arrow) drawn on top of [accentColor]. * @property bubbleShape Shape clipping every message bubble. + * @property fontFamily Font applied to all body text. Null = platform default. + * @property titleFontFamily Font for the header title. Null = falls back to [fontFamily]. + * @property cancelIcon Optional header close icon. Null = default close glyph. + * @property attachmentIcon Optional override for the attachment ("+") button inside the input + * pill. Null = a default "+" glyph. + * @property sendIcon Optional override for the glyph inside the circular send button. Null = a + * built-in up-arrow drawn on a canvas. + * @property micIcon Optional override for the glyph inside the circular voice-note button (shown + * when the input is empty and mic permission is available). Null = a built-in "voice levels" + * glyph (three vertical bars, the middle one elongated) drawn on a canvas, matching the send + * button's white-circle treatment. + * @property insetsHandledByHost Set true when the host's View layer already applies the system-bar + * and keyboard insets — e.g. an Activity using `adjustResize` with a `fitsSystemWindows` root. The + * SDK then consumes no window insets of its own (neither the IME on the input bar nor the status + * bar on the header), avoiding double insets. Leave false (default) for edge-to-edge Android hosts + * and iOS, where Compose applies the insets. * @property title Header title of the chat page. + * @property timeFormatter Optional per-message time formatter. Null = no time label. + * @property dateSeparatorFormatter Optional day-separator formatter. Null = no day separators. */ public data class StyleConfig( - val primaryColor: Color = Color(0xFF1F93FF), + val primaryColor: Color = Color(0xFF252A3D), val onPrimaryColor: Color = Color.White, - val backgroundColor: Color = Color(0xFFF7F8FA), - val surfaceColor: Color = Color.White, - val outgoingBubbleColor: Color = Color(0xFF1F93FF), - val onOutgoingBubbleColor: Color = Color.White, - val incomingBubbleColor: Color = Color.White, - val onIncomingBubbleColor: Color = Color(0xFF1B1B33), - val textColor: Color = Color(0xFF1B1B33), - val secondaryTextColor: Color = Color(0xFF6E7191), - val bubbleShape: Shape = RoundedCornerShape(12.dp), + val backgroundColor: Color = Color(0xFF15161A), + val backgroundGradient: List = listOf(Color(0xFF252A3D), Color(0xFF15161A)), + val surfaceColor: Color = Color(0xFF131518), + val outgoingBubbleColor: Color = Color(0x4D165324), + val onOutgoingBubbleColor: Color = Color(0xCCFFFFFF), + val incomingBubbleColor: Color = Color(0x4D15161A), + val onIncomingBubbleColor: Color = Color(0xCCFFFFFF), + val bubbleBorderColor: Color = Color(0x1FFFFFFF), + val textColor: Color = Color(0xB3FFFFFF), + val secondaryTextColor: Color = Color(0x99FFFFFF), + val inputFieldColor: Color = Color(0xBF0D1219), + val inputBorderColor: Color = Color(0x1AFFFFFF), + val inputShape: Shape = RoundedCornerShape(34.dp), + val inputMinHeight: Dp = 52.dp, + val inputHint: String = "Message…", + val accentColor: Color = Color.White, + val onAccentColor: Color = Color(0xFF121922), + val bubbleShape: Shape = RoundedCornerShape(22.dp), + val fontFamily: FontFamily? = null, + val titleFontFamily: FontFamily? = null, + val cancelIcon: (@Composable () -> Unit)? = null, + val attachmentIcon: (@Composable () -> Unit)? = null, + val sendIcon: (@Composable () -> Unit)? = null, + val micIcon: (@Composable () -> Unit)? = null, + val insetsHandledByHost: Boolean = false, val title: String = "Chat with us", + val timeFormatter: ((epochSeconds: Long) -> String)? = null, + val dateSeparatorFormatter: ((epochSeconds: Long) -> String)? = null, ) /** The stock Chatwoot look. */ diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt index a263ac0..84907fe 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import coil3.compose.AsyncImage import com.chatwoot.android.sdk.data.ChatAttachment import com.chatwoot.android.sdk.data.ChatMessage @@ -178,11 +179,21 @@ private fun UploadingAttachment(message: ChatMessage, style: StyleConfig, onCont @Composable private fun FullScreenViewer(onDismiss: () -> Unit, content: @Composable () -> Unit) { - Dialog(onDismissRequest = onDismiss) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + // Dim scrim fills the screen and dismisses on tap; the media is inset with margins so it + // shows as a centered overlay instead of stretching edge-to-edge. Box( - modifier = Modifier.fillMaxSize().background(Color(0xEE000000)).clickable(onClick = onDismiss), + modifier = Modifier.fillMaxSize().background(Color(0xCC000000)).clickable(onClick = onDismiss), contentAlignment = Alignment.Center, - ) { content() } + ) { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { content() } + } } } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt index e1cc8a1..e478683 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt @@ -1,51 +1,82 @@ package com.chatwoot.android.sdk.ui import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chatwoot.android.sdk.style.StyleConfig +private val HeaderCloseButtonSize = 50.dp + @Composable internal fun ChatHeader(style: StyleConfig, connected: Boolean, onFinish: () -> Unit) { - Row( + Box( modifier = Modifier .fillMaxWidth() .background(style.primaryColor) - // Fill behind the status bar (no gap above the header); content sits below it. - .windowInsetsPadding(WindowInsets.statusBars) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + // Fill behind the status bar (no gap above the header); content sits below it. Skip this + // inset when the host already applies it (insetsHandledByHost) to avoid double padding. + .then(if (style.insetsHandledByHost) Modifier else Modifier.windowInsetsPadding(WindowInsets.statusBars)) + .padding(start = 16.dp, end = 0.dp, top = 0.dp, bottom = 0.dp), ) { - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(end = HeaderCloseButtonSize + 8.dp), + horizontalAlignment = Alignment.Start, + ) { Text( text = style.title, color = style.onPrimaryColor, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, + fontSize = 19.sp, + fontWeight = FontWeight.Bold, + lineHeight = 19.sp, + letterSpacing = 0.sp, + textAlign = TextAlign.Start, + fontFamily = style.titleFontFamily ?: style.fontFamily, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) if (!connected) { Text( text = "connecting…", - color = style.onPrimaryColor.copy(alpha = 0.7f), + color = style.onPrimaryColor.copy(alpha = 0.6f), fontSize = 12.sp, + fontFamily = style.fontFamily, ) } } - IconButton(onClick = onFinish) { - Text(text = "✕", color = style.onPrimaryColor, fontSize = 18.sp) + IconButton( + onClick = onFinish, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(HeaderCloseButtonSize), + ) { + val cancelIcon = style.cancelIcon + if (cancelIcon != null) { + cancelIcon() + } else { + Text(text = "✕", color = style.onPrimaryColor, fontSize = 18.sp) + } } } } @@ -55,18 +86,23 @@ internal fun ErrorBanner(error: String, style: StyleConfig, onDismiss: () -> Uni Row( modifier = Modifier .fillMaxWidth() - .background(androidx.compose.ui.graphics.Color(0xFFFFE5E5)) - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 12.dp, vertical = 6.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0x4D5A1A1A)) + .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = error, - color = androidx.compose.ui.graphics.Color(0xFFB3261E), + color = Color(0xFFFF8A80), fontSize = 13.sp, + fontFamily = style.fontFamily, + maxLines = 3, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) IconButton(onClick = onDismiss) { - Text(text = "✕", color = androidx.compose.ui.graphics.Color(0xFFB3261E), fontSize = 14.sp) + Text(text = "✕", color = Color(0xFFFF8A80), fontSize = 14.sp) } } } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt index 3d6e57b..8127e2f 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt @@ -1,29 +1,29 @@ package com.chatwoot.android.sdk.ui +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.ImageLoader @@ -32,13 +32,14 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory import com.chatwoot.android.sdk.ChatViewModel import com.chatwoot.android.sdk.style.StyleConfig +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun ChatScreen( onFinish: () -> Unit, style: StyleConfig, + modifier: Modifier = Modifier, viewModel: ChatViewModel = viewModel { ChatViewModel() }, ) { - // Coil loads attachment images over the same Ktor stack the SDK already uses (KMP-wide). setSingletonImageLoaderFactory { context -> ImageLoader.Builder(context) .components { add(KtorNetworkFetcherFactory()) } @@ -48,19 +49,36 @@ internal fun ChatScreen( val state by viewModel.state.collectAsStateWithLifecycle() val listState = rememberLazyListState() - // Scroll to the newest message whenever one is appended or replaced (sent OR received). + val dateLabels = remember(state.messages, style.dateSeparatorFormatter) { + style.dateSeparatorFormatter?.let { format -> + state.messages.map { format(it.createdAt) } + } ?: emptyList() + } + LaunchedEffect(state.messages.size, state.messages.lastOrNull()?.id) { if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1) } + val background = remember(style.backgroundGradient, style.backgroundColor) { + if (style.backgroundGradient.size >= 2) { + Brush.verticalGradient(style.backgroundGradient) + } else { + Brush.verticalGradient(listOf(style.backgroundColor, style.backgroundColor)) + } + } + Column( - modifier = Modifier - .fillMaxSize() - .background(style.backgroundColor) - // Header paints its own status-bar inset (edge-to-edge); here we only inset the - // sides + bottom. safeDrawing's bottom already maxes the nav bar and IME, so the - // input bar rises with the keyboard without double-counting. - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)), + modifier = modifier + .background(background) + .then( + if (style.insetsHandledByHost) { + Modifier + } else { + Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom), + ) + }, + ), ) { ChatHeader(style = style, connected = state.connected, onFinish = onFinish) @@ -71,31 +89,29 @@ internal fun ChatScreen( Box(modifier = Modifier.weight(1f).fillMaxWidth()) { if (state.loading) { CircularProgressIndicator( - color = style.primaryColor, + color = style.accentColor, modifier = Modifier.align(Alignment.Center), ) } else { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - items(count = state.messages.size, key = { state.messages[it].id }) { index -> - MessageBubble(message = state.messages[index], style = style) + state.messages.forEachIndexed { index, message -> + val label = dateLabels.getOrNull(index) + if (label != null && label != dateLabels.getOrNull(index - 1)) { + stickyHeader(key = "date_${message.id}") { DateSeparator(text = label, style = style) } + } + item(key = message.id) { MessageBubble(message = message, style = style) } } } } } if (state.agentTyping) { - Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { - Text( - text = "typing…", - color = style.secondaryTextColor, - fontSize = 13.sp, - ) - } + TypingIndicator(style = style) } InputBar( diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatStyleHelpers.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatStyleHelpers.kt new file mode 100644 index 0000000..d3180fa --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatStyleHelpers.kt @@ -0,0 +1,197 @@ +package com.chatwoot.android.sdk.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chatwoot.android.sdk.style.StyleConfig +import kotlin.math.PI +import kotlin.math.roundToInt +import kotlin.math.sin + +/** Rounded dashed outline drawn on top of the content (matches the host "Journey AI" look). */ +internal fun Modifier.dashedBorder(strokeWidth: Dp, color: Color, cornerRadius: Dp): Modifier = + drawWithCache { + val cornerRadiusPx = cornerRadius.toPx() + val stroke = Stroke( + width = strokeWidth.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 8f), 0f), + ) + onDrawWithContent { + drawContent() + drawRoundRect( + color = color, + cornerRadius = CornerRadius(cornerRadiusPx), + style = stroke, + ) + } + } + +private val typingDotColors = listOf( + Color(0xFF00A3FF), + Color(0xFF04FA9C), + Color(0xFFB0FA04), +) + +/** Three sine-wave dots after a label — shown while an agent is typing. */ +@Composable +internal fun TypingIndicator(style: StyleConfig, label: String = "typing") { + val transition = rememberInfiniteTransition(label = "typing") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = (2 * PI).toFloat(), + animationSpec = infiniteRepeatable(tween(durationMillis = 800, easing = LinearEasing)), + label = "phase", + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(text = label, color = style.secondaryTextColor, fontSize = 13.sp, fontFamily = style.fontFamily) + Row( + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp), + ) { + typingDotColors.forEachIndexed { index, color -> + Box( + modifier = Modifier + .offset { + val offsetY = (sin(phase + index * (2f * PI.toFloat() / 3f)) * 6f).roundToInt() + IntOffset(0, offsetY) + } + .size(5.5.dp) + .clip(CircleShape) + .background(color), + ) + } + } + } +} + +/** + * Circular send button. Renders [StyleConfig.sendIcon] when the host supplies one, otherwise a + * canvas-drawn up-arrow (no icon/resource dependency). Dims while [enabled] is false. + */ +@Composable +internal fun SendButton(enabled: Boolean, style: StyleConfig, onClick: () -> Unit) { + val bg = if (enabled) style.accentColor else style.accentColor.copy(alpha = 0.45f) + Box( + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .background(bg) + .clickable(enabled = enabled, onClick = onClick) + .alpha(if (enabled) 1f else 0.6f), + contentAlignment = Alignment.Center, + ) { + val custom = style.sendIcon + if (custom != null) { + custom() + } else { + val fg = style.onAccentColor + Canvas(modifier = Modifier.size(20.dp)) { + val w = size.width + val h = size.height + val stroke = w * 0.12f + val top = Offset(w / 2f, h * 0.18f) + drawLine(fg, Offset(w / 2f, h * 0.82f), top, strokeWidth = stroke, cap = StrokeCap.Round) + drawLine(fg, Offset(w * 0.27f, h * 0.45f), top, strokeWidth = stroke, cap = StrokeCap.Round) + drawLine(fg, Offset(w * 0.73f, h * 0.45f), top, strokeWidth = stroke, cap = StrokeCap.Round) + } + } + } +} + +/** + * Circular voice-note button — the same white-circle treatment as [SendButton], shown when the + * input is empty. Renders [StyleConfig.micIcon] when the host supplies one, otherwise a + * canvas-drawn "voice levels" glyph: three vertical bars with the middle one elongated. + */ +@Composable +internal fun VoiceButton(enabled: Boolean, style: StyleConfig, onClick: () -> Unit) { + val bg = if (enabled) style.accentColor else style.accentColor.copy(alpha = 0.45f) + Box( + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .background(bg) + .clickable(enabled = enabled, onClick = onClick) + .alpha(if (enabled) 1f else 0.6f), + contentAlignment = Alignment.Center, + ) { + val custom = style.micIcon + if (custom != null) { + custom() + } else { + val fg = style.onAccentColor + Canvas(modifier = Modifier.size(20.dp)) { + val w = size.width + val h = size.height + val cx = w / 2f + val mid = h / 2f + val stroke = w * 0.13f + val gap = w * 0.27f + // Two short side bars and a tall middle bar — reads as a voice / audio-levels mark. + val shortHalf = h * 0.16f + val tallHalf = h * 0.36f + drawLine(fg, Offset(cx - gap, mid - shortHalf), Offset(cx - gap, mid + shortHalf), strokeWidth = stroke, cap = StrokeCap.Round) + drawLine(fg, Offset(cx, mid - tallHalf), Offset(cx, mid + tallHalf), strokeWidth = stroke, cap = StrokeCap.Round) + drawLine(fg, Offset(cx + gap, mid - shortHalf), Offset(cx + gap, mid + shortHalf), strokeWidth = stroke, cap = StrokeCap.Round) + } + } + } +} + +/** Centered "Today / Yesterday / date" chip shown above the first message of each day. */ +@Composable +internal fun DateSeparator(text: String, style: StyleConfig) { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = Color(0xB2FFFFFF), + fontSize = 12.sp, + fontFamily = style.fontFamily, + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color(0xFF2B2C30)) + .padding(horizontal = 12.dp, vertical = 4.dp), + ) + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt index 69c115e..88a44a2 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt @@ -1,9 +1,11 @@ package com.chatwoot.android.sdk.ui import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -24,6 +26,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -78,8 +81,6 @@ internal fun InputBar( file ?: return scope.launch { val bytes = file.readBytes() - // Use the platform's real MIME (content resolver / UTType) so Chatwoot classifies the - // attachment correctly; gallery content:// URIs often have no usable extension. val mime = file.mimeType()?.let { "${it.primaryType}/${it.subtype}" } ?: mimeTypeForExtension(file.extension) onPickAttachment(PickedFile(file.name, mime, bytes)) @@ -93,68 +94,93 @@ internal fun InputBar( modifier = Modifier .fillMaxWidth() .background(style.surfaceColor) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 8.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (recording) { - Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFE53935))) - Text( - text = formatElapsed(elapsedMs), - color = style.textColor, - fontSize = 15.sp, - modifier = Modifier.weight(1f).padding(horizontal = 12.dp), - ) - IconButton(onClick = { recording = false; recorder.cancel() }) { - Text(text = "✕", color = style.secondaryTextColor, fontSize = 20.sp) - } - IconButton(onClick = { - recording = false - scope.launch { recorder.stop()?.let(onPickAttachment) } - }) { - Text(text = "➤", color = style.primaryColor, fontSize = 20.sp) - } - return@Row - } - - Box { - IconButton(onClick = { menuOpen = true }, enabled = enabled) { - Text(text = "📎", color = style.secondaryTextColor, fontSize = 20.sp) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( - text = { Text("Photo or video") }, - onClick = { menuOpen = false; mediaPicker.launch() }, + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = style.inputMinHeight) + .border(1.dp, style.inputBorderColor, style.inputShape) + .clip(style.inputShape) + .background(style.inputFieldColor) + .padding(start = 6.dp, end = 12.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (recording) { + Box( + modifier = Modifier + .padding(start = 8.dp) + .size(10.dp) + .clip(CircleShape) + .background(Color(0xFFE53935)), ) - DropdownMenuItem( - text = { Text("File") }, - onClick = { menuOpen = false; filePicker.launch() }, + Text( + text = formatElapsed(elapsedMs), + color = style.textColor, + fontSize = 15.sp, + fontFamily = style.fontFamily, + modifier = Modifier.weight(1f).padding(horizontal = 12.dp), ) + IconButton(onClick = { recording = false; recorder.cancel() }) { + Text(text = "✕", color = style.secondaryTextColor, fontSize = 20.sp) + } + SendButton(enabled = enabled, style = style, onClick = { + recording = false + scope.launch { recorder.stop()?.let(onPickAttachment) } + }) + return@Row } - } - BasicTextField( - value = text, - onValueChange = { text = it }, - enabled = enabled, - maxLines = 5, - textStyle = TextStyle(color = style.textColor, fontSize = 15.sp), - decorationBox = { innerTextField -> - if (text.isEmpty()) { - Text(text = "Type your message…", color = style.secondaryTextColor, fontSize = 15.sp) + + Box { + IconButton(onClick = { menuOpen = true }, enabled = enabled) { + val attachmentIcon = style.attachmentIcon + if (attachmentIcon != null) { + attachmentIcon() + } else { + Text(text = "+", color = style.secondaryTextColor, fontSize = 24.sp, fontFamily = style.fontFamily) + } + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Photo or video") }, + onClick = { menuOpen = false; mediaPicker.launch() }, + ) + DropdownMenuItem( + text = { Text("File") }, + onClick = { menuOpen = false; filePicker.launch() }, + ) } - innerTextField() - }, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - ) - when { - text.isNotBlank() -> IconButton(onClick = ::submit, enabled = enabled) { - Text(text = "➤", color = style.primaryColor, fontSize = 20.sp) } - // Mic shows while permission is grantable; a denial hides it (no error surfaced). - !mic.denied -> IconButton( - onClick = { if (mic.granted) recorder.start().also { recording = true } else mic.request() }, + + BasicTextField( + value = text, + onValueChange = { text = it }, enabled = enabled, - ) { - Text(text = "🎤", color = style.secondaryTextColor, fontSize = 20.sp) + maxLines = 3, + textStyle = TextStyle(color = style.textColor, fontSize = 16.sp, fontFamily = style.fontFamily), + cursorBrush = SolidColor(style.accentColor), + decorationBox = { innerTextField -> + if (text.isEmpty()) { + Text( + text = style.inputHint, + color = style.secondaryTextColor, + fontSize = 16.sp, + fontFamily = style.fontFamily, + ) + } + innerTextField() + }, + modifier = Modifier.weight(1f).padding(horizontal = 6.dp), + ) + + when { + text.isNotBlank() -> SendButton(enabled = enabled, style = style, onClick = ::submit) + else -> VoiceButton( + enabled = enabled && !mic.denied, + style = style, + onClick = { if (mic.granted) recorder.start().also { recording = true } else mic.request() }, + ) } } } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt index bd302c5..a75feef 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt @@ -4,20 +4,34 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chatwoot.android.sdk.data.AttachmentType import com.chatwoot.android.sdk.data.ChatMessage import com.chatwoot.android.sdk.style.StyleConfig +private val BubbleCorner = 22.dp + @Composable internal fun MessageBubble(message: ChatMessage, style: StyleConfig) { if (message.isActivity) { @@ -26,6 +40,7 @@ internal fun MessageBubble(message: ChatMessage, style: StyleConfig) { text = message.content, color = style.secondaryTextColor, fontSize = 12.sp, + fontFamily = style.fontFamily, modifier = Modifier.padding(vertical = 2.dp), ) } @@ -35,7 +50,6 @@ internal fun MessageBubble(message: ChatMessage, style: StyleConfig) { val fromContact = message.fromContact val hasText = message.content.isNotBlank() val hasAttachments = message.attachments.isNotEmpty() || message.pending || message.failed - // Standalone images/video render without bubble chrome; text, audio and files keep the bubble. val bareMedia = !hasText && hasAttachments && (message.pending || message.failed || message.attachments.all { it.type == AttachmentType.Image || it.type == AttachmentType.Video }) @@ -50,22 +64,78 @@ internal fun MessageBubble(message: ChatMessage, style: StyleConfig) { Box( modifier = Modifier .widthIn(max = 300.dp) - .clip(style.bubbleShape) + .clip(RoundedCornerShape(BubbleCorner)) .background(if (fromContact) style.outgoingBubbleColor else style.incomingBubbleColor) - .padding(horizontal = 14.dp, vertical = 10.dp), + .dashedBorder(1.dp, style.bubbleBorderColor, BubbleCorner) + .padding(12.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + val time = style.timeFormatter?.invoke(message.createdAt) if (hasText) { - Text( - text = message.content, - color = if (fromContact) style.onOutgoingBubbleColor else style.onIncomingBubbleColor, - fontSize = 15.sp, - ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + val contentColor = if (fromContact) style.onOutgoingBubbleColor else style.onIncomingBubbleColor + val annotatedContent = remember(message.content, contentColor) { + linkify(message.content, contentColor) + } + Text( + text = annotatedContent, + color = contentColor, + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = style.fontFamily, + ) + time?.let { + Text( + text = it, + color = style.secondaryTextColor, + fontSize = 10.sp, + fontWeight = FontWeight.Light, + fontFamily = style.fontFamily, + modifier = Modifier.align(Alignment.Bottom), + ) + } + } } if (hasAttachments) { AttachmentContent(message = message, style = style, onContact = fromContact) } + if (!hasText && time != null) { + Text( + text = time, + color = style.secondaryTextColor, + fontSize = 10.sp, + fontWeight = FontWeight.Light, + fontFamily = style.fontFamily, + modifier = Modifier.align(Alignment.End), + ) + } } } } } + +private val UrlRegex = Regex("""(?:https?://|www\.)[^\s]+""", RegexOption.IGNORE_CASE) + +private fun linkify(text: String, linkColor: Color): AnnotatedString = buildAnnotatedString { + var index = 0 + for (match in UrlRegex.findAll(text)) { + append(text.substring(index, match.range.first)) + val raw = match.value + val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '}', '"', '\'') + val href = if (url.startsWith("www.", ignoreCase = true)) "https://$url" else url + withLink( + LinkAnnotation.Url( + href, + TextLinkStyles(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)), + ), + ) { + append(url) + } + append(raw.substring(url.length)) + index = match.range.last + 1 + } + append(text.substring(index)) +}