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
2 changes: 1 addition & 1 deletion sdk/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ plugins {
alias(libs.plugins.vanniktech.maven.publish)
}

version = "0.1.0"
version = "0.1.1"

kotlin {
explicitApi()
Expand Down
21 changes: 16 additions & 5 deletions sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<Color> = 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() }
}
}
}

Expand Down
66 changes: 51 additions & 15 deletions sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Expand All @@ -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)
}
}
}
Loading