diff --git a/.gitignore b/.gitignore index adfa9bf..ec32c18 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .kotlin .gradle **/build/ +**/.build/ xcuserdata !src/**/build/ local.properties diff --git a/cards-android/build.gradle.kts b/cards-android/build.gradle.kts new file mode 100644 index 0000000..b07adef --- /dev/null +++ b/cards-android/build.gradle.kts @@ -0,0 +1,53 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } +} + +dependencies { + // api: CardView's public signature takes a CardEngine directly (see the M-Cards plan's + // "Architecture" section - cards-core has no :core dependency, so this module doesn't + // either), and ThreadwireColors/Typography appear in this module's own composables too. + api(projects.cardsCore) + api(projects.designTokensAndroid) + + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.materialIconsCore) + implementation(libs.compose.ui) + implementation(libs.androidx.lifecycle.runtimeCompose) + // TextBlock/FactSet/etc. render inline markdown, same library :ui-android already uses for + // message text - every title/subtitle/fact value in the source design does too. + implementation(libs.mikepenz.markdown.core) + implementation(libs.mikepenz.markdown.m3) + // Image/Avatar - new dependency, this module's first need for network image loading. + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + // Media (video card) - real playback per the M-Cards plan, not an inert placeholder. + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + + debugImplementation(libs.compose.uiTooling) +} + +android { + namespace = "com.fsk.threadwire.cards.ui" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardActionViews.kt b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardActionViews.kt new file mode 100644 index 0000000..504f595 --- /dev/null +++ b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardActionViews.kt @@ -0,0 +1,79 @@ +package com.fsk.threadwire.cards.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fsk.threadwire.cards.CardEngine +import com.fsk.threadwire.cards.schema.ActionStyle +import com.fsk.threadwire.cards.schema.AfterSelection +import com.fsk.threadwire.cards.schema.CardAction +import com.fsk.threadwire.cards.schema.CardElement +import com.fsk.threadwire.designtokens.ThreadwireTheme + +/** + * [CardElement.ActionSet.afterSelection] is two independent visual behaviors, not one, matching + * the source design exactly: [AfterSelection.HIDE_UNSELECTED] (`choices`) keeps only the fired + * action's button once one has answered; [AfterSelection.MARK_SELECTED] (`carousel`) keeps every + * button visible and only relabels the fired one via [CardAction.doneTitle]. Each button's + * enabled state is [CardRuntimeState.canInvoke][com.fsk.threadwire.cards.CardRuntimeState.canInvoke] + * directly - that already correctly stays `true` indefinitely for actions with no `doneTitle` + * (rating, choices, carousel, poll all remain re-tappable) and flips to `false` permanently once + * a one-shot (`doneTitle`-bearing) action has fired. + */ +@Composable +internal fun ActionSetView(element: CardElement.ActionSet, engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + val answered = element.actions.firstOrNull { it.id in state.answeredActionIds } + + val visibleActions = if (element.afterSelection == AfterSelection.HIDE_UNSELECTED && answered != null) { + listOf(answered) + } else { + element.actions + } + + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + visibleActions.forEach { action -> + val isAnswered = action.id in state.answeredActionIds + // Local val, not a direct action.doneTitle != null check: smart-casting a nullable + // property declared in a different module (CardAction lives in :cards-core) isn't + // allowed, so the null-check has to happen against a local copy. + val doneTitle = action.doneTitle + val label = if (isAnswered && doneTitle != null) doneTitle else action.title + ActionButton( + label = label, + style = action.style, + enabled = state.canInvoke(action), + onClick = { engine.invoke(action) }, + ) + } + } +} + +@Composable +private fun ActionButton(label: String, style: ActionStyle, enabled: Boolean, onClick: () -> Unit) { + when (style) { + ActionStyle.DESTRUCTIVE -> Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = ThreadwireTheme.colors.destructive), + contentPadding = ButtonDefaults.ContentPadding, + ) { Text(label) } + + ActionStyle.POSITIVE -> Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = ThreadwireTheme.colors.accent), + ) { Text(label) } + + ActionStyle.DEFAULT -> OutlinedButton(onClick = onClick, enabled = enabled) { Text(label) } + } +} diff --git a/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardContainerViews.kt b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardContainerViews.kt new file mode 100644 index 0000000..89a4e44 --- /dev/null +++ b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardContainerViews.kt @@ -0,0 +1,65 @@ +package com.fsk.threadwire.cards.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.fsk.threadwire.cards.CardEngine +import com.fsk.threadwire.cards.schema.CardElement +import com.fsk.threadwire.cards.schema.ContainerStyle +import com.fsk.threadwire.designtokens.ThreadwireTheme + +@Composable +internal fun ContainerView(element: CardElement.Container, engine: CardEngine, modifier: Modifier = Modifier) { + val emphasized = element.style == ContainerStyle.EMPHASIS + Column( + modifier = modifier + .fillMaxWidth() + .then( + if (emphasized) { + Modifier + .background(ThreadwireTheme.colors.surfaceAlt, RoundedCornerShape(12.dp)) + .padding(12.dp) + } else { + Modifier + }, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + element.items.forEach { child -> ElementView(element = child, engine = engine) } + } +} + +/** All columns get equal width ([Modifier.weight]`(1f)`) regardless of [CardElement.ColumnWidth] - + * a v1 simplification. Every design example ([weather]'s 3-day forecast) only ever needs equal + * columns; true `AUTO` (size-to-content) sizing is deferred until a real card actually needs it. */ +@Composable +internal fun ColumnSetView(element: CardElement.ColumnSet, engine: CardEngine, modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + element.columns.forEach { column -> + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + column.items.forEach { child -> ElementView(element = child, engine = engine) } + } + } + } +} + +@Composable +internal fun CarouselView(element: CardElement.Carousel, engine: CardEngine, modifier: Modifier = Modifier) { + if (element.pages.isEmpty()) return + val pagerState = rememberPagerState(pageCount = { element.pages.size }) + HorizontalPager(state = pagerState, modifier = modifier.fillMaxWidth(), pageSpacing = 12.dp) { page -> + ContainerView( + element = element.pages[page].copy(style = ContainerStyle.EMPHASIS), + engine = engine, + ) + } +} diff --git a/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardDisplayViews.kt b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardDisplayViews.kt new file mode 100644 index 0000000..62e530b --- /dev/null +++ b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardDisplayViews.kt @@ -0,0 +1,232 @@ +package com.fsk.threadwire.cards.ui + +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.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.fsk.threadwire.cards.schema.CardElement +import com.fsk.threadwire.cards.schema.ImageSize +import com.fsk.threadwire.cards.schema.TextColor +import com.fsk.threadwire.cards.schema.TextSize +import com.fsk.threadwire.cards.schema.TextWeight +import com.fsk.threadwire.designtokens.ThreadwireTheme +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.model.rememberMarkdownState + +/** Every title/subtitle/fact value in the source design renders inline markdown (bold/italic), + * not plain text - see the M-Cards plan's finding on `mdSpans`. Size/weight/color map onto the + * design's font scale and `ThreadwireColors`; [TextColor.GOOD]/[WARNING] aren't part of that + * token set (no card in the design needed them at rest, but Adaptive Cards' own vocabulary + * includes them for forward-compat) - standard semantic green/amber, not extracted from the + * design. */ +@Composable +internal fun TextBlockView(element: CardElement.TextBlock, modifier: Modifier = Modifier) { + val fontSize = when (element.size) { + TextSize.SMALL -> 12.5.sp + TextSize.DEFAULT -> 15.5.sp + TextSize.MEDIUM -> 16.sp + TextSize.LARGE -> 20.sp + TextSize.EXTRA_LARGE -> 23.sp + } + val fontWeight = when (element.weight) { + TextWeight.LIGHTER -> FontWeight.Light + TextWeight.DEFAULT -> FontWeight.Normal + TextWeight.BOLDER -> FontWeight.Bold + } + val color = when (element.color) { + TextColor.DEFAULT -> ThreadwireTheme.colors.text + TextColor.DARK -> ThreadwireTheme.colors.text + TextColor.LIGHT -> ThreadwireTheme.colors.textSecondary + TextColor.ACCENT -> ThreadwireTheme.colors.accent + TextColor.GOOD -> Color(0xFF3A8A4C) + TextColor.WARNING -> Color(0xFFB8860B) + TextColor.ATTENTION -> ThreadwireTheme.colors.destructive + } + val markdownState = rememberMarkdownState(element.text, retainState = true) + ProvideTextStyle(MaterialTheme.typography.bodyMedium.copy(fontSize = fontSize, fontWeight = fontWeight)) { + Markdown( + markdownState, + colors = markdownColor(text = color, codeBackground = ThreadwireTheme.colors.code, inlineCodeBackground = ThreadwireTheme.colors.code), + modifier = modifier, + ) + } +} + +@Composable +internal fun ImageView(element: CardElement.Image, modifier: Modifier = Modifier) { + val heightDp = when (element.size) { + ImageSize.SMALL -> 80.dp + ImageSize.MEDIUM -> 140.dp + ImageSize.LARGE -> 220.dp + } + AsyncImage( + model = element.url, + contentDescription = element.altText, + contentScale = ContentScale.Crop, + modifier = modifier + .fillMaxWidth() + .height(heightDp) + .clip(RoundedCornerShape(12.dp)) + .background(ThreadwireTheme.colors.surfaceAlt), + ) +} + +/** Circular avatar - [CardElement.Avatar.imageUrl] wins when present; otherwise derives initials + * from [CardElement.Avatar.name] the same way the source design does client-side (a + * presentation-only derivation, not a computed-value mechanism in the engine itself - see + * `CardElement.Avatar`'s KDoc). */ +@Composable +internal fun AvatarView(element: CardElement.Avatar, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(ThreadwireTheme.colors.accent), + contentAlignment = Alignment.Center, + ) { + if (element.imageUrl != null) { + AsyncImage( + model = element.imageUrl, + contentDescription = element.name, + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp).clip(CircleShape), + ) + } else { + Text( + text = initialsOf(element.name), + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + } + } +} + +private fun initialsOf(name: String): String = + name.split(" ").filter { it.isNotBlank() }.take(2).mapNotNull { it.firstOrNull()?.uppercaseChar() }.joinToString("") + +/** Real playback (androidx.media3 ExoPlayer), not an inert placeholder - see the M-Cards plan's + * "Video" section. Shows the poster with a play affordance until tapped, then swaps in a real + * player - avoids paying player-init cost for every video card that's merely scrolled past. + * Player lifecycle (`DisposableEffect` releasing on leaving composition) lives in + * [ExoPlayerSurface] so a player never leaks when this card scrolls away mid-playback. */ +@Composable +internal fun MediaView(element: CardElement.Media, modifier: Modifier = Modifier) { + var isPlaying by remember { mutableStateOf(false) } + Column(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)) + .background(ThreadwireTheme.colors.surfaceAlt) + .then(if (!isPlaying) Modifier.clickable { isPlaying = true } else Modifier), + contentAlignment = Alignment.Center, + ) { + if (isPlaying) { + ExoPlayerSurface(url = element.url, modifier = Modifier.fillMaxWidth()) + } else { + if (element.posterUrl != null) { + AsyncImage( + model = element.posterUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + ) + } + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.55f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = "Play video", tint = Color.White) + } + } + } + val duration = element.duration // local val - cross-module nullable smart-cast isn't allowed + if (duration != null) { + Text( + text = duration, + style = ThreadwireTheme.typography.meta, + color = ThreadwireTheme.colors.textSecondary, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +internal fun FactSetView(element: CardElement.FactSet, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + element.facts.forEach { fact -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(fact.title, style = ThreadwireTheme.typography.meta, color = ThreadwireTheme.colors.textSecondary) + Text(fact.value, style = ThreadwireTheme.typography.meta, color = ThreadwireTheme.colors.text, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +internal fun StepperView(element: CardElement.Stepper, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + element.steps.forEach { step -> + val dotColor = when (step.status) { + CardElement.StepStatus.DONE -> ThreadwireTheme.colors.accent + CardElement.StepStatus.CURRENT -> ThreadwireTheme.colors.accent + CardElement.StepStatus.UPCOMING -> ThreadwireTheme.colors.border + } + val labelColor = if (step.status == CardElement.StepStatus.UPCOMING) ThreadwireTheme.colors.textTertiary else ThreadwireTheme.colors.text + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(dotColor)) + Text(step.label, style = ThreadwireTheme.typography.msg, color = labelColor) + } + } + } +} + +@Composable +internal fun UnsupportedElementView(element: CardElement.Unsupported, modifier: Modifier = Modifier) { + Text( + text = "Unsupported: ${element.rawType}", + style = ThreadwireTheme.typography.meta, + color = ThreadwireTheme.colors.textTertiary, + modifier = modifier + .background(ThreadwireTheme.colors.surfaceAlt, RoundedCornerShape(8.dp)) + .padding(PaddingValues(horizontal = 10.dp, vertical = 6.dp)), + ) +} diff --git a/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardInputViews.kt b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardInputViews.kt new file mode 100644 index 0000000..d7c49e4 --- /dev/null +++ b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardInputViews.kt @@ -0,0 +1,283 @@ +package com.fsk.threadwire.cards.ui + +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.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fsk.threadwire.cards.CardEngine +import com.fsk.threadwire.cards.schema.CardElement +import com.fsk.threadwire.cards.schema.ChoicePresentation +import com.fsk.threadwire.designtokens.ThreadwireTheme +import java.text.SimpleDateFormat +import java.util.Locale + +@Composable +internal fun InputTextView(element: CardElement.InputText, engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + val value = state.values[element.id] ?: element.value + OutlinedTextField( + value = value, + onValueChange = { engine.setValueAndTrigger(element.id, it) }, + label = { Text(element.label) }, + placeholder = element.placeholder?.let { p -> { Text(p) } }, + singleLine = true, + modifier = modifier.fillMaxWidth(), + ) +} + +@Composable +internal fun InputToggleView(element: CardElement.InputToggle, engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + val checked = state.values[element.id]?.toBooleanStrictOrNull() ?: element.value + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth().clickable { engine.setValueAndTrigger(element.id, (!checked).toString()) }, + ) { + Checkbox(checked = checked, onCheckedChange = { engine.setValueAndTrigger(element.id, it.toString()) }) + Text(element.label, style = ThreadwireTheme.typography.msg, color = ThreadwireTheme.colors.text) + } +} + +/** + * Renders all three [ChoicePresentation] modes and both single/multi-select. Always calls + * [CardEngine.setValueAndTrigger]/[CardEngine.toggleSelection] rather than plain `setValue` - + * `setValueAndTrigger` is a safe superset (it only fires a paired immediate action when one + * actually exists via `CardAction.triggerInputId`; otherwise it's identical to `setValue`), so + * the renderer never has to know whether this particular choice set happens to be wired to one + * (`poll`) or not (`form`'s Guests picker). + */ +@Composable +internal fun InputChoiceSetView(element: CardElement.InputChoiceSet, engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + val selectedSingle = state.values[element.id] ?: element.value + val selectedMulti = state.selections[element.id].orEmpty() + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + element.label?.let { Text(it, style = ThreadwireTheme.typography.meta, color = ThreadwireTheme.colors.textSecondary) } + when (element.presentation) { + ChoicePresentation.POLL_BAR -> { + val voted = selectedSingle != null + element.choices.forEach { choice -> + PollBarRow( + choice = choice, + selected = choice.value == selectedSingle, + voted = voted, + onVote = { if (!voted) engine.setValueAndTrigger(element.id, choice.value) }, + ) + } + } + + ChoicePresentation.EXPANDED -> { + element.choices.forEach { choice -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { + if (element.exclusiveSelect) engine.setValueAndTrigger(element.id, choice.value) + else engine.toggleSelection(element.id, choice.value) + }, + ) { + if (element.exclusiveSelect) { + RadioButton(selected = choice.value == selectedSingle, onClick = { engine.setValueAndTrigger(element.id, choice.value) }) + } else { + Checkbox(checked = choice.value in selectedMulti, onCheckedChange = { engine.toggleSelection(element.id, choice.value) }) + } + Text(choice.title, style = ThreadwireTheme.typography.msg, color = ThreadwireTheme.colors.text) + } + } + } + + ChoicePresentation.COMPACT -> { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + element.choices.forEach { choice -> + val selected = if (element.exclusiveSelect) choice.value == selectedSingle else choice.value in selectedMulti + PillButton( + label = choice.title, + selected = selected, + onClick = { + if (element.exclusiveSelect) engine.setValueAndTrigger(element.id, choice.value) + else engine.toggleSelection(element.id, choice.value) + }, + ) + } + } + } + } + } +} + +@Composable +private fun PollBarRow(choice: CardElement.Choice, selected: Boolean, voted: Boolean, onVote: () -> Unit) { + // Local val, not repeated choice.percentage != null checks: smart-casting a nullable + // property declared in a different module (CardElement.Choice lives in :cards-core) isn't + // allowed, so the null-check has to happen once against a local copy. + val percentage = choice.percentage + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) ThreadwireTheme.colors.accent.copy(alpha = 0.12f) else ThreadwireTheme.colors.surfaceAlt) + .then(if (!voted) Modifier.clickable(onClick = onVote) else Modifier) + .padding(10.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(choice.title, style = ThreadwireTheme.typography.msg, color = ThreadwireTheme.colors.text) + if (percentage != null) { + Text("$percentage%", style = ThreadwireTheme.typography.meta, color = ThreadwireTheme.colors.textSecondary) + } + } + if (percentage != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(ThreadwireTheme.colors.border), + ) { + Box( + modifier = Modifier + .fillMaxWidth(percentage / 100f) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(if (selected) ThreadwireTheme.colors.accent else ThreadwireTheme.colors.textTertiary), + ) + } + } + } +} + +@Composable +private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (selected) ThreadwireTheme.colors.accent else ThreadwireTheme.colors.surfaceAlt) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + label, + style = ThreadwireTheme.typography.meta, + color = if (selected) androidx.compose.ui.graphics.Color.White else ThreadwireTheme.colors.text, + ) + } +} + +@Composable +internal fun InputRatingView(element: CardElement.InputRating, engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + val current = state.values[element.id]?.toIntOrNull() ?: element.value ?: 0 + Column(modifier = modifier.fillMaxWidth()) { + element.label?.let { Text(it, style = ThreadwireTheme.typography.meta, color = ThreadwireTheme.colors.textSecondary) } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + (1..element.maxValue).forEach { star -> + Text( + text = if (star <= current) "★" else "☆", + color = if (star <= current) ThreadwireTheme.colors.accent else ThreadwireTheme.colors.textTertiary, + style = ThreadwireTheme.typography.title, + modifier = Modifier.clickable { engine.setValueAndTrigger(element.id, star.toString()) }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InputDateView(element: CardElement.InputDate, engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + val value = state.values[element.id] ?: element.value.orEmpty() + var showPicker by remember { mutableStateOf(false) } + val pickerState = rememberDatePickerState() + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(element.label) }, + placeholder = { Text("Select a date") }, + modifier = modifier.fillMaxWidth().clickable { showPicker = true }, + enabled = false, + ) + if (showPicker) { + DatePickerDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { + TextButton(onClick = { + val millis = pickerState.selectedDateMillis + if (millis != null) { + val formatted = SimpleDateFormat("EEE, MMM d", Locale.getDefault()).format(java.util.Date(millis)) + engine.setValueAndTrigger(element.id, formatted) + } + showPicker = false + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Cancel") } }, + ) { + androidx.compose.material3.DatePicker(state = pickerState) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InputTimeView(element: CardElement.InputTime, engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + val value = state.values[element.id] ?: element.value.orEmpty() + var showPicker by remember { mutableStateOf(false) } + val pickerState = rememberTimePickerState() + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(element.label) }, + placeholder = { Text("Select a time") }, + modifier = modifier.fillMaxWidth().clickable { showPicker = true }, + enabled = false, + ) + if (showPicker) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { + TextButton(onClick = { + val hour = pickerState.hour + val minute = pickerState.minute + val amPm = if (hour < 12) "AM" else "PM" + val hour12 = if (hour % 12 == 0) 12 else hour % 12 + engine.setValueAndTrigger(element.id, "%d:%02d %s".format(hour12, minute, amPm)) + showPicker = false + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Cancel") } }, + text = { TimePicker(state = pickerState) }, + ) + } +} diff --git a/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardVideoPlayer.kt b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardVideoPlayer.kt new file mode 100644 index 0000000..20440a6 --- /dev/null +++ b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardVideoPlayer.kt @@ -0,0 +1,45 @@ +package com.fsk.threadwire.cards.ui + +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView + +/** + * Real ExoPlayer-backed video surface for [CardElement][com.fsk.threadwire.cards.schema.CardElement.Media] + * - see [MediaView]'s KDoc for why this isn't an inert placeholder in v1. + * + * The [ExoPlayer] instance is `remember`ed keyed on [url] (so a `resync`'d card with a new video + * URL gets a fresh player rather than trying to swap the media item on the old one) and released + * in [DisposableEffect]'s `onDispose` - a video card scrolled off-screen mid-playback (recycled + * out of the message `LazyColumn`) must not leak a player. + */ +@Composable +internal fun ExoPlayerSurface(url: String, modifier: Modifier = Modifier) { + val context = LocalContext.current + val exoPlayer = remember(url) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + playWhenReady = true + } + } + DisposableEffect(exoPlayer) { + onDispose { exoPlayer.release() } + } + AndroidView( + modifier = modifier, + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + } + }, + ) +} diff --git a/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardView.kt b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardView.kt new file mode 100644 index 0000000..4382921 --- /dev/null +++ b/cards-android/src/main/kotlin/com/fsk/threadwire/cards/ui/CardView.kt @@ -0,0 +1,59 @@ +package com.fsk.threadwire.cards.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fsk.threadwire.cards.CardEngine +import com.fsk.threadwire.cards.schema.CardElement + +/** + * Entry point: renders the current root of [engine]'s state and recomposes on every state change + * (a tapped action, a `resync` from a server `card-update`, a typed input, ...). + * + * One [CardView] per `MessagePart.Card` - the host looks the [CardEngine] instance up from its + * own `CardEngineStore` rather than constructing one here, so the same engine (and its live + * input state - a half-typed form, a chosen rating) survives scrolling the card off-screen and + * back in a `LazyColumn`. See the M-Cards plan's "Engine lifecycle" section for why that + * matters - this is the same class of bug the M2.6 streaming-markdown fix already had to solve + * once for message bubbles. + */ +@Composable +fun CardView(engine: CardEngine, modifier: Modifier = Modifier) { + val state by engine.state.collectAsStateWithLifecycle() + Column(modifier = modifier.padding(12.dp)) { + ElementView(element = state.root, engine = engine) + } +} + +/** + * Recursive dispatch, one branch per [CardElement] subtype - exhaustive `when`, so adding a new + * element kind to the schema without a matching branch here is a compile error, not a silently + * unrendered element (the same discipline `MessagePartRenderer`'s `when` already applies to + * `MessagePart` in `:ui-android`). + */ +@Composable +internal fun ElementView(element: CardElement, engine: CardEngine, modifier: Modifier = Modifier) { + when (element) { + is CardElement.TextBlock -> TextBlockView(element, modifier) + is CardElement.Image -> ImageView(element, modifier) + is CardElement.Avatar -> AvatarView(element, modifier) + is CardElement.Media -> MediaView(element, modifier) + is CardElement.Container -> ContainerView(element, engine, modifier) + is CardElement.ColumnSet -> ColumnSetView(element, engine, modifier) + is CardElement.FactSet -> FactSetView(element, modifier) + is CardElement.Carousel -> CarouselView(element, engine, modifier) + is CardElement.Stepper -> StepperView(element, modifier) + is CardElement.InputText -> InputTextView(element, engine, modifier) + is CardElement.InputChoiceSet -> InputChoiceSetView(element, engine, modifier) + is CardElement.InputToggle -> InputToggleView(element, engine, modifier) + is CardElement.InputDate -> InputDateView(element, engine, modifier) + is CardElement.InputTime -> InputTimeView(element, engine, modifier) + is CardElement.InputRating -> InputRatingView(element, engine, modifier) + is CardElement.ActionSet -> ActionSetView(element, engine, modifier) + is CardElement.Unsupported -> UnsupportedElementView(element, modifier) + } +} diff --git a/cards-core/build.gradle.kts b/cards-core/build.gradle.kts new file mode 100644 index 0000000..de892b0 --- /dev/null +++ b/cards-core/build.gradle.kts @@ -0,0 +1,58 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidMultiplatformLibrary) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + // Mirrors :core's XCFramework setup (see core/build.gradle.kts's own comment) - produces + // build/XCFrameworks//ThreadwireCards.xcframework, the task + // `assembleThreadwireCardsXCFramework`, referenced by `cards-ios/Package.swift`'s + // binaryTarget once that package exists. + val xcframework = XCFramework("ThreadwireCards") + + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ThreadwireCards" + isStatic = true + xcframework.add(this) + } + } + + androidLibrary { + namespace = "com.fsk.threadwire.cards" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } + // Mirrors :core - runs commonTest on the JVM (androidHostTest) instead of only via the + // iOS simulator's slower Kotlin/Native test runner. CardParserTest/CardEngineTest are + // pure Kotlin with no Android framework dependency, so this is a fast, CI-friendly loop. + withHostTest {} + } + + sourceSets { + commonMain.dependencies { + // Deliberately NO dependency on :core (project.projects.core) - this module is a + // standalone card-rendering engine, usable outside a chat context, with an eye + // toward splitting into its own repo later. CardEngine emits CardIntent values + // (Notify / HostAction); it never touches ChatSession. The adapter that turns a + // CardIntent.Notify into ChatSession.sendMessage lives in ui-android/ui-ios, not + // here - see the M-Cards plan's "Architecture" section. + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardActionHandler.kt b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardActionHandler.kt new file mode 100644 index 0000000..cf81669 --- /dev/null +++ b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardActionHandler.kt @@ -0,0 +1,16 @@ +package com.fsk.threadwire.cards + +/** + * Host-implemented sink for [CardIntent.HostAction] - the genuinely sensitive actions (payment, + * saving a contact) this library deliberately never turns into a synthesized chat message on its + * own. A cards-scoped sibling of design doc §9's `ChatActionHandler` pattern (`:core`'s own + * host-callback for LLM-issued `action.button` taps), not a replacement for it - the two exist + * for different payload shapes (a whole chat turn's actions vs. one card's), but the same + * security principle applies identically: *the card is a UX affordance, never an authorization + * mechanism*. The library makes no network call, doesn't know what "payment" means, and decides + * nothing - the host decides what to do (biometrics, a confirmation screen, its own transaction + * SDK), exactly as it would for any other channel. + */ +fun interface CardActionHandler { + fun handle(cardId: String, actionId: String, data: Map) +} diff --git a/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardEngine.kt b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardEngine.kt new file mode 100644 index 0000000..9b13e4b --- /dev/null +++ b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardEngine.kt @@ -0,0 +1,220 @@ +package com.fsk.threadwire.cards + +import com.fsk.threadwire.cards.schema.ActionMode +import com.fsk.threadwire.cards.schema.CardAction +import com.fsk.threadwire.cards.schema.CardElement +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Runtime state for one rendered card: local input values/selections, which one-shot actions + * have already fired, and the current element tree (replaced wholesale on [resync]). + * + * [values] holds every input kind *except* multi-select choice sets: `Input.Text`/`Input.Date`/ + * `Input.Time` store their raw string, `Input.Rating` stores its int as a string, and a + * single-select `Input.ChoiceSet` (`exclusiveSelect = true`) stores the chosen [CardElement.Choice.value]. + * [selections] holds multi-select choice sets (`exclusiveSelect = false`, e.g. `checklist`) as + * the *set* of currently-chosen choice values for that input id - a shape a single string + * can't represent. + */ +data class CardRuntimeState( + val root: CardElement, + val values: Map = emptyMap(), + val selections: Map> = emptyMap(), + val answeredActionIds: Set = emptySet(), +) { + /** Whether [action] should currently be tappable. Two independent gates: every id in + * [CardAction.enabledWhenInputsFilled] must have a non-empty value/selection (`form`, + * `checklist`, `datetime`); and an action with a [CardAction.doneTitle] is one-shot - once it + * has fired it stays disabled (`payment`'s Pay, `contact`'s Save, `location`'s Confirm). + * Actions *without* a `doneTitle` (rating, choices, carousel, poll) stay tappable + * indefinitely - the source design lets you change a rating or switch your poll vote. */ + fun canInvoke(action: CardAction): Boolean { + if (action.doneTitle != null && action.id in answeredActionIds) return false + return action.enabledWhenInputsFilled.all { id -> isInputFilled(id) } + } + + private fun isInputFilled(id: String): Boolean = + values[id]?.isNotBlank() == true || selections[id]?.isNotEmpty() == true +} + +/** + * One engine per rendered `MessagePart.Card` (owned by a `CardEngineStore` above the message + * list - see the M-Cards plan's "Engine lifecycle" section on why it must not live inside a + * recycled list row). Holds [state] and turns a tapped [CardAction] into a [CardIntent] via + * [onIntent] - this engine never calls a chat session or a network client itself; that is + * deliberately the host's job, not this library's (see [CardIntent]'s KDoc). + * + * No top-level `close()`: each [observeState] call returns its own [CardSubscription] to cancel + * *that* collector, and the default [create] overload's internally-created [scope] is this + * instance's alone to own - but whether tearing it down on card disposal is actually needed (vs. + * just dropping the reference and letting it get collected) is `CardEngineStore`'s call, since it + * is the one that knows the engine's real lifetime, not this class. + */ +class CardEngine private constructor( + val cardId: String, + initialRoot: CardElement, + private val onIntent: (CardIntent) -> Unit, + private val scope: CoroutineScope, +) { + private val _state = MutableStateFlow(CardRuntimeState(root = initialRoot)) + val state: StateFlow = _state.asStateFlow() + + /** For Swift consumers - see [CardSubscription]'s KDoc. Kotlin/Compose consumers should + * collect [state] directly instead. */ + fun observeState(onChange: (CardRuntimeState) -> Unit): CardSubscription { + val job = scope.launch { state.collect { onChange(it) } } + return CardSubscription { job.cancel() } + } + + /** Sets a plain value: `Input.Text`, `Input.Date`, `Input.Time`, `Input.Rating` (pass the + * rating as its string form), or a single-select `Input.ChoiceSet`. */ + fun setValue(inputId: String, value: String) { + _state.update { it.copy(values = it.values + (inputId to value)) } + } + + /** [setValue] plus: if any [CardAction] in the tree is paired with [inputId] via + * [CardAction.triggerInputId] (`immediate = true`), invokes it too, in that order (so + * template filling sees the new value). This is the single entry point for a "tap a star" + * style gesture - `rating`'s stars, `poll`'s options - so renderers on both platforms don't + * each have to independently orchestrate "set the value, then find and fire the paired + * action" themselves. [setValue] alone is still correct for inputs with no paired immediate + * action (a plain text field, `form`'s fields). */ + fun setValueAndTrigger(inputId: String, value: String) { + setValue(inputId, value) + findTriggeredAction(_state.value.root, inputId)?.let(::invoke) + } + + /** Toggles [choiceValue] in a multi-select `Input.ChoiceSet` (`exclusiveSelect = false`, + * e.g. `checklist`) - the analogue of [setValue] for the one input kind whose current value + * isn't a single string. */ + fun toggleSelection(inputId: String, choiceValue: String) { + _state.update { current -> + val existing = current.selections[inputId].orEmpty() + val updated = if (choiceValue in existing) existing - choiceValue else existing + choiceValue + current.copy(selections = current.selections + (inputId to updated)) + } + } + + /** No-ops if [CardRuntimeState.canInvoke] is currently false for [action] - a defensive + * floor independent of whatever UI-layer disabling the renderer applies, in case a renderer + * bug lets a disabled affordance fire anyway. */ + fun invoke(action: CardAction) { + val snapshot = _state.value + if (!snapshot.canInvoke(action)) return + + when (action.mode) { + ActionMode.NOTIFY -> { + val text = action.notifyTemplate?.let { fillTemplate(it, snapshot) } + if (text != null) onIntent(CardIntent.Notify(text)) + } + ActionMode.HOST_ACTION -> onIntent(CardIntent.HostAction(cardId, action.id, flattenValues(snapshot))) + } + + _state.update { it.copy(answeredActionIds = it.answeredActionIds + action.id) } + } + + /** + * Replaces the element tree on a server-driven `card-update` (the wholesale-replace + * semantics already exist in `:core`'s `ChatStateReducer` - this is the `cards-core` side of + * the same refresh). Local [CardRuntimeState.values]/[selections] whose input id is no + * longer present in [newRoot] are dropped; everything else survives - a `progress` card's + * server-pushed step update must not wipe a value the user was typing in an unrelated field + * elsewhere in the same card. + */ + fun resync(newRoot: CardElement) { + _state.update { current -> + val liveIds = collectInputIds(newRoot) + current.copy( + root = newRoot, + values = current.values.filterKeys { it in liveIds }, + selections = current.selections.filterKeys { it in liveIds }, + ) + } + } + + companion object { + fun create(cardId: String, initialRoot: CardElement, onIntent: (CardIntent) -> Unit): CardEngine = + create(cardId, initialRoot, onIntent, CoroutineScope(SupervisorJob() + Dispatchers.Default)) + + /** Explicit-scope overload for tests (and any host that wants engines sharing its own + * scope) - two overloads rather than one defaulted parameter, the same Swift-bridging + * accommodation `ChatSession.create` documents (default parameter values don't bridge to + * Swift/Obj-C). */ + fun create( + cardId: String, + initialRoot: CardElement, + onIntent: (CardIntent) -> Unit, + scope: CoroutineScope, + ): CardEngine = CardEngine(cardId, initialRoot, onIntent, scope) + } +} + +private fun flattenValues(state: CardRuntimeState): Map = + state.values + state.selections.mapValues { (_, values) -> values.joinToString(",") } + +/** `{id}` substitution. A plain input (`Input.Text`/`Date`/`Time`/`Rating`) substitutes its raw + * [CardRuntimeState.values] entry directly - there's no separate "display form" for those. An + * `Input.ChoiceSet` id - single-select (also stored in `values`, as the chosen `Choice.value`) + * or multi-select (`selections`, a set of `Choice.value`s) - substitutes the matching choices' + * *titles* instead, joined by `", "` for multi-select: `poll`'s `{neighborhood}` should read + * "I voted for Alfama.", not the wire value "alfama"; `checklist`'s `{items}` should read + * "Packing: Compact umbrella, Waterproof jacket.", not "umbrella,jacket". An id with no value + * anywhere substitutes to an empty string rather than leaving `{id}` literally in the output. */ +private fun fillTemplate(template: String, state: CardRuntimeState): String = + Regex("\\{(\\w+)}").replace(template) { match -> + val id = match.groupValues[1] + val choiceSet = findChoiceSet(state.root, id) + when { + choiceSet != null && state.selections.containsKey(id) -> + state.selections.getValue(id) + .mapNotNull { value -> choiceSet.choices.firstOrNull { it.value == value }?.title } + .joinToString(", ") + + choiceSet != null && state.values.containsKey(id) -> + choiceSet.choices.firstOrNull { it.value == state.values[id] }?.title ?: state.values.getValue(id) + + else -> state.values[id] ?: "" + } + } + +private fun findChoiceSet(root: CardElement, id: String): CardElement.InputChoiceSet? = + root.flatten().filterIsInstance().firstOrNull { it.id == id } + +private fun findTriggeredAction(root: CardElement, inputId: String): CardAction? = + root.flatten().filterIsInstance() + .flatMap { it.actions } + .firstOrNull { it.immediate && it.triggerInputId == inputId } + +private fun collectInputIds(root: CardElement): Set = + root.flatten().mapNotNull { element -> + when (element) { + is CardElement.InputText -> element.id + is CardElement.InputChoiceSet -> element.id + is CardElement.InputToggle -> element.id + is CardElement.InputDate -> element.id + is CardElement.InputTime -> element.id + is CardElement.InputRating -> element.id + else -> null + } + }.toSet() + +/** Depth-first walk of every element reachable from this one (including itself) - the shared + * tree-traversal `resync`/template-filling both need to find an element by id anywhere in the + * card, regardless of how deep it's nested in `Container`/`ColumnSet`/`Carousel`. */ +private fun CardElement.flatten(): List = buildList { + add(this@flatten) + when (val element = this@flatten) { + is CardElement.Container -> element.items.forEach { addAll(it.flatten()) } + is CardElement.ColumnSet -> element.columns.forEach { column -> column.items.forEach { addAll(it.flatten()) } } + is CardElement.Carousel -> element.pages.forEach { addAll(it.flatten()) } + else -> {} + } +} diff --git a/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardIntent.kt b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardIntent.kt new file mode 100644 index 0000000..468219a --- /dev/null +++ b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardIntent.kt @@ -0,0 +1,32 @@ +package com.fsk.threadwire.cards + +/** + * What a card action wants the host to do. `cards-core` never touches a chat session, a network + * client, or anything else host-owned - [CardEngine.invoke] only ever emits one of these two, and + * a thin adapter living in `ui-android`/`ui-ios` (not here) is what turns a [Notify] into + * `ChatSession.sendMessage` or routes a [HostAction] to the host's own `CardActionHandler`. That + * boundary is what keeps this library usable outside a chat context at all - see the M-Cards + * plan's "Architecture" section for why. + */ +sealed interface CardIntent { + /** Round-trip into the chat as a synthesized user message - the common case (confirm, + * choices, rating, form, ...). */ + data class Notify(val text: String) : CardIntent + + /** A genuinely sensitive action (payment, saving a contact) that this library will never + * synthesize a chat message for on its own - design doc §9's principle applies identically + * here: *the card is a UX affordance, never an authorization mechanism*. [data] is the + * current input/selection state at the moment of invocation, keyed by input id - plain + * `Map` rather than a JSON tree, since `cards-core` has no serialization + * opinion to impose on the host; the host's own `CardActionHandler` decides what, if + * anything, to do with it. */ + data class HostAction(val cardId: String, val actionId: String, val data: Map) : CardIntent +} + +/** Handle returned by [CardEngine.observeState] - the same callback-bridge shape `:core`'s + * `ChatSession.observeState`/`ChatSubscription` uses, reimplemented locally since `cards-core` + * has no dependency on `:core` to reuse that type from. Exists for Swift consumers, where + * collecting a raw `StateFlow` isn't ergonomic without extra tooling. */ +fun interface CardSubscription { + fun close() +} diff --git a/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardParser.kt b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardParser.kt new file mode 100644 index 0000000..236a32d --- /dev/null +++ b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardParser.kt @@ -0,0 +1,238 @@ +package com.fsk.threadwire.cards + +import com.fsk.threadwire.cards.schema.ActionMode +import com.fsk.threadwire.cards.schema.ActionStyle +import com.fsk.threadwire.cards.schema.AfterSelection +import com.fsk.threadwire.cards.schema.CardAction +import com.fsk.threadwire.cards.schema.CardElement +import com.fsk.threadwire.cards.schema.ChoicePresentation +import com.fsk.threadwire.cards.schema.ColumnWidth +import com.fsk.threadwire.cards.schema.ContainerStyle +import com.fsk.threadwire.cards.schema.ImageSize +import com.fsk.threadwire.cards.schema.TextColor +import com.fsk.threadwire.cards.schema.TextSize +import com.fsk.threadwire.cards.schema.TextWeight +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Recursively turns a wire [JsonObject] (a `MessagePart.Card.body`, or one element within it) + * into a [CardElement] tree. **Never throws** - see [CardElement]'s KDoc for why this can't be + * automatic `@Serializable` polymorphism. Every entry point below - the top-level [parse] and + * every element-kind branch inside [parseElement] - is wrapped so a malformed *child* degrades + * only that child to [CardElement.Unsupported] instead of failing its ancestors or the whole + * card. A card that's 90% well-formed still renders 90% of itself. + * + * Wire convention (this library's own, not validated against a real BFF yet - see + * `docs/cards-wire-schema.md`): each element object has a `type` string matching the element's + * Kotlin name exactly (`"TextBlock"`, `"Input.ChoiceSet"`, ...), case-sensitive - that part is a + * fixed contract, like any other wire discriminator. Enum-valued *fields* (weight, style, mode, + * presentation, ...) are matched case-insensitively against the Kotlin enum's own names, since + * those are more likely to drift in casing from a BFF author and a wrong case shouldn't sink the + * element over it. + */ +object CardParser { + /** [body] is the root element's own JSON object - e.g. `{"type": "Container", "items": [...]}` - + * not a wrapper with a separate `elements` array. Most cards root at `Container`; a few + * (`progress`, `video`) root directly at `Stepper`/`Media`. */ + fun parse(body: JsonObject): CardElement = parseElement(body) + + /** + * String entry point for iOS Swift callers - see [com.fsk.threadwire.session.bodyAsJsonString] + * in `:core` for why a `MessagePart.Card.body: JsonObject?` can't cross directly from + * `ThreadwireCore.xcframework` to `ThreadwireCards.xcframework`'s Swift API (they're + * independently-compiled Kotlin/Native modules with incompatible generated bindings for + * "the same" `kotlinx.serialization.json` types) - a plain `String` has no such problem. + * Malformed JSON (not just a malformed element) also degrades to [CardElement.Unsupported] + * rather than throwing, consistent with [parse]'s own never-throws contract. + */ + fun parseJson(jsonString: String): CardElement = + try { + parse(Json.parseToJsonElement(jsonString).jsonObject) + } catch (e: Exception) { + CardElement.Unsupported(rawType = "unknown", raw = JsonObject(emptyMap())) + } + + private fun parseElement(json: JsonObject): CardElement { + val type = json["type"]?.jsonPrimitive?.contentOrNull + return try { + when (type) { + "TextBlock" -> CardElement.TextBlock( + text = json.requireString("text"), + weight = json.enumOrDefault("weight", TextWeight.DEFAULT), + size = json.enumOrDefault("size", TextSize.DEFAULT), + color = json.enumOrDefault("color", TextColor.DEFAULT), + ) + + "Image" -> CardElement.Image( + url = json.requireString("url"), + altText = json.stringOrNull("altText"), + size = json.enumOrDefault("size", ImageSize.MEDIUM), + ) + + "Avatar" -> CardElement.Avatar( + name = json.requireString("name"), + imageUrl = json.stringOrNull("imageUrl"), + ) + + "Media" -> CardElement.Media( + url = json.requireString("url"), + posterUrl = json.stringOrNull("posterUrl"), + duration = json.stringOrNull("duration"), + ) + + "Container" -> CardElement.Container( + items = json.parseElementArray("items"), + style = json.enumOrDefault("style", ContainerStyle.DEFAULT), + ) + + "ColumnSet" -> CardElement.ColumnSet( + columns = json.arrayOrEmpty("columns").mapNotNullSafe { parseColumn(it.jsonObject) }, + ) + + "FactSet" -> CardElement.FactSet( + facts = json.arrayOrEmpty("facts").mapNotNullSafe { parseFact(it.jsonObject) }, + ) + + "Carousel" -> CardElement.Carousel( + // A malformed/non-Container page is dropped rather than crashing the whole + // carousel - see this object's KDoc on per-child degradation. + pages = json.arrayOrEmpty("pages") + .mapNotNullSafe { it.jsonObject.let(::parseElement) as? CardElement.Container }, + ) + + "Stepper" -> CardElement.Stepper( + steps = json.arrayOrEmpty("steps").mapNotNullSafe { parseStep(it.jsonObject) }, + ) + + "Input.Text" -> CardElement.InputText( + id = json.requireString("id"), + label = json.requireString("label"), + placeholder = json.stringOrNull("placeholder"), + value = json.stringOrNull("value") ?: "", + isRequired = json["isRequired"]?.jsonPrimitive?.booleanOrNull ?: false, + ) + + "Input.ChoiceSet" -> CardElement.InputChoiceSet( + id = json.requireString("id"), + label = json.stringOrNull("label"), + choices = json.arrayOrEmpty("choices").mapNotNullSafe { parseChoice(it.jsonObject) }, + presentation = json.enumOrDefault("presentation", ChoicePresentation.COMPACT), + exclusiveSelect = json["exclusiveSelect"]?.jsonPrimitive?.booleanOrNull ?: true, + value = json.stringOrNull("value"), + ) + + "Input.Toggle" -> CardElement.InputToggle( + id = json.requireString("id"), + label = json.requireString("label"), + value = json["value"]?.jsonPrimitive?.booleanOrNull ?: false, + ) + + "Input.Date" -> CardElement.InputDate( + id = json.requireString("id"), + label = json.requireString("label"), + value = json.stringOrNull("value"), + ) + + "Input.Time" -> CardElement.InputTime( + id = json.requireString("id"), + label = json.requireString("label"), + value = json.stringOrNull("value"), + ) + + "Input.Rating" -> CardElement.InputRating( + id = json.requireString("id"), + label = json.stringOrNull("label"), + maxValue = json["maxValue"]?.jsonPrimitive?.intOrNull ?: 5, + value = json["value"]?.jsonPrimitive?.intOrNull, + ) + + "ActionSet" -> CardElement.ActionSet( + actions = json.arrayOrEmpty("actions").mapNotNullSafe { parseAction(it.jsonObject) }, + afterSelection = json.enumOrDefault("afterSelection", AfterSelection.NONE), + ) + + else -> CardElement.Unsupported(rawType = type ?: "unknown", raw = json) + } + } catch (e: Exception) { + // A required field (requireString) was missing/wrong-typed for an otherwise- + // recognized type - degrade to Unsupported rather than propagate. rawType stays the + // real declared type here (not "unknown") since the type WAS recognized; only its + // shape was wrong - useful when debugging a BFF payload. + CardElement.Unsupported(rawType = type ?: "unknown", raw = json) + } + } + + private fun parseColumn(json: JsonObject) = CardElement.Column( + items = json.parseElementArray("items"), + width = json.enumOrDefault("width", ColumnWidth.AUTO), + ) + + private fun parseFact(json: JsonObject) = CardElement.Fact( + title = json.requireString("title"), + value = json.requireString("value"), + ) + + private fun parseStep(json: JsonObject) = CardElement.Step( + label = json.requireString("label"), + status = json.enumOrDefault("status", CardElement.StepStatus.UPCOMING), + ) + + private fun parseChoice(json: JsonObject) = CardElement.Choice( + title = json.requireString("title"), + value = json.requireString("value"), + percentage = json["percentage"]?.jsonPrimitive?.intOrNull, + ) + + private fun parseAction(json: JsonObject) = CardAction( + id = json.requireString("id"), + title = json.requireString("title"), + style = json.enumOrDefault("style", ActionStyle.DEFAULT), + mode = json.enumOrDefault("mode", ActionMode.NOTIFY), + notifyTemplate = json.stringOrNull("notifyTemplate"), + immediate = json["immediate"]?.jsonPrimitive?.booleanOrNull ?: false, + triggerInputId = json.stringOrNull("triggerInputId"), + doneTitle = json.stringOrNull("doneTitle"), + enabledWhenInputsFilled = json.arrayOrEmpty("enabledWhenInputsFilled") + .mapNotNullSafe { it.jsonPrimitive.contentOrNull }, + ) + + // --- Field-extraction helpers - centralize the "be lenient, never throw past this point" + // policy so every branch above reads declaratively. --- + + private fun JsonObject.stringOrNull(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull + + /** Throws (caught by [parseElement]'s try/catch) when [key] is missing or empty - a required + * field that isn't there means this element's shape doesn't match its declared `type`, which + * is exactly the "recognized type, wrong shape" case that degrades to [CardElement.Unsupported]. */ + private fun JsonObject.requireString(key: String): String = + stringOrNull(key)?.takeIf { it.isNotEmpty() } + ?: throw IllegalArgumentException("missing or empty required field \"$key\"") + + private fun JsonObject.arrayOrEmpty(key: String): JsonArray = + (this[key] as? JsonArray) ?: JsonArray(emptyList()) + + /** Parses [key] as an array of child elements, dropping any entry that isn't itself a JSON + * object (rather than throwing) - used by every element kind with nested content + * (`Container.items`, `Column.items`). */ + private fun JsonObject.parseElementArray(key: String): List = + arrayOrEmpty(key).mapNotNullSafe { parseElement(it.jsonObject) } + + private inline fun JsonArray.mapNotNullSafe(transform: (JsonElement) -> T?): List = + mapNotNull { element -> runCatching { transform(element) }.getOrNull() } + + /** Case-insensitive match against [T]'s own enum names - lenient on purpose, see this + * object's KDoc. Missing/unrecognized value silently falls back to [default]. */ + private inline fun > JsonObject.enumOrDefault(key: String, default: T): T { + val raw = stringOrNull(key) ?: return default + return enumValues().firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: default + } +} diff --git a/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/schema/CardElement.kt b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/schema/CardElement.kt new file mode 100644 index 0000000..4981a2c --- /dev/null +++ b/cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/schema/CardElement.kt @@ -0,0 +1,198 @@ +package com.fsk.threadwire.cards.schema + +import kotlinx.serialization.json.JsonObject + +/** + * A deliberate subset of real Adaptive Cards' schema, plus two small custom additions + * ([CardElement.Carousel], [CardElement.Stepper]) that AC has no clean equivalent for in a chat + * context. Validated against all 16 illustrative card layouts from the commissioned design (see + * the M-Cards plan) - every one of them expresses as a tree of these elements, with zero new + * Kotlin/Swift code required per new card design a BFF author invents later. + * + * Not `@Serializable`: `CardParser` hand-parses the wire `JsonObject` into this tree recursively + * and never throws - an unrecognized `type` value becomes [Unsupported] instead of failing the + * whole card, the same graceful-degradation posture as `:core`'s `MessagePart.Unknown`/ + * `ChatEvent.Unknown` (this module has no dependency on `:core` - the parallel is in spirit, not + * in code). Automatic polymorphic deserialization (via `@Serializable sealed interface` + a + * `type` discriminator) doesn't have that fallback built in - it throws on an unknown + * discriminator - so a hand-written parser is what actually gets the "never throws" requirement, + * not a shortcut around it. + */ +sealed interface CardElement { + /** Inline markdown (bold/italic) - every title/subtitle/fact value in the source design + * renders through a markdown-span helper, not plain text. */ + data class TextBlock( + val text: String, + val weight: TextWeight = TextWeight.DEFAULT, + val size: TextSize = TextSize.DEFAULT, + val color: TextColor = TextColor.DEFAULT, + ) : CardElement + + data class Image( + val url: String, + val altText: String? = null, + val size: ImageSize = ImageSize.MEDIUM, + ) : CardElement + + /** A small avatar, e.g. the `contact` card's initials-in-a-circle. [imageUrl] wins when + * present; otherwise the renderer derives initials from [name] (the same derivation the + * source design does client-side - presentation-only, not a computed/derived-value + * mechanism in the engine). */ + data class Avatar( + val name: String, + val imageUrl: String? = null, + ) : CardElement + + /** Real playback in v1 (SwiftUI `VideoPlayer`/AVKit on iOS, Media3 ExoPlayer on Android) - + * see the M-Cards plan's "Video" section. */ + data class Media( + val url: String, + val posterUrl: String? = null, + val duration: String? = null, + ) : CardElement + + data class Container( + val items: List, + val style: ContainerStyle = ContainerStyle.DEFAULT, + ) : CardElement + + data class ColumnSet(val columns: List) : CardElement + data class Column( + val items: List, + val width: ColumnWidth = ColumnWidth.AUTO, + ) + + data class FactSet(val facts: List) : CardElement + data class Fact(val title: String, val value: String) + + /** Custom addition - real Adaptive Cards has no clean horizontal-carousel equivalent. */ + data class Carousel(val pages: List) : CardElement + + /** Custom addition - progress/order-tracking (read-only, server-driven via `card-update`). */ + data class Stepper(val steps: List) : CardElement + data class Step(val label: String, val status: StepStatus) + enum class StepStatus { DONE, CURRENT, UPCOMING } + + data class InputText( + val id: String, + val label: String, + val placeholder: String? = null, + val value: String = "", + val isRequired: Boolean = false, + ) : CardElement + + data class InputChoiceSet( + val id: String, + val label: String? = null, + val choices: List, + val presentation: ChoicePresentation = ChoicePresentation.COMPACT, + val exclusiveSelect: Boolean = true, + val value: String? = null, + ) : CardElement + + /** [percentage] is the one place a value arrives pre-computed rather than derived on-device + * (the `poll` card) - the server owns vote tallying; the client only ever renders a number + * it was handed. Null for every other `Input.ChoiceSet` use. */ + data class Choice(val title: String, val value: String, val percentage: Int? = null) + + data class InputToggle( + val id: String, + val label: String, + val value: Boolean = false, + ) : CardElement + + data class InputDate( + val id: String, + val label: String, + val value: String? = null, + ) : CardElement + + data class InputTime( + val id: String, + val label: String, + val value: String? = null, + ) : CardElement + + data class InputRating( + val id: String, + val label: String? = null, + val maxValue: Int = 5, + val value: Int? = null, + ) : CardElement + + /** [afterSelection] captures that "exclusive selection" is two different visual behaviors in + * the source design, not one: `choices` hides the options you didn't pick, `carousel` keeps + * every page visible and only relabels the chosen one's button. */ + data class ActionSet( + val actions: List, + val afterSelection: AfterSelection = AfterSelection.NONE, + ) : CardElement + + /** Forward-compat fallback for an unrecognized `type` - see this interface's KDoc. Never + * thrown; [raw] is kept so a future engine version (or a host that understands a newer + * element type than this library does) can still act on it instead of losing the data. */ + data class Unsupported(val rawType: String, val raw: JsonObject) : CardElement +} + +enum class TextWeight { DEFAULT, LIGHTER, BOLDER } +enum class TextSize { DEFAULT, SMALL, MEDIUM, LARGE, EXTRA_LARGE } +enum class TextColor { DEFAULT, DARK, LIGHT, ACCENT, GOOD, WARNING, ATTENTION } +enum class ImageSize { SMALL, MEDIUM, LARGE } +enum class ContainerStyle { DEFAULT, EMPHASIS } +enum class ColumnWidth { AUTO, STRETCH } + +/** [POLL_BAR] renders each [CardElement.Choice] as a filled progress bar sized by its + * `percentage`, used only by the `poll` card. */ +enum class ChoicePresentation { COMPACT, EXPANDED, POLL_BAR } + +enum class AfterSelection { + /** Read-only or immediate-fire content where nothing needs to change after a tap. */ + NONE, + /** Only the chosen action's button remains visible (`choices`). */ + HIDE_UNSELECTED, + /** Every option stays visible; the chosen one's button relabels via [CardAction.doneTitle] + * (`carousel`). */ + MARK_SELECTED, +} + +/** + * An actionable affordance attached to a [CardElement.ActionSet] (button tap, or - when + * [immediate] is true - fired as soon as an input/selection changes, no separate tap needed: + * rating, choice pills, poll, carousel). + */ +data class CardAction( + val id: String, + val title: String, + val style: ActionStyle = ActionStyle.DEFAULT, + /** [ActionMode.NOTIFY] emits [com.fsk.threadwire.cards.CardIntent.Notify] for the host to + * route into the chat (e.g. `ChatSession.sendMessage`); [ActionMode.HOST_ACTION] emits + * [com.fsk.threadwire.cards.CardIntent.HostAction] instead, for the host's own + * `CardActionHandler` - for genuinely sensitive actions (payment, saving a contact) this + * library never synthesizes a chat message on its own. */ + val mode: ActionMode = ActionMode.NOTIFY, + /** Filled from the current input/selection values at submit time (e.g. `"Rating: {rating}/5 + * stars."`) - only meaningful for [ActionMode.NOTIFY]. A multi-select `Input.ChoiceSet` + * value fills as its selected choices' titles joined by `", "`. */ + val notifyTemplate: String? = null, + /** Fires as soon as the input/selection it's paired with changes - no separate tap on this + * action itself needed. Meaningful only together with [triggerInputId] for a *shared* + * widget like `Input.Rating` (tapping a star sets the value **and** fires this action in one + * gesture - the renderer is what wires the two together via that id). `choices`/`carousel` + * don't need [triggerInputId] at all: there each option **is** its own immediate + * [CardAction] with its own button, tapped and fired directly. */ + val immediate: Boolean = false, + /** The input/choice-set id this action is paired with when [immediate] is true and the + * interaction lives on a separate input widget rather than the action's own button (e.g. + * `rating`'s `Input.Rating`, `poll`'s `Input.ChoiceSet`). Null for an action that's its own + * standalone tappable affordance. */ + val triggerInputId: String? = null, + /** What the button's title becomes once this action has fired once - e.g. "Confirm meeting + * point" -> "Confirmed", "Pay €238.00" -> "Paid". Null means the title never changes. */ + val doneTitle: String? = null, + /** This action stays disabled until every listed input/choice-set id has a non-empty value + * (`form`, `checklist`, `datetime`). Empty list = always enabled. */ + val enabledWhenInputsFilled: List = emptyList(), +) + +enum class ActionMode { NOTIFY, HOST_ACTION } +enum class ActionStyle { DEFAULT, POSITIVE, DESTRUCTIVE } diff --git a/cards-core/src/commonTest/kotlin/com/fsk/threadwire/cards/CardEngineTest.kt b/cards-core/src/commonTest/kotlin/com/fsk/threadwire/cards/CardEngineTest.kt new file mode 100644 index 0000000..e4cf710 --- /dev/null +++ b/cards-core/src/commonTest/kotlin/com/fsk/threadwire/cards/CardEngineTest.kt @@ -0,0 +1,302 @@ +package com.fsk.threadwire.cards + +import com.fsk.threadwire.cards.schema.ActionMode +import com.fsk.threadwire.cards.schema.CardAction +import com.fsk.threadwire.cards.schema.CardElement +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CardEngineTest { + + /** Explicit-scope overload, passing the test's own [TestScope] - none of these tests call + * [CardEngine.observeState] (the only method that actually launches on the scope), they read + * [CardEngine.state]`.value` directly, so any [kotlinx.coroutines.CoroutineScope] would do. */ + private fun TestScope.engine(root: CardElement, onIntent: (CardIntent) -> Unit = {}): CardEngine = + CardEngine.create("card_1", root, onIntent, this) + + // --- template filling --- + + @Test + fun invokeFillsTemplateFromASetValue() = runTest { + val action = CardAction(id = "greet", title = "Greet", notifyTemplate = "Hi {name}!") + val root = CardElement.Container(listOf( + CardElement.InputText(id = "name", label = "Name"), + CardElement.ActionSet(actions = listOf(action)), + )) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + engine.setValue("name", "Ramon") + engine.invoke(action) + + assertEquals(listOf(CardIntent.Notify("Hi Ramon!")), intents) + } + + @Test + fun invokeWithNoValueForPlaceholderSubstitutesEmptyString() = runTest { + val action = CardAction(id = "greet", title = "Greet", notifyTemplate = "Hi {name}!") + val root = CardElement.Container(listOf(CardElement.ActionSet(actions = listOf(action)))) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + engine.invoke(action) + + assertEquals(listOf(CardIntent.Notify("Hi !")), intents) + } + + @Test + fun multiSelectTemplateJoinsSelectedChoiceTitlesNotWireValues() = runTest { + val choiceSet = CardElement.InputChoiceSet( + id = "items", + exclusiveSelect = false, + choices = listOf( + CardElement.Choice(title = "Compact umbrella", value = "umbrella"), + CardElement.Choice(title = "Waterproof jacket", value = "jacket"), + CardElement.Choice(title = "Waterproof shoes", value = "shoes"), + ), + ) + val action = CardAction(id = "submit", title = "Pack it", notifyTemplate = "Packing: {items}.") + val root = CardElement.Container(listOf(choiceSet, CardElement.ActionSet(actions = listOf(action)))) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + engine.toggleSelection("items", "umbrella") + engine.toggleSelection("items", "jacket") + engine.invoke(action) + + assertEquals(listOf(CardIntent.Notify("Packing: Compact umbrella, Waterproof jacket.")), intents) + } + + @Test + fun singleSelectTemplateSubstitutesChoiceTitleNotWireValue() = runTest { + // This is the poll card's exact shape: a single-select Input.ChoiceSet whose value is a + // short machine id ("alfama"), but the notify sentence must read the display title + // ("Alfama") - regression coverage for the bug caught while writing CardParserTest's + // poll fixture (fillTemplate originally substituted the raw stored value here). + val choiceSet = CardElement.InputChoiceSet( + id = "neighborhood", + exclusiveSelect = true, + choices = listOf( + CardElement.Choice(title = "Alfama", value = "alfama", percentage = 48), + CardElement.Choice(title = "Baixa", value = "baixa", percentage = 31), + ), + ) + val voteAction = CardAction( + id = "vote", title = "Vote", immediate = true, + triggerInputId = "neighborhood", notifyTemplate = "I voted for {neighborhood}.", + ) + val root = CardElement.Container(listOf(choiceSet, CardElement.ActionSet(actions = listOf(voteAction)))) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + engine.setValueAndTrigger("neighborhood", "alfama") + + assertEquals(listOf(CardIntent.Notify("I voted for Alfama.")), intents) + } + + // --- setValueAndTrigger wiring --- + + @Test + fun setValueAndTriggerFiresThePairedImmediateAction() = runTest { + val ratingAction = CardAction( + id = "rate", title = "Rate", immediate = true, + triggerInputId = "rating", notifyTemplate = "Rating: {rating}/5 stars.", + ) + val root = CardElement.Container(listOf( + CardElement.InputRating(id = "rating"), + CardElement.ActionSet(actions = listOf(ratingAction)), + )) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + engine.setValueAndTrigger("rating", "4") + + assertEquals(listOf(CardIntent.Notify("Rating: 4/5 stars.")), intents) + assertTrue(engine.state.value.answeredActionIds.contains("rate")) + } + + @Test + fun setValueAndTriggerWithNoPairedActionOnlySetsTheValue() = runTest { + val root = CardElement.Container(listOf(CardElement.InputRating(id = "rating"))) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + engine.setValueAndTrigger("rating", "4") + + assertTrue(intents.isEmpty()) + assertEquals("4", engine.state.value.values["rating"]) + } + + // --- submit gating (enabledWhenInputsFilled) --- + + @Test + fun canInvokeIsFalseUntilAllGatingInputsAreFilled() = runTest { + val action = CardAction( + id = "submit", title = "Book", + enabledWhenInputsFilled = listOf("name", "date"), + notifyTemplate = "Please book: Name: {name}, Date: {date}.", + ) + val root = CardElement.Container(listOf( + CardElement.InputText(id = "name", label = "Name"), + CardElement.InputText(id = "date", label = "Date"), + CardElement.ActionSet(actions = listOf(action)), + )) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + assertFalse(engine.state.value.canInvoke(action)) + engine.invoke(action) // should no-op - not enough inputs filled yet + assertTrue(intents.isEmpty()) + + engine.setValue("name", "Ramon") + assertFalse(engine.state.value.canInvoke(action)) // "date" still missing + + engine.setValue("date", "Fri") + assertTrue(engine.state.value.canInvoke(action)) + engine.invoke(action) + assertEquals(1, intents.size) + } + + @Test + fun multiSelectChoiceSetCountsAsFilledOnlyWhenNonEmpty() = runTest { + val action = CardAction(id = "submit", title = "Pack it", enabledWhenInputsFilled = listOf("items")) + val choiceSet = CardElement.InputChoiceSet(id = "items", exclusiveSelect = false, choices = listOf( + CardElement.Choice(title = "Umbrella", value = "umbrella"), + )) + val root = CardElement.Container(listOf(choiceSet, CardElement.ActionSet(actions = listOf(action)))) + val engine = engine(root) + + assertFalse(engine.state.value.canInvoke(action)) + engine.toggleSelection("items", "umbrella") + assertTrue(engine.state.value.canInvoke(action)) + engine.toggleSelection("items", "umbrella") // toggled back off + assertFalse(engine.state.value.canInvoke(action)) + } + + // --- NOTIFY vs HOST_ACTION routing --- + + @Test + fun notifyModeEmitsNotifyIntentWithFilledTemplate() = runTest { + val action = CardAction(id = "confirm", title = "Confirm", mode = ActionMode.NOTIFY, notifyTemplate = "Done: {x}") + val root = CardElement.Container(listOf(CardElement.ActionSet(actions = listOf(action)))) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + engine.setValue("x", "yes") + + engine.invoke(action) + + assertEquals(CardIntent.Notify("Done: yes"), intents.single()) + } + + @Test + fun hostActionModeEmitsHostActionIntentWithCurrentValuesNeverNotify() = runTest { + val action = CardAction(id = "pay", title = "Pay €238.00", mode = ActionMode.HOST_ACTION, doneTitle = "Paid") + val root = CardElement.Container(listOf(CardElement.ActionSet(actions = listOf(action)))) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + engine.setValue("tip", "10") + + engine.invoke(action) + + val intent = assertIs(intents.single()) + assertEquals("card_1", intent.cardId) + assertEquals("pay", intent.actionId) + assertEquals("10", intent.data["tip"]) + } + + // --- doneTitle one-shot gating --- + + @Test + fun doneTitleActionBecomesDisabledAfterFiringOnce() = runTest { + val action = CardAction(id = "pay", title = "Pay", mode = ActionMode.HOST_ACTION, doneTitle = "Paid") + val root = CardElement.Container(listOf(CardElement.ActionSet(actions = listOf(action)))) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + assertTrue(engine.state.value.canInvoke(action)) + engine.invoke(action) + assertFalse(engine.state.value.canInvoke(action)) + engine.invoke(action) // no-ops - already answered and doneTitle marks it one-shot + + assertEquals(1, intents.size) + } + + @Test + fun actionWithoutDoneTitleStaysInvokableRepeatedly() = runTest { + // rating/choices/carousel/poll all lack doneTitle and must stay tappable indefinitely - + // the source design lets you change a rating or switch a poll vote. + val action = CardAction( + id = "rate", title = "Rate", immediate = true, + triggerInputId = "rating", notifyTemplate = "Rating: {rating}/5 stars.", + ) + val root = CardElement.Container(listOf( + CardElement.InputRating(id = "rating"), + CardElement.ActionSet(actions = listOf(action)), + )) + val intents = mutableListOf() + val engine = engine(root) { intents.add(it) } + + engine.setValueAndTrigger("rating", "3") + engine.setValueAndTrigger("rating", "5") + + assertEquals( + listOf(CardIntent.Notify("Rating: 3/5 stars."), CardIntent.Notify("Rating: 5/5 stars.")), + intents, + ) + } + + // --- resync --- + + @Test + fun resyncDropsValuesForInputsNoLongerInTheTreeKeepsTheRest() = runTest { + val root = CardElement.Container(listOf( + CardElement.InputText(id = "a", label = "A"), + CardElement.InputText(id = "b", label = "B"), + )) + val engine = engine(root) + engine.setValue("a", "keep me") + engine.setValue("b", "drop me") + + val newRoot = CardElement.Container(listOf(CardElement.InputText(id = "a", label = "A"))) + engine.resync(newRoot) + + assertEquals("keep me", engine.state.value.values["a"]) + assertFalse(engine.state.value.values.containsKey("b")) + assertEquals(newRoot, engine.state.value.root) + } + + @Test + fun resyncDropsSelectionsForChoiceSetsNoLongerInTheTree() = runTest { + val choiceSet = CardElement.InputChoiceSet(id = "items", exclusiveSelect = false, choices = listOf( + CardElement.Choice(title = "Umbrella", value = "umbrella"), + )) + val root = CardElement.Container(listOf(choiceSet)) + val engine = engine(root) + engine.toggleSelection("items", "umbrella") + assertTrue(engine.state.value.selections.containsKey("items")) + + engine.resync(CardElement.Container(emptyList())) + + assertFalse(engine.state.value.selections.containsKey("items")) + } + + // --- answeredActionIds --- + + @Test + fun invokeRecordsTheFiredActionIdRegardlessOfMode() = runTest { + val notifyAction = CardAction(id = "a", title = "A", mode = ActionMode.NOTIFY, notifyTemplate = "x") + val hostAction = CardAction(id = "b", title = "B", mode = ActionMode.HOST_ACTION) + val root = CardElement.Container(listOf(CardElement.ActionSet(actions = listOf(notifyAction, hostAction)))) + val engine = engine(root) + + engine.invoke(notifyAction) + engine.invoke(hostAction) + + assertEquals(setOf("a", "b"), engine.state.value.answeredActionIds) + } +} diff --git a/cards-core/src/commonTest/kotlin/com/fsk/threadwire/cards/CardParserTest.kt b/cards-core/src/commonTest/kotlin/com/fsk/threadwire/cards/CardParserTest.kt new file mode 100644 index 0000000..5b5f380 --- /dev/null +++ b/cards-core/src/commonTest/kotlin/com/fsk/threadwire/cards/CardParserTest.kt @@ -0,0 +1,649 @@ +package com.fsk.threadwire.cards + +import com.fsk.threadwire.cards.schema.ActionMode +import com.fsk.threadwire.cards.schema.ActionStyle +import com.fsk.threadwire.cards.schema.AfterSelection +import com.fsk.threadwire.cards.schema.CardElement +import com.fsk.threadwire.cards.schema.ChoicePresentation +import com.fsk.threadwire.cards.schema.TextWeight +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Parses the 16 illustrative card layouts from the commissioned design (see the M-Cards plan's + * composition table) through [CardParser] and checks each lands on the expected [CardElement] + * tree with nothing degrading to [CardElement.Unsupported]. These payloads are this library's + * own wire-format proposal (`docs/cards-wire-schema.md`), not yet validated against a real BFF - + * see that doc's status note. + */ +class CardParserTest { + + // --- confirm --- + + @Test + fun confirmParsesToContainerWithHideUnselectedActionSet() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Cancel your Alfama hotel booking?") + textBlock("This can't be undone once confirmed.") + putJsonObjectEntry { + put("type", "ActionSet") + put("afterSelection", "HIDE_UNSELECTED") + putActions("actions") { + action(id = "yes", title = "Yes, cancel it", style = "DESTRUCTIVE", notifyTemplate = "Yes, please cancel it.") + action(id = "no", title = "No, keep it", notifyTemplate = "No, keep it.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + assertEquals(3, root.items.size) + val actionSet = assertIs(root.items[2]) + assertEquals(AfterSelection.HIDE_UNSELECTED, actionSet.afterSelection) + assertEquals(2, actionSet.actions.size) + assertEquals(ActionStyle.DESTRUCTIVE, actionSet.actions[0].style) + assertEquals("Yes, please cancel it.", actionSet.actions[0].notifyTemplate) + } + + // --- summary --- + + @Test + fun summaryParsesToContainerWithFactSet() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Your Lisbon itinerary") + putJsonObjectEntry { + put("type", "FactSet") + putFacts("facts") { + fact("Day 1", "Historic Center — Alfama & São Jorge Castle") + fact("Day 2", "Belém — Monastery & Tower") + fact("Day 3", "LX Factory — shopping & street art") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val factSet = assertIs(root.items[1]) + assertEquals(3, factSet.facts.size) + assertEquals("Day 1", factSet.facts[0].title) + } + + // --- carousel --- + + @Test + fun carouselParsesToPagesWithMarkSelectedActionSet() { + fun page(name: String, meta: String, id: String) = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock(name) + textBlock(meta) + putJsonObjectEntry { + put("type", "ActionSet") + put("afterSelection", "MARK_SELECTED") + putActions("actions") { + action(id = id, title = "Select", immediate = true, doneTitle = "Selected", notifyTemplate = "I'll go with $name.") + } + } + } + } + val json = buildJsonObject { + put("type", "Carousel") + put("pages", buildJsonArray { + add(page("Casa do Alfama", "4.6 ★ · \$120/night", "h1")) + add(page("Lisbon Riverside", "4.3 ★ · \$95/night", "h2")) + add(page("Castelo Boutique", "4.8 ★ · \$150/night", "h3")) + }) + } + + val root = assertIs(CardParser.parse(json)) + assertEquals(3, root.pages.size) + root.pages.forEach { assertNoUnsupported(it) } + val firstAction = (root.pages[0].items[2] as CardElement.ActionSet).actions.single() + assertTrue(firstAction.immediate) + assertEquals("Selected", firstAction.doneTitle) + } + + // --- rating --- + + @Test + fun ratingParsesWithTriggerInputIdWiredToInputRating() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("How was this itinerary?") + putJsonObjectEntry { + put("type", "Input.Rating") + put("id", "rating") + put("maxValue", 5) + } + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "rate", title = "Rate", immediate = true, triggerInputId = "rating", notifyTemplate = "Rating: {rating}/5 stars.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val ratingInput = assertIs(root.items[1]) + assertEquals("rating", ratingInput.id) + assertEquals(5, ratingInput.maxValue) + val action = (root.items[2] as CardElement.ActionSet).actions.single() + assertEquals("rating", action.triggerInputId) + } + + // --- choices --- + + @Test + fun choicesParsesToFourImmediateActionsHidingUnselected() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("What kind of trip are you after?") + putJsonObjectEntry { + put("type", "ActionSet") + put("afterSelection", "HIDE_UNSELECTED") + putActions("actions") { + action(id = "adventure", title = "Adventure", immediate = true, notifyTemplate = "I'm looking for a adventure trip.") + action(id = "relax", title = "Relaxing", immediate = true, notifyTemplate = "I'm looking for a relaxing trip.") + action(id = "culture", title = "Cultural", immediate = true, notifyTemplate = "I'm looking for a cultural trip.") + action(id = "food", title = "Food & drink", immediate = true, notifyTemplate = "I'm looking for a food & drink trip.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val actionSet = assertIs(root.items[1]) + assertEquals(4, actionSet.actions.size) + assertTrue(actionSet.actions.all { it.immediate }) + } + + // --- form --- + + @Test + fun formParsesTextAndChoiceInputsWithGatedSubmit() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Book a table") + putJsonObjectEntry { + put("type", "Input.Text") + put("id", "name") + put("label", "Name") + put("isRequired", true) + } + putJsonObjectEntry { + put("type", "Input.Text") + put("id", "date") + put("label", "Date") + put("placeholder", "e.g. Fri, 8pm") + put("isRequired", true) + } + putJsonObjectEntry { + put("type", "Input.ChoiceSet") + put("id", "guests") + put("label", "Guests") + put("value", "2") + putChoices("choices") { (1..6).forEach { choice(it.toString(), it.toString()) } } + } + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "submit", title = "Book", enabledWhenInputsFilled = listOf("name", "date"), notifyTemplate = "Please book: Name: {name}, Date: {date}, Guests: {guests}.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val name = assertIs(root.items[1]) + assertTrue(name.isRequired) + val guests = assertIs(root.items[3]) + assertEquals(6, guests.choices.size) + val submit = (root.items[4] as CardElement.ActionSet).actions.single() + assertEquals(listOf("name", "date"), submit.enabledWhenInputsFilled) + } + + // --- location --- + + @Test + fun locationParsesWithDoneTitleOnConfirm() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Meeting point") + textBlock("Praça do Comércio, 1100-148 Lisboa") + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "confirm", title = "Confirm meeting point", doneTitle = "Confirmed", notifyTemplate = "Works for me — I'll meet you at Praça do Comércio, 1100-148 Lisboa.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val action = (root.items[2] as CardElement.ActionSet).actions.single() + assertEquals("Confirmed", action.doneTitle) + } + + // --- checklist --- + + @Test + fun checklistParsesMultiSelectChoiceSetWithGatedSubmit() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Pack for rainy days") + putJsonObjectEntry { + put("type", "Input.ChoiceSet") + put("id", "items") + put("exclusiveSelect", false) + put("presentation", "EXPANDED") + putChoices("choices") { + choice("Compact umbrella", "umbrella") + choice("Waterproof jacket", "jacket") + choice("Waterproof shoes", "shoes") + choice("Dry bag for electronics", "bag") + } + } + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "submit", title = "Pack it", enabledWhenInputsFilled = listOf("items"), notifyTemplate = "Packing: {items}.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val choiceSet = assertIs(root.items[1]) + assertFalse(choiceSet.exclusiveSelect) + assertEquals(ChoicePresentation.EXPANDED, choiceSet.presentation) + assertEquals(4, choiceSet.choices.size) + } + + // --- datetime --- + + @Test + fun datetimeParsesDateAndTimeInputsWithGatedSubmit() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("When should we schedule the call?") + putJsonObjectEntry { put("type", "Input.Date"); put("id", "date"); put("label", "Date") } + putJsonObjectEntry { put("type", "Input.Time"); put("id", "time"); put("label", "Time") } + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "submit", title = "Confirm", enabledWhenInputsFilled = listOf("date", "time"), notifyTemplate = "Let's do {date} at {time}.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + assertIs(root.items[1]) + assertIs(root.items[2]) + } + + // --- progress --- + + @Test + fun progressParsesStepperWithMixedStatuses() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Booking status") + putJsonObjectEntry { + put("type", "Stepper") + putSteps("steps") { + step("Request received", "DONE") + step("Confirming with hotel", "CURRENT") + step("Confirmation email", "UPCOMING") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val stepper = assertIs(root.items[1]) + assertEquals(3, stepper.steps.size) + assertEquals(CardElement.StepStatus.CURRENT, stepper.steps[1].status) + } + + // --- payment --- + + @Test + fun paymentParsesWithHostActionMode() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Confirm payment") + textBlock("**Alfama Rooftop Suite** — 2 nights") + putJsonObjectEntry { + put("type", "FactSet") + putFacts("facts") { fact("Amount", "€238.00"); fact("Method", "•••• 4242") } + } + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "pay", title = "Pay €238.00", mode = "HOST_ACTION", doneTitle = "Paid") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val action = (root.items[3] as CardElement.ActionSet).actions.single() + assertEquals(ActionMode.HOST_ACTION, action.mode) + assertNull(action.notifyTemplate) + } + + // --- contact --- + + @Test + fun contactParsesAvatarAndHostActionSave() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + putJsonObjectEntry { put("type", "Avatar"); put("name", "Mariana Sousa") } + textBlock("Mariana Sousa") + textBlock("*Licensed* Lisbon guide") + putJsonObjectEntry { + put("type", "FactSet") + putFacts("facts") { fact("Phone", "+351 91 234 5678"); fact("Email", "mariana@lisbonwalks.pt") } + } + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "save_contact", title = "Save contact", mode = "HOST_ACTION", doneTitle = "Saved") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val avatar = assertIs(root.items[0]) + assertEquals("Mariana Sousa", avatar.name) + assertNull(avatar.imageUrl) + } + + // --- poll --- + + @Test + fun pollParsesServerComputedPercentagesWithTriggerInputId() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Which neighborhood should we prioritize?") + putJsonObjectEntry { + put("type", "Input.ChoiceSet") + put("id", "neighborhood") + put("presentation", "POLL_BAR") + putChoices("choices") { + choice("Alfama", "alfama", 48) + choice("Baixa", "baixa", 31) + choice("Príncipe Real", "principe", 21) + } + } + putJsonObjectEntry { + put("type", "ActionSet") + putActions("actions") { + action(id = "vote", title = "Vote", immediate = true, triggerInputId = "neighborhood", notifyTemplate = "I voted for {neighborhood}.") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val choiceSet = assertIs(root.items[1]) + assertEquals(ChoicePresentation.POLL_BAR, choiceSet.presentation) + assertEquals(48, choiceSet.choices[0].percentage) + } + + // --- weather --- + + @Test + fun weatherParsesColumnSetOfThreeDays() { + fun column(day: String, temp: String, cond: String) = buildJsonObject { + putElements("items") { textBlock(day); textBlock(temp); textBlock(cond) } + } + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Lisbon forecast") + putJsonObjectEntry { + put("type", "ColumnSet") + put("columns", buildJsonArray { + add(column("Today", "24°", "Sunny")) + add(column("Tomorrow", "21°", "Partly cloudy")) + add(column("Wed", "19°", "Rain")) + }) + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val columnSet = assertIs(root.items[1]) + assertEquals(3, columnSet.columns.size) + assertEquals(3, columnSet.columns[0].items.size) + } + + // --- ordertracking --- + + @Test + fun orderTrackingParsesFactSetAndStepper() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Your order is on the way") + putJsonObjectEntry { + put("type", "FactSet") + putFacts("facts") { fact("Order", "#48213"); fact("ETA", "Arrives by 6:30 PM") } + } + putJsonObjectEntry { + put("type", "Stepper") + putSteps("steps") { + step("Order placed", "DONE") + step("Preparing", "DONE") + step("Out for delivery", "CURRENT") + step("Delivered", "UPCOMING") + } + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val stepper = assertIs(root.items[2]) + assertEquals(4, stepper.steps.size) + } + + // --- video --- + + @Test + fun videoParsesMediaWithDuration() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Watch: **Alfama** walking tour") + putJsonObjectEntry { + put("type", "Media") + put("url", "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4") + put("posterUrl", "https://example.com/poster.jpg") + put("duration", "4:32") + } + } + } + + val root = assertIs(CardParser.parse(json)) + assertNoUnsupported(root) + val media = assertIs(root.items[1]) + assertEquals("4:32", media.duration) + } + + // --- graceful degradation --- + + @Test + fun unrecognizedTypeDegradesToUnsupportedWithoutThrowing() { + val json = buildJsonObject { + put("type", "SomeFutureElementKind") + put("someField", "someValue") + } + + val result = assertIs(CardParser.parse(json)) + assertEquals("SomeFutureElementKind", result.rawType) + assertEquals(json, result.raw) + } + + @Test + fun unrecognizedNestedChildDegradesOnlyThatChildNotTheWholeCard() { + val json = buildJsonObject { + put("type", "Container") + putElements("items") { + textBlock("Still readable") + putJsonObjectEntry { put("type", "SomeFutureWidget") } + } + } + + val root = assertIs(CardParser.parse(json)) + assertIs(root.items[0]) + assertIs(root.items[1]) + } + + @Test + fun recognizedTypeWithMissingRequiredFieldDegradesToUnsupportedInsteadOfThrowing() { + val json = buildJsonObject { + put("type", "TextBlock") + // "text" deliberately omitted - a recognized type with the wrong shape. + } + + val result = assertIs(CardParser.parse(json)) + assertEquals("TextBlock", result.rawType) + } + + @Test + fun unrecognizedEnumValueFallsBackToDefaultRatherThanFailingTheElement() { + val json = buildJsonObject { + put("type", "TextBlock") + put("text", "Hello") + put("weight", "SuperUltraBold") // not a real TextWeight value + } + + val result = assertIs(CardParser.parse(json)) + assertEquals("Hello", result.text) + assertEquals(TextWeight.DEFAULT, result.weight) + } +} + +// --- Fixture-building DSL helpers - kept local to this test file; not part of the library's +// public API. --- + +private fun MutableList.textBlock(text: String) = + add(buildJsonObject { put("type", "TextBlock"); put("text", text) }) + +private fun kotlinx.serialization.json.JsonObjectBuilder.putElements(key: String, build: MutableList.() -> Unit) { + val items = mutableListOf().apply(build) + put(key, buildJsonArray { items.forEach { add(it) } }) +} + +private fun MutableList.putJsonObjectEntry(build: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit) { + add(buildJsonObject(build)) +} + +private fun kotlinx.serialization.json.JsonObjectBuilder.putFacts(key: String, build: MutableList.() -> Unit) { + val facts = mutableListOf().apply(build) + put(key, buildJsonArray { facts.forEach { add(it) } }) +} + +private fun MutableList.fact(title: String, value: String) = + add(buildJsonObject { put("title", title); put("value", value) }) + +private fun kotlinx.serialization.json.JsonObjectBuilder.putSteps(key: String, build: MutableList.() -> Unit) { + val steps = mutableListOf().apply(build) + put(key, buildJsonArray { steps.forEach { add(it) } }) +} + +private fun MutableList.step(label: String, status: String) = + add(buildJsonObject { put("label", label); put("status", status) }) + +private fun kotlinx.serialization.json.JsonObjectBuilder.putChoices(key: String, build: MutableList.() -> Unit) { + val choices = mutableListOf().apply(build) + put(key, buildJsonArray { choices.forEach { add(it) } }) +} + +private fun MutableList.choice(title: String, value: String, percentage: Int? = null) = + add(buildJsonObject { + put("title", title) + put("value", value) + if (percentage != null) put("percentage", percentage) + }) + +private fun kotlinx.serialization.json.JsonObjectBuilder.putActions(key: String, build: MutableList.() -> Unit) { + val actions = mutableListOf().apply(build) + put(key, buildJsonArray { actions.forEach { add(it) } }) +} + +private fun MutableList.action( + id: String, + title: String, + style: String? = null, + mode: String? = null, + notifyTemplate: String? = null, + immediate: Boolean = false, + triggerInputId: String? = null, + doneTitle: String? = null, + enabledWhenInputsFilled: List? = null, +) = add(buildJsonObject { + put("id", id) + put("title", title) + if (style != null) put("style", style) + if (mode != null) put("mode", mode) + if (notifyTemplate != null) put("notifyTemplate", notifyTemplate) + if (immediate) put("immediate", true) + if (triggerInputId != null) put("triggerInputId", triggerInputId) + if (doneTitle != null) put("doneTitle", doneTitle) + if (enabledWhenInputsFilled != null) put("enabledWhenInputsFilled", buildJsonArray { enabledWhenInputsFilled.forEach { add(JsonPrimitive(it)) } }) +}) + +/** Recursively asserts no [CardElement.Unsupported] appears anywhere in the tree - a + * test-local mirror of `CardEngine`'s private `flatten()` (not shared - that one's an + * implementation detail of a different file, not worth exposing just for this). */ +private fun assertNoUnsupported(element: CardElement) { + assertFalse(element is CardElement.Unsupported, "unexpected Unsupported: $element") + when (element) { + is CardElement.Container -> element.items.forEach { assertNoUnsupported(it) } + is CardElement.ColumnSet -> element.columns.forEach { column -> column.items.forEach { assertNoUnsupported(it) } } + is CardElement.Carousel -> element.pages.forEach { assertNoUnsupported(it) } + else -> {} + } +} diff --git a/cards-ios/Package.resolved b/cards-ios/Package.resolved new file mode 100644 index 0000000..c810ecc --- /dev/null +++ b/cards-ios/Package.resolved @@ -0,0 +1,75 @@ +{ + "pins" : [ + { + "identity" : "equatable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/equatable", + "state" : { + "revision" : "63e597c07e1e0622e6c8f2b33c972525a444c8eb", + "version" : "1.0.10" + } + }, + { + "identity" : "highlightswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/appstefan/highlightswift", + "state" : { + "revision" : "99c431b38a1444a5fd6a4978307fbbefe3a7af53" + } + }, + { + "identity" : "iosmath", + "kind" : "remoteSourceControl", + "location" : "https://github.com/junyan72/iosMath", + "state" : { + "revision" : "ba9ab7729b151329c54fd895a7c1859981d9484c" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown.git", + "state" : { + "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94", + "version" : "0.7.3" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, + { + "identity" : "swiftstreamingmarkdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ramonfsk/SwiftStreamingMarkdown", + "state" : { + "branch" : "fix-paragraph-hugging-width", + "revision" : "e6dbf08b47b88dda1d06980324e736f6793d84f6" + } + }, + { + "identity" : "swiftui-shimmer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/markiv/SwiftUI-Shimmer", + "state" : { + "revision" : "0226e21f9bf355d40e07e5f5e1c33679d50e167f", + "version" : "1.5.1" + } + } + ], + "version" : 2 +} diff --git a/cards-ios/Package.swift b/cards-ios/Package.swift new file mode 100644 index 0000000..a1daa5a --- /dev/null +++ b/cards-ios/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version:5.9 +import PackageDescription + +// NOTE: the binaryTarget below points at a real, locally-built .xcframework - see ui-ios's +// Package.swift for the identical convention (build it first via +// `./gradlew :cards-core:assembleThreadwireCardsXCFramework`). Its name MUST be "ThreadwireCards" +// - that's the Kotlin/Native-baked Obj-C/Swift module name (from cards-core/build.gradle.kts's +// `binaries.framework { baseName = "ThreadwireCards" }`), which is what Swift code actually +// `import`s, not an arbitrary SPM graph label - hence the hand-written renderer target below is +// named "ThreadwireCardsUI" instead, to avoid colliding with it. +let package = Package( + name: "ThreadwireCards", + platforms: [ + .iOS(.v16), // matches ui-ios's floor - see that package's own note on why + ], + products: [ + .library(name: "ThreadwireCardsUI", targets: ["ThreadwireCardsUI"]), + ], + dependencies: [ + // Local path, not a remote reference: cards-ios shares ui-ios's design tokens + // (ThreadwireColors/Typography/Theme) so card chrome matches bubble chrome, without + // the Android-side weight concern that made :cards-android need its own leaf + // :design-tokens-android module - see the M-Cards plan's "Token sharing" section. + // cards-core itself (the engine) has NO such dependency - see cards-core's own + // build.gradle.kts comment; only this UI-layer package pulls in chat UI, for tokens only. + .package(path: "../ui-ios"), + // TextBlockView renders through the same threadwireMarkdownConfig ui-ios's message + // bubbles use (now public - see that function's KDoc) - depending on ui-ios alone + // doesn't transitively re-export SwiftStreamingMarkdown's own types (MarkdownView, + // MarkdownRenderConfig) for direct use in this package's source, so it's declared here + // too, same pin as ui-ios's Package.swift (our fork, pending PR #158 upstream). + .package(url: "https://github.com/ramonfsk/SwiftStreamingMarkdown", branch: "fix-paragraph-hugging-width"), + ], + targets: [ + .binaryTarget( + name: "ThreadwireCards", + path: "../cards-core/build/XCFrameworks/release/ThreadwireCards.xcframework" + ), + // CardEngineStore.swift (this package's chat-session adapter - see that file's KDoc) + // needs ChatSession directly, which depending on "ThreadwireUI" doesn't transitively + // grant `import ThreadwireCore` for - same reasoning as the SwiftStreamingMarkdown + // dependency above, declared independently here rather than assumed transitive. + .binaryTarget( + name: "ThreadwireCore", + path: "../core/build/XCFrameworks/release/ThreadwireCore.xcframework" + ), + .target( + name: "ThreadwireCardsUI", + dependencies: [ + "ThreadwireCards", + "ThreadwireCore", + .product(name: "ThreadwireUI", package: "ui-ios"), + .product(name: "SwiftStreamingMarkdown", package: "SwiftStreamingMarkdown"), + ] + ), + ] +) diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardActionViews.swift b/cards-ios/Sources/ThreadwireCardsUI/CardActionViews.swift new file mode 100644 index 0000000..28bc470 --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardActionViews.swift @@ -0,0 +1,82 @@ +import SwiftUI +import ThreadwireCards +import ThreadwireUI + +/// `CardElementActionSet.afterSelection` is two independent visual behaviors, not one, matching +/// the source design exactly: `.hideUnselected` (`choices`) keeps only the fired action's button +/// once one has answered; `.markSelected` (`carousel`) keeps every button visible and only +/// relabels the fired one via `CardAction.doneTitle`. Each button's enabled state reads +/// `state.canInvoke(action:)` directly - see `CardActionViews.kt`'s (the Android counterpart) +/// identical note on why that's already correct with no extra logic needed here. +struct ActionSetView: View { + let element: CardElementActionSet + let engine: CardEngine + + @StateObject private var observable: CardEngineObservable + + init(element: CardElementActionSet, engine: CardEngine) { + self.element = element + self.engine = engine + self._observable = StateObject(wrappedValue: CardEngineObservable(engine: engine)) + } + + private var answered: CardAction? { + element.actions.first { observable.state.answeredActionIds.contains($0.id) } + } + + private var visibleActions: [CardAction] { + if element.afterSelection == .hideUnselected, let answered { + return [answered] + } + return element.actions + } + + var body: some View { + HStack(spacing: 8) { + ForEach(Array(visibleActions.enumerated()), id: \.offset) { _, action in + let isAnswered = observable.state.answeredActionIds.contains(action.id) + let label = (isAnswered ? action.doneTitle : nil) ?? action.title + ActionButton( + label: label, + style: action.style, + enabled: observable.state.canInvoke(action: action), + onClick: { engine.invoke(action: action) } + ) + } + } + } +} + +private struct ActionButton: View { + let label: String + let style: ActionStyle + let enabled: Bool + let onClick: () -> Void + + @Environment(\.threadwireColors) private var colors + + var body: some View { + Button(action: onClick) { + Text(label) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(backgroundColor) + .foregroundColor(foregroundColor) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .disabled(!enabled) + .opacity(enabled ? 1 : 0.4) + } + + private var backgroundColor: Color { + if style == .destructive { return colors.destructive } + if style == .positive { return colors.accent } + return Color.clear + } + + private var foregroundColor: Color { + if style == .destructive || style == .positive { return .white } + return colors.accent + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardContainerViews.swift b/cards-ios/Sources/ThreadwireCardsUI/CardContainerViews.swift new file mode 100644 index 0000000..3c2e2de --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardContainerViews.swift @@ -0,0 +1,76 @@ +import SwiftUI +import ThreadwireCards +import ThreadwireUI + +struct ContainerView: View { + let element: CardElementContainer + let engine: CardEngine + + @Environment(\.threadwireColors) private var colors + + var body: some View { + let emphasized = element.style == .emphasis + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(element.items.enumerated()), id: \.offset) { _, child in + ElementView(element: child, engine: engine) + } + } + .padding(emphasized ? 12 : 0) + .background(emphasized ? colors.surfaceAlt : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// All columns get equal width regardless of `CardElementColumnWidth` - a v1 simplification, see +/// `CardContainerViews.kt`'s (the Android counterpart) identical note: every design example only +/// ever needs equal columns. +struct ColumnSetView: View { + let element: CardElementColumnSet + let engine: CardEngine + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ForEach(Array(element.columns.enumerated()), id: \.offset) { _, column in + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(column.items.enumerated()), id: \.offset) { _, child in + ElementView(element: child, engine: engine) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +struct CarouselView: View { + let element: CardElementCarousel + let engine: CardEngine + + @Environment(\.threadwireColors) private var colors + + var body: some View { + if !element.pages.isEmpty { + TabView { + // Renders each page's items directly (not via ContainerView) and applies the + // "emphasized card" chrome here instead of trying to override page.style - Kotlin + // data class `copy()` bridges to Swift as `doCopy(...)`, a K/N Obj-C-export + // convention not exercised anywhere else in this codebase yet, so this sidesteps + // relying on it rather than risk an unverified assumption. + ForEach(Array(element.pages.enumerated()), id: \.offset) { _, page in + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(page.items.enumerated()), id: \.offset) { _, child in + ElementView(element: child, engine: engine) + } + } + .padding(12) + .background(colors.surfaceAlt) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.bottom, 24) // room for the page-dot indicator + } + } + .tabViewStyle(.page) + .frame(height: 160) + } + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardDisplayViews.swift b/cards-ios/Sources/ThreadwireCardsUI/CardDisplayViews.swift new file mode 100644 index 0000000..34f0747 --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardDisplayViews.swift @@ -0,0 +1,155 @@ +import SwiftUI +import ThreadwireCards +import ThreadwireUI +import SwiftStreamingMarkdown + +/// Every title/subtitle/fact value in the source design renders inline markdown (bold/italic), +/// not plain text - see the M-Cards plan's finding on `mdSpans`. Renders through the same +/// `threadwireMarkdownConfig` message bubbles use, so card prose matches bubble prose exactly. +/// +/// Size/weight/color are mapped with `if/else` chains, not `switch` - the same reasoning +/// `MessageListView.swift` already documents for `MessageAuthor`: Kotlin enums bridged via +/// Kotlin/Native's Obj-C export aren't necessarily `@frozen` Swift enums, so exhaustiveness +/// isn't reliable without a real build to confirm against. +struct TextBlockView: View { + let element: CardElementTextBlock + + @Environment(\.threadwireColors) private var colors + + private var fontSize: CGFloat { + if element.size == .small { return 12.5 } + if element.size == .medium { return 16 } + if element.size == .large { return 20 } + if element.size == .extraLarge { return 23 } + return 15.5 // .default + } + + private var color: Color { + if element.color == .light { return colors.textSecondary } + if element.color == .accent { return colors.accent } + if element.color == .good { return Color(red: 0.23, green: 0.54, blue: 0.30) } + if element.color == .warning { return Color(red: 0.72, green: 0.53, blue: 0.04) } + if element.color == .attention { return colors.destructive } + return colors.text // .default / .dark (no distinct dark token) + } + + var body: some View { + MarkdownView(text: element.text, config: threadwireMarkdownConfig(textColor: color, bodySize: fontSize)) + } +} + +struct ImageElementView: View { + let element: CardElementImage + + @Environment(\.threadwireColors) private var colors + + private var height: CGFloat { + if element.size == .small { return 80 } + if element.size == .large { return 220 } + return 140 // .medium + } + + var body: some View { + AsyncImage(url: URL(string: element.url)) { phase in + if let image = phase.image { + image.resizable().aspectRatio(contentMode: .fill) + } else { + Color.clear + } + } + .frame(maxWidth: .infinity) + .frame(height: height) + .background(colors.surfaceAlt) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipped() + } +} + +/// Circular avatar - `imageUrl` wins when present; otherwise derives initials from `name` the +/// same way the source design does client-side (presentation-only, not a computed-value +/// mechanism in the engine - see `CardElement.Avatar`'s KDoc in `cards-core`). +struct AvatarView: View { + let element: CardElementAvatar + + @Environment(\.threadwireColors) private var colors + + var body: some View { + ZStack { + Circle().fill(colors.accent) + if let urlString = element.imageUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().aspectRatio(contentMode: .fill).clipShape(Circle()) + } + } + } else { + Text(initials(of: element.name)) + .foregroundColor(.white) + .font(.system(size: 16, weight: .semibold)) + } + } + .frame(width: 48, height: 48) + } + + private func initials(of name: String) -> String { + name.split(separator: " ").prefix(2).compactMap { $0.first.map(String.init) }.joined().uppercased() + } +} + +struct FactSetView: View { + let element: CardElementFactSet + + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(element.facts.enumerated()), id: \.offset) { _, fact in + HStack { + Text(fact.title).font(typography.meta).foregroundColor(colors.textSecondary) + Spacer(minLength: 12) + Text(fact.value).font(typography.meta).foregroundColor(colors.text).fontWeight(.medium) + } + } + } + } +} + +struct StepperView: View { + let element: CardElementStepper + + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(element.steps.enumerated()), id: \.offset) { _, step in + HStack(spacing: 10) { + Circle() + .fill(step.status == .upcoming ? colors.border : colors.accent) + .frame(width: 10, height: 10) + Text(step.label) + .font(typography.msg) + .foregroundColor(step.status == .upcoming ? colors.textTertiary : colors.text) + } + } + } + } +} + +struct UnsupportedElementView: View { + let element: CardElementUnsupported + + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + var body: some View { + Text("Unsupported: \(element.rawType)") + .font(typography.meta) + .foregroundColor(colors.textTertiary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(colors.surfaceAlt) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardEngineObservable.swift b/cards-ios/Sources/ThreadwireCardsUI/CardEngineObservable.swift new file mode 100644 index 0000000..a9a5678 --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardEngineObservable.swift @@ -0,0 +1,37 @@ +import Foundation +import ThreadwireCards + +/// Bridges `CardEngine.state` (a Kotlin `StateFlow`) into SwiftUI's observation model - the +/// exact same pattern `ui-ios`'s `ChatSessionStore` already uses for `ChatSession.state`, via +/// `observeState(onChange:)` rather than collecting the `StateFlow`/`Flow` directly (no SKIE or +/// equivalent tooling in this project). +/// +/// NOTE: like `ChatSessionStore`, this file's exact interop shape - in particular whether +/// `CardSubscription` and the flattened Obj-C names for `CardElement`'s nested types +/// (`CardElementTextBlock`, `CardElementContainer`, ...) bridge as assumed here - has not been +/// verified against a real Xcode build yet. Flagging per this project's established pattern for +/// unverified Kotlin/Swift-interop assumptions (see `MessagePartView.swift`'s identical note). +@MainActor +final class CardEngineObservable: ObservableObject { + @Published private(set) var state: CardRuntimeState + + let engine: CardEngine + private var subscription: CardSubscription? + + init(engine: CardEngine) { + self.engine = engine + // StateFlow's generic `value` is type-erased across the K/N Swift bridge (no SKIE), + // so it comes back as `Any?` and needs an explicit cast - observeState's callback isn't + // affected, since its parameter type is concrete in Kotlin. + self.state = engine.state.value as! CardRuntimeState + self.subscription = engine.observeState { [weak self] newState in + DispatchQueue.main.async { + self?.state = newState + } + } + } + + deinit { + subscription?.close() + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardEngineStore.swift b/cards-ios/Sources/ThreadwireCardsUI/CardEngineStore.swift new file mode 100644 index 0000000..e05936f --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardEngineStore.swift @@ -0,0 +1,69 @@ +import Foundation +import SwiftUI +import ThreadwireCards +import ThreadwireCore + +/// Routes a card's `CardIntent.HostAction` (payment, saving a contact - the genuinely sensitive +/// actions this library never turns into a chat message on its own, design doc §9's principle). +/// A plain Swift closure, not a bridged Kotlin `CardActionHandler` conformance - `cards-core`'s +/// `fun interface CardActionHandler` bridges to Swift as either a protocol or a block depending +/// on how Kotlin/Native's Obj-C export treats a `fun interface` referenced this way, which isn't +/// exercised anywhere else in this codebase yet; a native Swift closure sidesteps that +/// uncertainty entirely at this API boundary instead of resting on an unverified assumption. +public typealias CardHostActionHandler = (_ cardId: String, _ actionId: String, _ data: [String: String]) -> Void + +/// Owns one `CardEngine` per card id, above the message list (`ChatView`, not `MessagePartView`) +/// - see the M-Cards plan's "Engine lifecycle" section on why: an engine constructed inside a +/// recycled `LazyVStack` row would lose its live input state (a half-typed form, a chosen +/// rating) every time that row scrolls off-screen and gets recomposed fresh. This is the thin +/// adapter the M-Cards plan's "Architecture" section calls for - `cards-core` itself never +/// touches `ChatSession`; this is where a `CardIntent.Notify` becomes `ChatSession.sendMessage` +/// and a `CardIntent.HostAction` reaches [onHostAction]. +/// +/// Set at `ChatView`'s root and threaded down via `@Environment`, the same idiom +/// `ThreadwireColors`/`ThreadwireTypography` already use - see `CardEngineStoreKey` below. +@MainActor +public final class CardEngineStore { + private let session: ChatSession + private let onHostAction: CardHostActionHandler? + private var engines: [String: CardEngine] = [:] + + public init(session: ChatSession, onHostAction: CardHostActionHandler? = nil) { + self.session = session + self.onHostAction = onHostAction + } + + /// Returns the existing engine for `cardId`, calling `resync(newRoot:)` on it first (a + /// cheap no-op if nothing actually changed - `CardRuntimeState`'s structural equality means + /// its `StateFlow` won't re-emit for an unchanged value, so this is safe to call on every + /// render without tracking "did the body actually change" separately) - or creates a fresh + /// one on first render. + public func engine(for cardId: String, currentRoot: CardElement) -> CardEngine { + if let existing = engines[cardId] { + existing.resync(newRoot: currentRoot) + return existing + } + let engine = CardEngine.companion.create(cardId: cardId, initialRoot: currentRoot) { [weak self] intent in + guard let self else { return } + if let notify = intent as? CardIntentNotify { + self.session.sendMessage(text: notify.text) + } else if let hostAction = intent as? CardIntentHostAction { + self.onHostAction?(hostAction.cardId, hostAction.actionId, hostAction.data) + } + } + engines[cardId] = engine + return engine + } +} + +private struct CardEngineStoreKey: EnvironmentKey { + static let defaultValue: CardEngineStore? = nil +} + +extension EnvironmentValues { + public var cardEngineStore: CardEngineStore? { + get { self[CardEngineStoreKey.self] } + set { self[CardEngineStoreKey.self] = newValue } + } +} + diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardInputViews.swift b/cards-ios/Sources/ThreadwireCardsUI/CardInputViews.swift new file mode 100644 index 0000000..2dd5db3 --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardInputViews.swift @@ -0,0 +1,261 @@ +import SwiftUI +import ThreadwireCards +import ThreadwireUI + +struct InputTextView: View { + let element: CardElementInputText + let engine: CardEngine + + @StateObject private var observable: CardEngineObservable + @State private var text: String + + init(element: CardElementInputText, engine: CardEngine) { + self.element = element + self.engine = engine + let observable = CardEngineObservable(engine: engine) + self._observable = StateObject(wrappedValue: observable) + self._text = State(initialValue: (observable.state.values[element.id] as? String) ?? element.value) + } + + var body: some View { + TextField(element.label, text: $text, prompt: element.placeholder.map { Text($0) }) + .textFieldStyle(.roundedBorder) + .onChange(of: text) { newValue in + engine.setValueAndTrigger(inputId: element.id, value: newValue) + } + } +} + +struct InputToggleView: View { + let element: CardElementInputToggle + let engine: CardEngine + + @StateObject private var observable: CardEngineObservable + + init(element: CardElementInputToggle, engine: CardEngine) { + self.element = element + self.engine = engine + self._observable = StateObject(wrappedValue: CardEngineObservable(engine: engine)) + } + + private var checked: Bool { + (observable.state.values[element.id] as? String).flatMap { Bool($0) } ?? element.value + } + + var body: some View { + Toggle(element.label, isOn: Binding( + get: { checked }, + set: { engine.setValueAndTrigger(inputId: element.id, value: String($0)) } + )) + } +} + +/// Renders all three presentation modes and both single/multi-select. Always calls +/// `setValueAndTrigger`/`toggleSelection`, never plain `setValue` - `setValueAndTrigger` is a +/// safe superset (it only fires a paired immediate action when one actually exists via +/// `CardAction.triggerInputId`; otherwise it's identical to `setValue`), so this view never has +/// to know whether this particular choice set happens to be wired to one (`poll`) or not +/// (`form`'s Guests picker) - see `CardInputViews.kt`'s (the Android counterpart) identical note. +struct InputChoiceSetView: View { + let element: CardElementInputChoiceSet + let engine: CardEngine + + @StateObject private var observable: CardEngineObservable + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + init(element: CardElementInputChoiceSet, engine: CardEngine) { + self.element = element + self.engine = engine + self._observable = StateObject(wrappedValue: CardEngineObservable(engine: engine)) + } + + private var selectedSingle: String? { + (observable.state.values[element.id] as? String) ?? element.value + } + + private var selectedMulti: Set { + (observable.state.selections[element.id] as? Set) ?? [] + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let label = element.label { + Text(label).font(typography.meta).foregroundColor(colors.textSecondary) + } + if element.presentation == .pollBar { + let voted = selectedSingle != nil + ForEach(Array(element.choices.enumerated()), id: \.offset) { _, choice in + PollBarRow(choice: choice, selected: choice.value == selectedSingle, voted: voted) { + if !voted { engine.setValueAndTrigger(inputId: element.id, value: choice.value) } + } + } + } else if element.presentation == .expanded { + ForEach(Array(element.choices.enumerated()), id: \.offset) { _, choice in + Button(action: { + if element.exclusiveSelect { engine.setValueAndTrigger(inputId: element.id, value: choice.value) } + else { engine.toggleSelection(inputId: element.id, choiceValue: choice.value) } + }) { + HStack { + Image(systemName: iconName(for: choice)) + .foregroundColor(colors.accent) + Text(choice.title).foregroundColor(colors.text) + Spacer() + } + } + .buttonStyle(.plain) + } + } else { // .compact + FlowLayout(spacing: 8) { + ForEach(Array(element.choices.enumerated()), id: \.offset) { _, choice in + let selected = element.exclusiveSelect ? choice.value == selectedSingle : selectedMulti.contains(choice.value) + PillButton(label: choice.title, selected: selected) { + if element.exclusiveSelect { engine.setValueAndTrigger(inputId: element.id, value: choice.value) } + else { engine.toggleSelection(inputId: element.id, choiceValue: choice.value) } + } + } + } + } + } + } + + private func iconName(for choice: CardElementChoice) -> String { + let selected = element.exclusiveSelect ? choice.value == selectedSingle : selectedMulti.contains(choice.value) + if element.exclusiveSelect { return selected ? "largecircle.fill.circle" : "circle" } + return selected ? "checkmark.square.fill" : "square" + } +} + +private struct PollBarRow: View { + let choice: CardElementChoice + let selected: Bool + let voted: Bool + let onVote: () -> Void + + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + // choice.percentage is Kotlin's `Int?`, which bridges to Swift not as a native `Int?` but as + // a boxed `KotlinInt?` (an NSNumber subclass) - confirmed against the real generated header + // (ThreadwireCards.h declares it `ThreadwireCardsInt * _Nullable`). `.intValue` is the + // correct unwrap, not `as? Int`. + private var percentage: Int32? { choice.percentage?.intValue } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(choice.title).font(typography.msg).foregroundColor(colors.text) + Spacer() + if let percentage { + Text("\(percentage)%").font(typography.meta).foregroundColor(colors.textSecondary) + } + } + if let percentage { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3).fill(colors.border).frame(height: 6) + RoundedRectangle(cornerRadius: 3) + .fill(selected ? colors.accent : colors.textTertiary) + .frame(width: geo.size.width * CGFloat(percentage) / 100, height: 6) + } + } + .frame(height: 6) + } + } + .padding(10) + .background(selected ? colors.accent.opacity(0.12) : colors.surfaceAlt) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .contentShape(Rectangle()) + .onTapGesture { if !voted { onVote() } } + } +} + +private struct PillButton: View { + let label: String + let selected: Bool + let onClick: () -> Void + + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + var body: some View { + Button(action: onClick) { + Text(label) + .font(typography.meta) + .foregroundColor(selected ? .white : colors.text) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(selected ? colors.accent : colors.surfaceAlt) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct InputRatingView: View { + let element: CardElementInputRating + let engine: CardEngine + + @StateObject private var observable: CardEngineObservable + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + init(element: CardElementInputRating, engine: CardEngine) { + self.element = element + self.engine = engine + self._observable = StateObject(wrappedValue: CardEngineObservable(engine: engine)) + } + + private var current: Int { + observable.state.values[element.id].flatMap { Int($0) } ?? element.value?.intValue.map(Int.init) ?? 0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let label = element.label { + Text(label).font(typography.meta).foregroundColor(colors.textSecondary) + } + HStack(spacing: 4) { + ForEach(1...Int(element.maxValue), id: \.self) { star in + Image(systemName: star <= current ? "star.fill" : "star") + .foregroundColor(star <= current ? colors.accent : colors.textTertiary) + .onTapGesture { engine.setValueAndTrigger(inputId: element.id, value: String(star)) } + } + } + } + } +} + +struct InputDateView: View { + let element: CardElementInputDate + let engine: CardEngine + + @State private var date = Date() + + var body: some View { + DatePicker(element.label, selection: $date, displayedComponents: .date) + .datePickerStyle(.compact) + .onChange(of: date) { newValue in + let formatter = DateFormatter() + formatter.dateFormat = "EEE, MMM d" + engine.setValueAndTrigger(inputId: element.id, value: formatter.string(from: newValue)) + } + } +} + +struct InputTimeView: View { + let element: CardElementInputTime + let engine: CardEngine + + @State private var time = Date() + + var body: some View { + DatePicker(element.label, selection: $time, displayedComponents: .hourAndMinute) + .datePickerStyle(.compact) + .onChange(of: time) { newValue in + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + engine.setValueAndTrigger(inputId: element.id, value: formatter.string(from: newValue)) + } + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardMediaView.swift b/cards-ios/Sources/ThreadwireCardsUI/CardMediaView.swift new file mode 100644 index 0000000..41bb727 --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardMediaView.swift @@ -0,0 +1,56 @@ +import SwiftUI +import AVKit +import ThreadwireCards +import ThreadwireUI + +/// Real playback via AVKit's `VideoPlayer` (iOS 14+, already under this package's iOS 16 floor - +/// no new dependency, unlike Android's Media3 addition) - see the M-Cards plan's "Video" section. +/// Shows the poster with a play affordance until tapped, then swaps in a real `AVPlayer` - +/// avoids paying player-init cost for every video card that's merely scrolled past. +struct MediaElementView: View { + let element: CardElementMedia + + @State private var isPlaying = false + @Environment(\.threadwireColors) private var colors + @Environment(\.threadwireTypography) private var typography + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ZStack { + RoundedRectangle(cornerRadius: 12).fill(colors.surfaceAlt) + if isPlaying, let url = URL(string: element.url) { + VideoPlayer(player: AVPlayer(url: url)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .onAppear { + // AVPlayer instances aren't reused across appearances; SwiftUI + // recreates this view's AVPlayer(url:) each time isPlaying flips + // true, and releases it (stopping playback/decoding) when this + // leaves the view tree - no separate teardown needed the way + // Android's ExoPlayer requires an explicit release() call. + } + } else { + if let posterUrlString = element.posterUrl, let posterUrl = URL(string: posterUrlString) { + AsyncImage(url: posterUrl) { phase in + if let image = phase.image { + image.resizable().aspectRatio(contentMode: .fill) + } + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipped() + } + Circle() + .fill(Color.black.opacity(0.55)) + .frame(width: 56, height: 56) + .overlay(Image(systemName: "play.fill").foregroundColor(.white)) + .onTapGesture { isPlaying = true } + } + } + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .frame(maxWidth: .infinity) + + if let duration = element.duration { + Text(duration).font(typography.meta).foregroundColor(colors.textSecondary) + } + } + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardRendering.swift b/cards-ios/Sources/ThreadwireCardsUI/CardRendering.swift new file mode 100644 index 0000000..d3957be --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardRendering.swift @@ -0,0 +1,43 @@ +import SwiftUI +import ThreadwireCards +import ThreadwireCore +import ThreadwireUI + +public extension View { + /// Wires M-Cards into a `ChatView`/`MessageListView` subtree - call this on `ChatView` (or + /// any of its ancestors) to make `MessagePart.Card`s render as real, interactive cards + /// instead of the inert placeholder chip `ui-ios` falls back to on its own. See `ui-ios`'s + /// `CardContentBuilder.swift` for why this can't just be `ui-ios`'s own default behavior - + /// `cards-ios` depends on `ui-ios` (for shared tokens), so the reverse isn't possible; this + /// is the concrete filler for the generic extension point that circular dependency forces. + /// + /// ```swift + /// ChatView(session: session) + /// .cardRendering(store: CardEngineStore(session: session)) + /// ``` + func cardRendering(store: CardEngineStore) -> some View { + environment(\.cardContentBuilder) { card in + AnyView(CardContentView(card: card, store: store)) + } + } +} + +/// `body` is a computed property, so `CardParser.shared.parseJson`/`store.engine(for:)` run on +/// every render pass rather than once - safe and cheap even so, since `CardEngine.resync` is a +/// no-op beyond the tree-walk when the parsed root is structurally unchanged +/// (`CardRuntimeState`'s value equality means its `StateFlow` won't re-emit, so no spurious +/// downstream re-render cascades from calling this repeatedly - see `CardEngineStore`'s KDoc). +private struct CardContentView: View { + let card: MessagePartCard + let store: CardEngineStore + + var body: some View { + // body is null between card-start and the first card-update (:core's own documented + // window) - a loading affordance, not a misleading empty card. + if let json = card.bodyAsJsonString() { + CardView(engine: store.engine(for: card.id, currentRoot: CardParser.shared.parseJson(jsonString: json))) + } else { + ProgressView() + } + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/CardView.swift b/cards-ios/Sources/ThreadwireCardsUI/CardView.swift new file mode 100644 index 0000000..3156361 --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/CardView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import ThreadwireCards + +/// Entry point: renders the current root of `engine`'s state and re-renders on every state +/// change (a tapped action, a `resync` from a server `card-update`, a typed input, ...). +/// +/// One `CardView` per `MessagePart.Card` - the host looks the `CardEngine` instance up from its +/// own `CardEngineStore` rather than constructing one here, so the same engine (and its live +/// input state - a half-typed form, a chosen rating) survives scrolling the card off-screen and +/// back in a `LazyVStack`. See the M-Cards plan's "Engine lifecycle" section - this is the same +/// class of bug the M2.6 streaming-markdown fix already had to solve once for message bubbles. +public struct CardView: View { + @StateObject private var observable: CardEngineObservable + + public init(engine: CardEngine) { + _observable = StateObject(wrappedValue: CardEngineObservable(engine: engine)) + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + ElementView(element: observable.state.root, engine: observable.engine) + } + .padding(12) + } +} + +/// Recursive dispatch, one branch per `CardElement` subtype. Kotlin's sealed interface exports +/// to Swift as flattened, prefixed class names (`CardElement.TextBlock` -> `CardElementTextBlock`) +/// - the same convention `:core`'s `MessagePart` sealed interface already uses +/// (`MessagePart.Text` -> `MessagePartText`, used throughout `ui-ios`'s existing bubble views). +@ViewBuilder +func ElementView(element: CardElement, engine: CardEngine) -> some View { + if let e = element as? CardElementTextBlock { + TextBlockView(element: e) + } else if let e = element as? CardElementImage { + ImageElementView(element: e) + } else if let e = element as? CardElementAvatar { + AvatarView(element: e) + } else if let e = element as? CardElementMedia { + MediaElementView(element: e) + } else if let e = element as? CardElementContainer { + ContainerView(element: e, engine: engine) + } else if let e = element as? CardElementColumnSet { + ColumnSetView(element: e, engine: engine) + } else if let e = element as? CardElementFactSet { + FactSetView(element: e) + } else if let e = element as? CardElementCarousel { + CarouselView(element: e, engine: engine) + } else if let e = element as? CardElementStepper { + StepperView(element: e) + } else if let e = element as? CardElementInputText { + InputTextView(element: e, engine: engine) + } else if let e = element as? CardElementInputChoiceSet { + InputChoiceSetView(element: e, engine: engine) + } else if let e = element as? CardElementInputToggle { + InputToggleView(element: e, engine: engine) + } else if let e = element as? CardElementInputDate { + InputDateView(element: e, engine: engine) + } else if let e = element as? CardElementInputTime { + InputTimeView(element: e, engine: engine) + } else if let e = element as? CardElementInputRating { + InputRatingView(element: e, engine: engine) + } else if let e = element as? CardElementActionSet { + ActionSetView(element: e, engine: engine) + } else if let e = element as? CardElementUnsupported { + UnsupportedElementView(element: e) + } else { + EmptyView() + } +} diff --git a/cards-ios/Sources/ThreadwireCardsUI/FlowLayout.swift b/cards-ios/Sources/ThreadwireCardsUI/FlowLayout.swift new file mode 100644 index 0000000..22dc10c --- /dev/null +++ b/cards-ios/Sources/ThreadwireCardsUI/FlowLayout.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Minimal wrapping horizontal layout (SwiftUI's `Layout` protocol, iOS 16+ - matches this +/// package's floor) for `InputChoiceSetView`'s `.compact` presentation (pill buttons that wrap +/// to a new line rather than overflow or scroll). SwiftUI has no built-in flow/wrap layout. +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var rowWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if rowWidth + size.width > maxWidth, rowWidth > 0 { + totalHeight += rowHeight + spacing + rowWidth = 0 + rowHeight = 0 + } + rowWidth += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + totalHeight += rowHeight + return CGSize(width: maxWidth.isFinite ? maxWidth : rowWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX, x > bounds.minX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index ae221c0..7a474a7 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -49,7 +49,14 @@ kotlin { implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentNegotiation) implementation(libs.ktor.serialization.kotlinxJson) - implementation(libs.kotlinx.serialization.json) + // api, not implementation: MessagePart.Card.body/Custom.raw/Unknown.raw/ + // ToolCall.input are all typed JsonObject/JsonElement in :core's own public API + // (the "never interpret opaque card/tool payloads" principle means handing the raw + // JsonObject straight to the consumer) - a consumer touching those fields needs + // kotlinx.serialization.json on its own compile classpath too, which `implementation` + // doesn't provide transitively. Surfaced by M-Cards' CardParser being the first + // consumer to actually read MessagePart.Card.body from outside this module. + api(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) } commonTest.dependencies { diff --git a/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatMessage.kt b/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatMessage.kt index fd4aee4..96fdf8c 100644 --- a/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatMessage.kt +++ b/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatMessage.kt @@ -1,5 +1,6 @@ package com.fsk.threadwire.session +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -59,3 +60,19 @@ sealed interface MessagePart { /** Forward-compatible catch-all - never dropped, so a newer BFF can't crash an older client. */ data class Unknown(val type: String, val raw: JsonObject) : MessagePart } + +/** + * Re-serializes [MessagePart.Card.body] to a plain JSON string - the only way to hand it to a + * *different*, independently-compiled Kotlin/Native XCFramework's Swift-facing API (M-Cards' + * `cards-core`) on iOS. `:core` and `cards-core` each bake their own Objective-C/Swift bindings + * for `kotlinx.serialization.json` types at XCFramework-build time; Swift sees + * `ThreadwireCore`'s `JsonElement`/`JsonObject` and `ThreadwireCards`'s as two *unrelated* types, + * even though they're "the same" Kotlin type at the source level - confirmed by inspecting both + * generated headers directly (`MessagePartCard.body` and `CardParser.parse(body:)`'s parameter + * disagree in their Swift-visible element type). A plain `String` has no such problem - it + * bridges identically regardless of which XCFramework produced it - so `cards-core`'s + * `CardParser` gets a matching `parseJson(jsonString: String)` entry point instead of a + * `JsonObject`-typed one for iOS callers. Android has no equivalent need (one JVM classloader, + * one real `JsonObject` class shared naturally across every Gradle module). + */ +fun MessagePart.Card.bodyAsJsonString(): String? = body?.let { Json.encodeToString(JsonObject.serializer(), it) } diff --git a/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatStateReducer.kt b/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatStateReducer.kt index 769451e..074eaa2 100644 --- a/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatStateReducer.kt +++ b/core/src/commonMain/kotlin/com/fsk/threadwire/session/ChatStateReducer.kt @@ -44,19 +44,20 @@ object ChatStateReducer { // Full replace, not merge - same ID-reconciliation philosophy as M0's parser // and design doc §4.3 ("resending the same ID replaces the previous part"). + // Searches *every* message, not just the in-progress one (updateAnyMessagePart, + // unlike every other part-reconciliation case in this reducer) - M-Cards' server- + // driven cards (`progress`, `ordertracking`) are refreshed via a later card-update + // that legitimately arrives after the turn that created the card already finished. + // Scoping the lookup to inProgressMessage() the way every other event here does + // would silently no-op that refresh once the turn ends. is ChatEvent.CardUpdate -> { - val hasMatch = state.inProgressMessage()?.parts.orEmpty() - .any { it is MessagePart.Card && it.id == event.id } - if (hasMatch) { - state.updateInProgressPart( - matches = { it is MessagePart.Card && it.id == event.id }, - transform = { (it as MessagePart.Card).copy(body = event.body) }, - ) - } else { - // Defensive: a card-update with no matching card-start is out-of-order/ - // malformed input, but synthesizing a part beats silently dropping data. - state.appendPart(MessagePart.Card(event.id, version = 1, body = event.body, isComplete = false), nowMillis) - } + state.updateAnyMessagePart( + matches = { it is MessagePart.Card && it.id == event.id }, + transform = { (it as MessagePart.Card).copy(body = event.body) }, + ) + // Defensive: a card-update with no matching card-start anywhere is out-of- + // order/malformed input, but synthesizing a part beats silently dropping data. + ?: state.appendPart(MessagePart.Card(event.id, version = 1, body = event.body, isComplete = false), nowMillis) } is ChatEvent.CardEnd -> state.updateInProgressPart( @@ -195,6 +196,24 @@ object ChatStateReducer { return copy(messages = messages.dropLast(1) + last.copy(parts = newParts)) } + /** Like [updateInProgressPart] but searches every message, not just the in-progress one - + * see [ChatEvent.CardUpdate]'s handling above for why that's needed. Returns `null` (rather + * than `this` unchanged) when nothing matches in *any* message, so the caller can tell + * "genuinely nothing to update anywhere" apart from every other no-op case and fall back to + * a defensive synthesize instead of silently dropping the event. Matches the *last* message + * containing the part when a card id could somehow appear in more than one (same + * most-recent-wins convention as [markLastUserMessageFailed]). */ + private fun ChatState.updateAnyMessagePart( + matches: (MessagePart) -> Boolean, + transform: (MessagePart) -> MessagePart, + ): ChatState? { + val index = messages.indexOfLast { message -> message.parts.any(matches) } + if (index < 0) return null + val target = messages[index] + val newParts = target.parts.map { if (matches(it)) transform(it) else it } + return copy(messages = messages.mapIndexed { i, m -> if (i == index) target.copy(parts = newParts) else m }) + } + private fun ChatState.finalizeInProgressMessage(): ChatState { val last = inProgressMessage() ?: return this val finishedParts = last.parts.map { it.markComplete() } diff --git a/core/src/commonTest/kotlin/com/fsk/threadwire/session/ChatStateReducerTest.kt b/core/src/commonTest/kotlin/com/fsk/threadwire/session/ChatStateReducerTest.kt index 30dd7a7..c414710 100644 --- a/core/src/commonTest/kotlin/com/fsk/threadwire/session/ChatStateReducerTest.kt +++ b/core/src/commonTest/kotlin/com/fsk/threadwire/session/ChatStateReducerTest.kt @@ -55,6 +55,32 @@ class ChatStateReducerTest { assertTrue(card.isComplete) } + @Test + fun cardUpdateReachesCardInAlreadyCompletedMessage() { + // M-Cards' server-driven cards (progress, ordertracking) are refreshed by a card-update + // that arrives after the turn that created the card already finished - the reducer must + // still find and update that card, not silently no-op or synthesize a stray new message. + val bodyA = buildJsonObject { put("status", JsonPrimitive("out_for_delivery")) } + val bodyB = buildJsonObject { put("status", JsonPrimitive("delivered")) } + + val state = reduceAll( + listOf( + ChatEvent.CardStart(id = "card_1", version = 1), + ChatEvent.CardUpdate(id = "card_1", body = bodyA), + ChatEvent.CardEnd(id = "card_1"), + ChatEvent.Finish, + ChatEvent.CardUpdate(id = "card_1", body = bodyB), + ), + ) + + assertEquals(1, state.messages.size) + val message = state.messages.single() + assertTrue(message.isComplete) + val card = assertIs(message.parts.single()) + assertEquals(bodyB, card.body) + assertTrue(card.isComplete) + } + @Test fun cardUpdateWithNoMatchingStartSynthesizesCardDefensively() { val body = buildJsonObject { put("status", JsonPrimitive("processing")) } diff --git a/design-tokens-android/build.gradle.kts b/design-tokens-android/build.gradle.kts new file mode 100644 index 0000000..6ed8fb6 --- /dev/null +++ b/design-tokens-android/build.gradle.kts @@ -0,0 +1,37 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } +} + +dependencies { + // No dependency on :core - these are pure design values (Color/TextStyle/RoundedCornerShape), + // not chat/session concepts. Extracted from :ui-android so :cards-android's card chrome + // matches bubble chrome without pulling in unrelated chat UI - see the M-Cards plan's + // "Token sharing (Android)" section. + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui) +} + +android { + namespace = "com.fsk.threadwire.designtokens" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireColors.kt b/design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireColors.kt similarity index 98% rename from ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireColors.kt rename to design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireColors.kt index f94b0b9..c5899aa 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireColors.kt +++ b/design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireColors.kt @@ -1,4 +1,4 @@ -package com.fsk.threadwire.ui.theme +package com.fsk.threadwire.designtokens import androidx.compose.ui.graphics.Color diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireTheme.kt b/design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireTheme.kt similarity index 97% rename from ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireTheme.kt rename to design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireTheme.kt index 33927b5..c3e8235 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireTheme.kt +++ b/design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireTheme.kt @@ -1,4 +1,4 @@ -package com.fsk.threadwire.ui.theme +package com.fsk.threadwire.designtokens import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireTypography.kt b/design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireTypography.kt similarity index 98% rename from ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireTypography.kt rename to design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireTypography.kt index 58e615e..be50686 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/theme/ThreadwireTypography.kt +++ b/design-tokens-android/src/main/kotlin/com/fsk/threadwire/designtokens/ThreadwireTypography.kt @@ -1,4 +1,4 @@ -package com.fsk.threadwire.ui.theme +package com.fsk.threadwire.designtokens import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight diff --git a/docs/cards-wire-schema.md b/docs/cards-wire-schema.md new file mode 100644 index 0000000..66d2f9c --- /dev/null +++ b/docs/cards-wire-schema.md @@ -0,0 +1,130 @@ +# Cards wire schema + +**Status: this project's own proposal, not yet validated against a real BFF.** It's what `CardParser` (`cards-core/src/commonMain/kotlin/com/fsk/threadwire/cards/CardParser.kt`) actually parses today — the code is the source of truth; this document is a reference view of it, not the other way around. See design doc §8 for the reasoning behind the shape. + +## How a card arrives + +A card travels as `MessagePart.Card.body: JsonObject?` (`:core`), populated by `card-update` events (design doc §4.4). `CardParser.parse(body)` turns that `JsonObject` into a `CardElement` tree. The body's `"type"` field is the **root element** — almost always `Container`, but `progress`/`ordertracking` root directly at `Stepper` and `video` at `Container[TextBlock, Media]` (see the worked examples in `tools/fake-sse-server`'s `Main.kt`, one real payload per illustrative card). + +## Conventions + +- **`type` is case-sensitive** and must match a Kotlin element name exactly (`"TextBlock"`, `"Input.ChoiceSet"`, ...) — this is a fixed wire discriminator, like any other. +- **Enum-valued fields are case-insensitive** (`weight`, `style`, `mode`, `presentation`, `afterSelection`, `status`) — a BFF author's casing shouldn't sink an otherwise-valid element. +- **An unrecognized `type` never fails the card.** It parses to `Unsupported(rawType, raw)` and every other element in the tree still renders — see `CardParser`'s KDoc for why this can't be automatic `@Serializable` polymorphism (it throws on an unknown discriminator; this can't). +- **A required field that's missing or wrong-typed** degrades just that one element to `Unsupported` too (`rawType` keeps the real declared type in this case, since it *was* recognized — only its shape was wrong). +- **IDs are the only identity mechanism.** Elements have no `id` field except the interactive leaves (`Input.*`, `CardAction`) — those ids are what local runtime state, template substitution, and `enabledWhenInputsFilled` key off of. Reuse the same id across a `card-update` resync to keep the user's in-progress input; a changed id is treated as a different input. + +## Element reference + +| `type` | Fields | Notes | +|---|---|---| +| `TextBlock` | `text` (req), `weight`, `size`, `color` | Renders inline markdown (bold/italic) — every title/subtitle/fact-value in the source design does. `weight`: `DEFAULT`\|`LIGHTER`\|`BOLDER`. `size`: `DEFAULT`\|`SMALL`\|`MEDIUM`\|`LARGE`\|`EXTRA_LARGE`. `color`: `DEFAULT`\|`DARK`\|`LIGHT`\|`ACCENT`\|`GOOD`\|`WARNING`\|`ATTENTION`. | +| `Image` | `url` (req), `altText`, `size` | `size`: `SMALL`\|`MEDIUM`\|`LARGE`. | +| `Avatar` | `name` (req), `imageUrl` | `imageUrl` wins when present; otherwise the renderer derives initials from `name` (presentation-only, not a computed value). | +| `Media` | `url` (req), `posterUrl`, `duration` | Real playback (`VideoPlayer`/AVKit on iOS, Media3 ExoPlayer on Android) — not a placeholder. | +| `Container` | `items` (req, array of elements), `style` | `style`: `DEFAULT`\|`EMPHASIS`. Almost every card roots here. | +| `ColumnSet` | `columns` (req, array of `Column`) | `Column`: `items` (array of elements), `width` (`AUTO`\|`STRETCH`). | +| `FactSet` | `facts` (req, array of `Fact`) | `Fact`: `title` (req), `value` (req). Read-only key/value rows. | +| `Carousel` *(custom)* | `pages` (req, array of `Container`) | Non-`Container` entries are dropped, not fatal. Real Adaptive Cards has no clean equivalent. | +| `Stepper` *(custom)* | `steps` (req, array of `Step`) | `Step`: `label` (req), `status` (`DONE`\|`CURRENT`\|`UPCOMING`). Read-only; re-push wholesale via `card-update` as status changes. | +| `Input.Text` | `id` (req), `label` (req), `placeholder`, `value`, `isRequired` | | +| `Input.ChoiceSet` | `id` (req), `label`, `choices` (req, array of `Choice`), `presentation`, `exclusiveSelect`, `value` | `Choice`: `title` (req), `value` (req), `percentage` (server-computed, `poll` only — see below). `presentation`: `COMPACT`\|`EXPANDED`\|`POLL_BAR`. `exclusiveSelect: false` (e.g. `checklist`) makes this a multi-select — its runtime value is a *set* of choice values, not one string. | +| `Input.Toggle` | `id` (req), `label` (req), `value` | | +| `Input.Date` | `id` (req), `label` (req), `value` | Value is a plain string — this schema doesn't impose a date format. | +| `Input.Time` | `id` (req), `label` (req), `value` | Same. | +| `Input.Rating` | `id` (req), `label`, `maxValue` (default 5), `value` | | +| `ActionSet` | `actions` (req, array of `CardAction`), `afterSelection` | `afterSelection`: `NONE` (default) \| `HIDE_UNSELECTED` (`choices`: only the chosen button stays) \| `MARK_SELECTED` (`carousel`: every option stays visible, the chosen one's button relabels via `doneTitle`). | + +## `CardAction` + +```json +{ + "id": "yes", + "title": "Yes, cancel it", + "style": "DESTRUCTIVE", + "mode": "NOTIFY", + "notifyTemplate": "Yes, please cancel it.", + "immediate": false, + "triggerInputId": null, + "doneTitle": null, + "enabledWhenInputsFilled": [] +} +``` + +| Field | Notes | +|---|---| +| `id`, `title` | Required. | +| `style` | `DEFAULT`\|`POSITIVE`\|`DESTRUCTIVE`. | +| `mode` | `NOTIFY` (default) — round-trips as a synthesized chat message via `ChatSession.sendMessage`. `HOST_ACTION` — routes to the host's `CardActionHandler` instead; the library never sends a chat message for these. Reserve `HOST_ACTION` for genuinely sensitive actions (payment, saving a contact) — design doc §9's principle: *the card is a UX affordance, never an authorization mechanism.* | +| `notifyTemplate` | `{id}` placeholders, filled from current input/selection values at invoke time — see "Template substitution" below. Only meaningful for `mode: NOTIFY`. | +| `immediate` | Fires as soon as its paired input changes, no separate tap — used two ways: (a) the action **is** the tappable thing (`choices`' pills, `carousel`'s "Select" buttons — each option is its own immediate `CardAction`, no `triggerInputId` needed); (b) the action is paired with a *separate* input widget via `triggerInputId` (`rating`'s stars, `poll`'s choice pills — tapping the star both sets the value and fires the action). | +| `triggerInputId` | The input/choice-set id this action listens to when `immediate: true` and case (b) above applies. Null for case (a) or for a normal tap-only action. | +| `doneTitle` | Title the button switches to after firing once (`"Confirm meeting point"` → `"Confirmed"`), and — critically — **an action with `doneTitle` becomes one-shot**: it stays disabled after firing. An action *without* `doneTitle` (rating, choices, carousel, poll) stays tappable indefinitely, matching the source design (you can change a rating or switch your poll vote). | +| `enabledWhenInputsFilled` | List of input/choice-set ids that must all have a non-empty value/selection before this action is tappable (`form`, `checklist`, `datetime`). Empty = always enabled (subject to `doneTitle`'s one-shot rule above). | + +## Template substitution + +`{id}` in `notifyTemplate` is replaced with: +- A plain input (`Input.Text`/`Date`/`Time`/`Rating`) → its raw current value. +- A **single-select** `Input.ChoiceSet` → the chosen choice's **title** (not its wire `value`) — `poll`'s `{neighborhood}` reads "I voted for Alfama.", not "I voted for alfama." +- A **multi-select** `Input.ChoiceSet` → the selected choices' titles joined by `", "` — `checklist`'s `{items}` reads "Packing: Compact umbrella, Waterproof jacket.", not the raw values joined by commas. +- An id with no value anywhere → empty string, never the literal `{id}` left in the output. + +This is why `checklist` doesn't need a separate aggregation mechanism: it's modeled as **one** multi-select `Input.ChoiceSet`, not N `Input.Toggle`s, and template substitution's multi-select case does the joining automatically. + +## `poll`: percentages are server-computed + +`Choice.percentage` is the **one** field in this schema that arrives pre-computed rather than being derived on-device. The client renders whatever percentage it's handed and never tallies votes itself — a vote fires a `notify` action, and the server is expected to reply with a fresh `card-update` carrying the new percentages. This is a deliberate line (matching how real Adaptive Cards treats client-side state): keeping computation out of the engine is what keeps it from growing into an expression/binding language. + +## Worked examples + +`tools/fake-sse-server/src/main/kotlin/com/fsk/threadwire/tools/fakesse/Main.kt` has one complete, wire-accurate payload per illustrative card (16 total — `confirm`, `summary`, `carousel`, `rating`, `choices`, `form`, `location`, `checklist`, `datetime`, `progress`, `payment`, `contact`, `poll`, `weather`, `ordertracking`, `video`) — these are canonical, kept in sync with `CardParser` by an actual round-trip test (`cards-core`'s `CardParserTest`) and by running the server and inspecting the stream. Trigger any of them locally by including the card's keyword in a sent chat message (see `tools/fake-sse-server`'s own module doc). + +Two representative ones: + +**`confirm`** — the simplest interactive shape (`Container` → 2×`TextBlock` → `ActionSet`): + +```json +{ + "type": "Container", + "items": [ + { "type": "TextBlock", "text": "Cancel your Alfama hotel booking?" }, + { "type": "TextBlock", "text": "This can't be undone once confirmed." }, + { + "type": "ActionSet", + "afterSelection": "HIDE_UNSELECTED", + "actions": [ + { "id": "yes", "title": "Yes, cancel it", "style": "DESTRUCTIVE", "notifyTemplate": "Yes, please cancel it." }, + { "id": "no", "title": "No, keep it", "notifyTemplate": "No, keep it." } + ] + } + ] +} +``` + +**`poll`** — server-computed percentages, `immediate`+`triggerInputId`, `POLL_BAR` presentation: + +```json +{ + "type": "Container", + "items": [ + { "type": "TextBlock", "text": "Which neighborhood should we prioritize?" }, + { + "type": "Input.ChoiceSet", + "id": "neighborhood", + "presentation": "POLL_BAR", + "choices": [ + { "title": "Alfama", "value": "alfama", "percentage": 48 }, + { "title": "Baixa", "value": "baixa", "percentage": 31 }, + { "title": "Príncipe Real", "value": "principe", "percentage": 21 } + ] + }, + { + "type": "ActionSet", + "actions": [ + { "id": "vote", "title": "Vote", "immediate": true, "triggerInputId": "neighborhood", "notifyTemplate": "I voted for {neighborhood}." } + ] + } + ] +} +``` diff --git a/docs/design-doc.md b/docs/design-doc.md index ffa7ea5..8bcddb4 100644 --- a/docs/design-doc.md +++ b/docs/design-doc.md @@ -175,24 +175,32 @@ data class ChatConfig( ## 8. Dynamic cards (own schema, inspired by Adaptive Cards) -Decision: do not adopt Adaptive Cards wholesale (large schema, geared toward the M365/Teams ecosystem, and there's a licensing note on the official binaries worth checking before any dependency — the source code is MIT but consumption of the binary packages is subject to a Microsoft EULA; we avoid that ambiguity). Instead, a minimal subset of our own, covering only the most commonly used elements: +Decision: do not adopt Adaptive Cards wholesale (large schema, geared toward the M365/Teams ecosystem, and there's a licensing note on the official binaries worth checking before any dependency — the source code is MIT but consumption of the binary packages is subject to a Microsoft EULA; we avoid that ambiguity). Instead, a minimal subset of our own, borrowing AC's element vocabulary as inspiration, not its schema wholesale. Full field-by-field reference lives in `docs/cards-wire-schema.md`; this section is the shape and the reasoning. + +An element is `{ "type": "", ...fields }`. A card's root is a single element — almost always a `Container` (see §15.3's table for how each of the 16 illustrative cards composes). Every message part with an interactive element carries stable `id`s, since those are what local input state and template substitution key off of. Worked example (`confirm`): ```json { - "version": 1, - "elements": [ - { "type": "text", "text": "Confirm payment of **$120.00**?" }, - { "type": "image", "url": "https://..." }, - { "type": "input.text", "id": "note", "placeholder": "Note (optional)" }, - { "type": "input.choice", "id": "method", "options": ["Pix", "Card"] }, - { "type": "action.button", "actionId": "authorize_payment", "label": "Authorize", "payload": { "amount": 120.00, "txId": "abc123" } } + "type": "Container", + "items": [ + { "type": "TextBlock", "text": "Cancel your Alfama hotel booking?" }, + { "type": "TextBlock", "text": "This can't be undone once confirmed." }, + { + "type": "ActionSet", + "afterSelection": "HIDE_UNSELECTED", + "actions": [ + { "id": "yes", "title": "Yes, cancel it", "style": "DESTRUCTIVE", "notifyTemplate": "Yes, please cancel it." }, + { "id": "no", "title": "No, keep it", "notifyTemplate": "No, keep it." } + ] + } ] } ``` -- 100% native rendering (a small renderer per platform — manageable, because the schema is lean). -- `version` at the top, the same fallback principle used by Adaptive Cards (an older client that doesn't know how to render a new element ignores it and moves on). -- In-place updates via `card-update` (section 4.4) — the same "refresh" mechanism. +- 100% native rendering, no schema-interpreting engine on the wire — `:cards-core` parses this into a `CardElement` tree and `:cards-android`/`cards-ios` render it, one small composable/view per element kind. +- No `version` field at the card-envelope level (there is one on `MessagePart.Card` itself, from `card-start` — §4.4). Forward-compat instead relies on graceful per-element degradation: an unrecognized `type` string parses to `CardElement.Unsupported` rather than failing the whole card, and a client showing 90% of a card it partially understands is the actual fallback behavior, not a version negotiation. +- In-place updates via `card-update` (section 4.4) — the same "refresh" mechanism, and the one this schema was built to keep working for: a `progress`/`ordertracking` card's `Stepper` is meant to be re-pushed wholesale as things change server-side, same as any other element. +- Interaction is `notify` (the tap becomes a synthesized chat message — the common case) or `hostAction` (routed to the host's own handler instead, for anything sensitive — see §9). Both are per-`CardAction` fields in the payload, not a hardcoded list of "which card types are sensitive." ### 8.1 Who builds the card: the LLM or the BFF? @@ -222,6 +230,14 @@ interface ChatActionHandler { - The host decides what to do (call biometrics, open a confirmation screen, invoke the app's own internal transaction SDK). - Security principle: the card is a UX affordance, never an authorization mechanism. Every sensitive action must be independently revalidated by whoever executes it (the host / the BFF), exactly as it would be validated coming from any other channel — LLMs are susceptible to prompt injection via third-party content, so the card is never a source of authority. +`:cards-core` (§15.3) has its own `CardActionHandler` — a cards-scoped sibling of `ChatActionHandler` above, not a replacement for it. The two exist for different payload shapes (a whole chat turn's actions vs. one card's `id`/`actionId`/current-input-values), but the same black-box principle applies identically: `cards-core` makes no network call and decides nothing about what "payment" or "save contact" means, exactly like `ChatActionHandler` above. + +```kotlin +fun interface CardActionHandler { + fun handle(cardId: String, actionId: String, data: Map) +} +``` + ## 10. Telemetry ```kotlin @@ -352,7 +368,7 @@ This project has had a deliberate gap up to this point: it solves architecture, 4. **M2.5 — Chat resilience & history:** manual test scenarios (`tools/fake-sse-server`), SSE reconnection validation, non-duplicating message retry, server-fetched paginated history via `ChatHistoryProvider` (section 15.1). Inserted after M2 was already built; does not renumber the milestones below. 5. **M2.6 — Design System Adoption:** full replacement of M2's bubbles/banners/input bar/retry UX in both `:ui-android` and `:ui-ios` with a commissioned design (tokens, per-message failed-send treatment, chat-list sidebar, search overlay) — section 15.2. Also inserted without renumbering. 6. **M3 — Media:** file upload, native audio record/playback. -7. **M4 — Cards:** a generic, data-driven card-rendering engine (own `:cards-core`/`:cards-android`/`cards-ios` library) — section 15.3 replaces this milestone's earlier one-line placeholder with a concrete, validated scope. +7. **M4 — Cards:** a generic, data-driven card-rendering engine (standalone `:cards-core`/`:cards-android`/`cards-ios` library, no dependency on `:core`) — section 15.3 replaces this milestone's earlier one-line placeholder with the implemented scope. 8. **M5 — Telemetry:** `ChatTelemetrySink`, background pipeline with backpressure. 9. **M6 — Handoff:** `WebSocketChatTransport`, handoff events, presence/typing indicators. 10. **M7 — Sample apps:** `:sample-app-android` / `:sample-app-ios`, published to the app stores as a showcase. @@ -384,15 +400,17 @@ A commissioned design (design tool export, reverse-engineered from a standalone **Deferred out of M2.6** (design shows them, but they depend on features `:core` doesn't have yet — future milestones, not regressions): the built-in chat-list sidebar, in-chat search overlay, and `ChatListProvider` (a navigation/multi-session milestone); attachments + audio record/playback (M3); the media viewer, splash, auth/session-error full screens, header rename/delete/clear menu, and toasts (as-needed later); cards (M4). -### 15.3 M4 — Cards (detail, redefined) +### 15.3 M4 — Cards (detail, implemented) -Originally a one-line placeholder ("own schema, parser in `commonMain`, native renderers, `ChatActionHandler`"). Now concrete: a **generic, data-driven rendering engine**, not a fixed set of dedicated card types — deliberately scoped down from real Adaptive Cards (adaptivecards.microsoft.com), reusing its actual element vocabulary rather than inventing a competing one. +Originally a one-line placeholder ("own schema, parser in `commonMain`, native renderers, `ChatActionHandler`"). Now built: a **generic, data-driven rendering engine**, not a fixed set of dedicated card types — deliberately scoped down from real Adaptive Cards (adaptivecards.microsoft.com), reusing its actual element vocabulary rather than inventing a competing one. -- **Module structure:** new, separate `:cards-core` (KMP schema/engine, `api(projects.core)`), `:cards-android` (Compose renderers), `cards-ios/` (SwiftUI Swift package) - a standalone library, not folded into `:ui-android`/`ui-ios`, so card rendering is usable outside a chat context too. -- **Element vocabulary:** `TextBlock`, `Image`, `Media`, `Container`, `ColumnSet`/`Column`, `FactSet`, `ActionSet`, `Input.Text`/`Input.ChoiceSet`/`Input.Toggle`/`Input.Date`/`Input.Time`/`Input.Rating`, plus two custom additions real AC doesn't cleanly cover for chat (`Carousel`, `Stepper`). Validated by composing all 17 illustrative card layouts from the commissioned design (confirm/summary/carousel/rating/choices/form/location/checklist/datetime/progress/payment/contact/poll/weather/ordertracking/video) purely as data - zero new Kotlin/Swift code per new card design a BFF author invents later. -- **`:core` stays untouched:** `MessagePart.Card`'s `body: JsonObject?` stays opaque; `:cards-core` is what interprets it, matching the existing "never interpret card/tool payloads" principle. -- **Interaction round-trip:** most actions synthesize a chat message via the existing `ChatSession.sendMessage` (no new `:core` API needed) - a per-action `mode: notify | hostAction` flag (data-driven, not a hardcoded type list) instead routes genuinely sensitive actions (payment, saving a contact) through a new `CardActionHandler`, a cards-scoped sibling of §9's existing `ChatActionHandler` pattern, not a replacement for it. -- **Wire schema:** documented separately in `docs/cards-wire-schema.md`, marked as this project's own proposal pending real-BFF validation - §8 below is updated to reflect the vocabulary instead of its earlier generic sketch. +- **Module structure:** `:cards-core` (KMP schema/parser/engine), `:cards-android` (Compose renderers, depends on a new leaf `:design-tokens-android` extracted out of `:ui-android` so card chrome matches bubble chrome without pulling in unrelated chat UI), `cards-ios/` (SwiftUI Swift package, `ThreadwireCards.xcframework`). **`:cards-core` has no dependency on `:core`** — a deliberate standalone-library decision (the maintainer's own framing: this is worth enough, and large enough, to plausibly live in its own repo later, and a clean dependency boundary now makes that a `git mv`, not a refactor). It doesn't touch `ChatSession`, doesn't know what a chat message is: `CardEngine.invoke()` emits a `CardIntent` (`Notify(text)` or `HostAction(cardId, actionId, data)`), and a thin adapter living in `ui-android`/`ui-ios` (`CardEngineStore`, owned above the message list at `ChatScreen`/`ChatView` level, not inside a recycled row) is what turns a `Notify` into `ChatSession.sendMessage` or routes a `HostAction` to the host's `CardActionHandler`. +- **Element vocabulary:** `TextBlock`, `Image`, `Avatar`, `Media`, `Container`, `ColumnSet`/`Column`, `FactSet`, `ActionSet`, `Input.Text`/`Input.ChoiceSet`/`Input.Toggle`/`Input.Date`/`Input.Time`/`Input.Rating`, plus two custom additions real AC doesn't cleanly cover for chat (`Carousel`, `Stepper`). Validated by composing all **16** illustrative card layouts from the commissioned design (confirm/summary/carousel/rating/choices/form/location/checklist/datetime/progress/payment/contact/poll/weather/ordertracking/video) purely as data — zero new Kotlin/Swift code per new card design a BFF author invents later. Full field reference: `docs/cards-wire-schema.md`. +- **Not `@Serializable`:** automatic polymorphic deserialization throws on an unrecognized discriminator; `CardParser` hand-parses instead so an unknown `type` degrades that one element to `CardElement.Unsupported` (never the whole card) — the same graceful-degradation posture as `:core`'s `MessagePart.Unknown`, reimplemented in spirit since `:cards-core` can't depend on `:core` to reuse the type. +- **Four design-time resolutions**, found by actually composing the commissioned design's 16 cards rather than assumed up front: (1) `poll` percentages are server-computed and arrive pre-populated on `CardElement.Choice.percentage` — the client never tallies votes itself, keeping the engine from growing into an expression language; (2) `choices` hides unchosen options after a pick while `carousel` keeps all pages visible and only relabels the chosen one — two different behaviors, both covered by `ActionSet.afterSelection` (`HIDE_UNSELECTED` / `MARK_SELECTED`); (3) `form`/`checklist`'s "submit disabled until filled" gate is `CardAction.enabledWhenInputsFilled: List`, evaluated by `CardRuntimeState.canInvoke`; (4) `checklist`'s aggregate notify sentence ("Packing: umbrella, jacket.") comes from modeling it as one multi-select `Input.ChoiceSet` rather than N toggles — `{id}` template substitution joins the selected choices' titles automatically, no separate aggregation mechanism needed. +- **`:core` change required:** `ChatStateReducer` originally routed `card-update` only to the in-progress (not-yet-`isComplete`) message, so a server-driven update to a `progress`/`ordertracking` card could never reach it after the turn finished — exactly the case those two cards exist for. Reducer now finds the target `MessagePart.Card` by id across *all* messages, with test coverage for a `card-update` landing after completion. +- **Interaction round-trip:** most actions synthesize a chat message via the existing `ChatSession.sendMessage` (no new `:core` API needed) — a per-action `mode: NOTIFY | HOST_ACTION` field (data-driven, not a hardcoded type list) instead routes genuinely sensitive actions (payment, saving a contact) through `CardActionHandler` (§9). +- **Wire schema:** documented separately in `docs/cards-wire-schema.md`, marked as this project's own proposal pending real-BFF validation — §8 reflects the shape and reasoning, not the full field-by-field contract. ## 16. Risks and open questions diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5b8f068..1e704ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ androidx-espresso = "3.7.0" androidx-fragment = "1.8.5" androidx-lifecycle = "2.11.0-beta01" androidx-testExt = "1.3.0" +coil3 = "3.0.4" # coil-kt.coil3 - Compose Multiplatform-capable image loader, new for M-Cards (Image/Avatar elements) composeMultiplatform = "1.11.1" composeMaterialIcons = "1.7.3" # material-icons-core hasn't published a 1.11.x release yet (verified against Maven Central); independent version line junit = "4.13.2" @@ -18,6 +19,7 @@ kotlinx-coroutines = "1.9.0" kotlinx-serialization = "1.7.3" ktor = "3.5.1" material3 = "1.11.0-alpha07" +media3 = "1.5.1" # androidx.media3 ExoPlayer, new for M-Cards (video card real playback) mikepenzMarkdown = "0.41.0" # 0.42.0+ requires minCompileSdk=37 (verified via AAR metadata); we're on compileSdk 36 [libraries] @@ -54,6 +56,10 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } mikepenz-markdown-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "mikepenzMarkdown" } mikepenz-markdown-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "mikepenzMarkdown" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/sample-app-ios/sample-app-ios/ContentView.swift b/sample-app-ios/sample-app-ios/ContentView.swift index f52081d..2ad5b19 100644 --- a/sample-app-ios/sample-app-ios/ContentView.swift +++ b/sample-app-ios/sample-app-ios/ContentView.swift @@ -1,10 +1,16 @@ import SwiftUI import ThreadwireCore import ThreadwireUI +import ThreadwireCardsUI struct ContentView: View { + // Built explicitly (not via ChatView's config:sessionId: convenience initializer) because + // CardEngineStore also needs this same ChatSession - the convenience initializer hides it. + private let session = ChatSession.companion.create(config: sampleConfig, sessionId: "sample-session") + var body: some View { - ChatView(config: sampleConfig, sessionId: "sample-session") + ChatView(session: session) + .cardRendering(store: CardEngineStore(session: session)) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index c3bd8fc..11793fc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,4 +31,7 @@ dependencyResolutionManagement { include(":sample-app-android") include(":core") include(":ui-android") +include(":cards-core") +include(":cards-android") +include(":design-tokens-android") include(":tools:fake-sse-server") \ No newline at end of file diff --git a/tools/fake-sse-server/src/main/kotlin/com/fsk/threadwire/tools/fakesse/Main.kt b/tools/fake-sse-server/src/main/kotlin/com/fsk/threadwire/tools/fakesse/Main.kt index d3c27d1..4f3fa1b 100644 --- a/tools/fake-sse-server/src/main/kotlin/com/fsk/threadwire/tools/fakesse/Main.kt +++ b/tools/fake-sse-server/src/main/kotlin/com/fsk/threadwire/tools/fakesse/Main.kt @@ -132,8 +132,270 @@ private val longScenario = Scenario( }, ) -// Checked in this order; first keyword match wins, "happy-path" is the fallback. -private val scenariosByPriority = listOf(reconnectScenario, errorScenario, handoffScenario, longScenario) +/** + * Builds a standard "lead text, then a card" scenario - card-start/update/end wrapping the given + * [cardBodyJson] (already a complete JSON object literal, e.g. `{"type":"Container","items":[...]}` + * - matches `cards-core`'s `CardParser` wire convention, see that file's KDoc), preceded by + * [leadText] streamed as a single delta (real streaming granularity doesn't matter for a card + * scenario the way it does for [longScenario]) and followed by `finish`. One per M-Cards + * illustrative card layout (see the M-Cards plan's composition table) - [keyword] triggers it, + * [leadText] matches the commissioned design's own `CARD_LEAD_TEXT` copy for that card type. + */ +private fun cardScenario(name: String, keyword: String, leadText: String, cardBodyJson: String): Scenario { + val cardId = "card_$name" + // The multi-line JSON literals below are formatted across lines for source readability, but + // an SSE `data:` field must be exactly one line (raw newlines inside it break the wire + // framing - a real bug caught by actually running this server and inspecting the stream, not + // just by the JSON compiling as a Kotlin string). Collapsing whitespace *around* newlines + // only - never whitespace in the middle of a line - can't touch any string value's own + // spaces, since none of them span a source line break. + val flatBody = cardBodyJson.replace(Regex("\\s*\\n\\s*"), "") + return Scenario( + name = name, + keyword = keyword, + frames = listOf( + ScriptedFrame(1, """{"type":"text-start","id":"msg_1"}"""), + ScriptedFrame(2, """{"type":"text-delta","id":"msg_1","delta":"$leadText"}"""), + ScriptedFrame(3, """{"type":"text-end","id":"msg_1"}"""), + ScriptedFrame(4, """{"type":"card-start","id":"$cardId","version":1}"""), + ScriptedFrame(5, """{"type":"card-update","id":"$cardId","body":$flatBody}"""), + ScriptedFrame(6, """{"type":"card-end","id":"$cardId"}"""), + ScriptedFrame(7, """{"type":"finish"}"""), + ), + ) +} + +// One scenario per M-Cards illustrative card layout - see the M-Cards plan's composition table +// for how each JSON body maps to a CardElement tree, and docs/cards-wire-schema.md for the wire +// contract these bodies follow. Card ids/lead text match the commissioned design's own copy. +private val confirmScenario = cardScenario( + "confirm", "confirm", "Sure — here's what I found:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Cancel your Alfama hotel booking?"}, + {"type":"TextBlock","text":"This can't be undone once confirmed."}, + {"type":"ActionSet","afterSelection":"HIDE_UNSELECTED","actions":[ + {"id":"yes","title":"Yes, cancel it","style":"DESTRUCTIVE","notifyTemplate":"Yes, please cancel it."}, + {"id":"no","title":"No, keep it","notifyTemplate":"No, keep it."} + ]} + ]}""", +) + +private val summaryScenario = cardScenario( + "summary", "summary", "Here's where things stand:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Your Lisbon itinerary","weight":"BOLDER"}, + {"type":"FactSet","facts":[ + {"title":"Day 1","value":"Historic Center — Alfama & São Jorge Castle"}, + {"title":"Day 2","value":"Belém — Monastery & Tower"}, + {"title":"Day 3","value":"LX Factory — shopping & street art"} + ]} + ]}""", +) + +private val carouselScenario = cardScenario( + "carousel", "carousel", "I found a few options that could work:", + """{"type":"Carousel","pages":[ + {"type":"Container","items":[ + {"type":"TextBlock","text":"Casa do Alfama"}, + {"type":"TextBlock","text":"4.6 ★ · ${'$'}120/night"}, + {"type":"ActionSet","afterSelection":"MARK_SELECTED","actions":[ + {"id":"select_h1","title":"Select","immediate":true,"doneTitle":"Selected","notifyTemplate":"I'll go with Casa do Alfama."} + ]} + ]}, + {"type":"Container","items":[ + {"type":"TextBlock","text":"Lisbon Riverside"}, + {"type":"TextBlock","text":"4.3 ★ · ${'$'}95/night"}, + {"type":"ActionSet","afterSelection":"MARK_SELECTED","actions":[ + {"id":"select_h2","title":"Select","immediate":true,"doneTitle":"Selected","notifyTemplate":"I'll go with Lisbon Riverside."} + ]} + ]}, + {"type":"Container","items":[ + {"type":"TextBlock","text":"Castelo Boutique"}, + {"type":"TextBlock","text":"4.8 ★ · ${'$'}150/night"}, + {"type":"ActionSet","afterSelection":"MARK_SELECTED","actions":[ + {"id":"select_h3","title":"Select","immediate":true,"doneTitle":"Selected","notifyTemplate":"I'll go with Castelo Boutique."} + ]} + ]} + ]}""", +) + +private val ratingScenario = cardScenario( + "rating", "rating", "Glad that's sorted! One quick thing:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"How was this itinerary?"}, + {"type":"Input.Rating","id":"rating","maxValue":5}, + {"type":"ActionSet","actions":[ + {"id":"rate","title":"Rate","immediate":true,"triggerInputId":"rating","notifyTemplate":"Rating: {rating}/5 stars."} + ]} + ]}""", +) + +private val choicesScenario = cardScenario( + "choices", "choices", "Happy to tailor this. Quick question:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"What kind of trip are you after?"}, + {"type":"ActionSet","afterSelection":"HIDE_UNSELECTED","actions":[ + {"id":"adventure","title":"Adventure","immediate":true,"notifyTemplate":"I'm looking for a adventure trip."}, + {"id":"relax","title":"Relaxing","immediate":true,"notifyTemplate":"I'm looking for a relaxing trip."}, + {"id":"culture","title":"Cultural","immediate":true,"notifyTemplate":"I'm looking for a cultural trip."}, + {"id":"food","title":"Food & drink","immediate":true,"notifyTemplate":"I'm looking for a food & drink trip."} + ]} + ]}""", +) + +private val formScenario = cardScenario( + "form", "form", "Let's get that set up:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Book a table"}, + {"type":"Input.Text","id":"name","label":"Name","isRequired":true}, + {"type":"Input.Text","id":"date","label":"Date","placeholder":"e.g. Fri, 8pm","isRequired":true}, + {"type":"Input.ChoiceSet","id":"guests","label":"Guests","value":"2","choices":[ + {"title":"1","value":"1"},{"title":"2","value":"2"},{"title":"3","value":"3"}, + {"title":"4","value":"4"},{"title":"5","value":"5"},{"title":"6","value":"6"} + ]}, + {"type":"ActionSet","actions":[ + {"id":"submit","title":"Book","enabledWhenInputsFilled":["name","date"],"notifyTemplate":"Please book: Name: {name}, Date: {date}, Guests: {guests}."} + ]} + ]}""", +) + +private val locationScenario = cardScenario( + "location", "location", "Here's the spot:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Meeting point"}, + {"type":"TextBlock","text":"Praça do Comércio, 1100-148 Lisboa"}, + {"type":"ActionSet","actions":[ + {"id":"confirm","title":"Confirm meeting point","doneTitle":"Confirmed","notifyTemplate":"Works for me — I'll meet you at Praça do Comércio, 1100-148 Lisboa."} + ]} + ]}""", +) + +private val checklistScenario = cardScenario( + "checklist", "checklist", "Good call — here's a packing checklist:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Pack for rainy days"}, + {"type":"Input.ChoiceSet","id":"items","exclusiveSelect":false,"presentation":"EXPANDED","choices":[ + {"title":"Compact umbrella","value":"umbrella"}, + {"title":"Waterproof jacket","value":"jacket"}, + {"title":"Waterproof shoes","value":"shoes"}, + {"title":"Dry bag for electronics","value":"bag"} + ]}, + {"type":"ActionSet","actions":[ + {"id":"submit","title":"Pack it","enabledWhenInputsFilled":["items"],"notifyTemplate":"Packing: {items}."} + ]} + ]}""", +) + +private val datetimeScenario = cardScenario( + "datetime", "datetime", "Let's find a time:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"When should we schedule the call?"}, + {"type":"Input.Date","id":"date","label":"Date"}, + {"type":"Input.Time","id":"time","label":"Time"}, + {"type":"ActionSet","actions":[ + {"id":"submit","title":"Confirm","enabledWhenInputsFilled":["date","time"],"notifyTemplate":"Let's do {date} at {time}."} + ]} + ]}""", +) + +private val progressScenario = cardScenario( + "progress", "progress", "Here's where things stand on that:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Booking status"}, + {"type":"Stepper","steps":[ + {"label":"Request received","status":"DONE"}, + {"label":"Confirming with hotel","status":"CURRENT"}, + {"label":"Confirmation email","status":"UPCOMING"} + ]} + ]}""", +) + +private val paymentScenario = cardScenario( + "payment", "payment", "Here's the payment summary:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Confirm payment"}, + {"type":"TextBlock","text":"**Alfama Rooftop Suite** — 2 nights"}, + {"type":"FactSet","facts":[{"title":"Amount","value":"€238.00"},{"title":"Method","value":"•••• 4242"}]}, + {"type":"ActionSet","actions":[ + {"id":"pay","title":"Pay €238.00","mode":"HOST_ACTION","doneTitle":"Paid"} + ]} + ]}""", +) + +private val contactScenario = cardScenario( + "contact", "contact", "Here's who to reach out to:", + """{"type":"Container","items":[ + {"type":"Avatar","name":"Mariana Sousa"}, + {"type":"TextBlock","text":"Mariana Sousa"}, + {"type":"TextBlock","text":"*Licensed* Lisbon guide"}, + {"type":"FactSet","facts":[{"title":"Phone","value":"+351 91 234 5678"},{"title":"Email","value":"mariana@lisbonwalks.pt"}]}, + {"type":"ActionSet","actions":[ + {"id":"save_contact","title":"Save contact","mode":"HOST_ACTION","doneTitle":"Saved"} + ]} + ]}""", +) + +private val pollScenario = cardScenario( + "poll", "poll", "Let's see what people think:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Which neighborhood should we prioritize?"}, + {"type":"Input.ChoiceSet","id":"neighborhood","presentation":"POLL_BAR","choices":[ + {"title":"Alfama","value":"alfama","percentage":48}, + {"title":"Baixa","value":"baixa","percentage":31}, + {"title":"Príncipe Real","value":"principe","percentage":21} + ]}, + {"type":"ActionSet","actions":[ + {"id":"vote","title":"Vote","immediate":true,"triggerInputId":"neighborhood","notifyTemplate":"I voted for {neighborhood}."} + ]} + ]}""", +) + +private val weatherScenario = cardScenario( + "weather", "weather", "Here's the forecast:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Lisbon forecast"}, + {"type":"ColumnSet","columns":[ + {"items":[{"type":"TextBlock","text":"Today"},{"type":"TextBlock","text":"24°"},{"type":"TextBlock","text":"Sunny"}]}, + {"items":[{"type":"TextBlock","text":"Tomorrow"},{"type":"TextBlock","text":"21°"},{"type":"TextBlock","text":"Partly cloudy"}]}, + {"items":[{"type":"TextBlock","text":"Wed"},{"type":"TextBlock","text":"19°"},{"type":"TextBlock","text":"Rain"}]} + ]} + ]}""", +) + +private val orderTrackingScenario = cardScenario( + "ordertracking", "ordertracking", "Here's your order status:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Your order is on the way"}, + {"type":"FactSet","facts":[{"title":"Order","value":"#48213"},{"title":"ETA","value":"Arrives by 6:30 PM"}]}, + {"type":"Stepper","steps":[ + {"label":"Order placed","status":"DONE"}, + {"label":"Preparing","status":"DONE"}, + {"label":"Out for delivery","status":"CURRENT"}, + {"label":"Delivered","status":"UPCOMING"} + ]} + ]}""", +) + +private val videoScenario = cardScenario( + "video", "video", "Thought this might help:", + """{"type":"Container","items":[ + {"type":"TextBlock","text":"Watch: **Alfama** walking tour"}, + {"type":"Media","url":"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4","posterUrl":"https://picsum.photos/seed/alfama/640/360","duration":"4:32"} + ]}""", +) + +private val cardScenarios = listOf( + confirmScenario, summaryScenario, carouselScenario, ratingScenario, choicesScenario, + formScenario, locationScenario, checklistScenario, datetimeScenario, progressScenario, + paymentScenario, contactScenario, pollScenario, weatherScenario, orderTrackingScenario, + videoScenario, +) + +// Checked in this order; first keyword match wins, "happy-path" is the fallback. Card scenarios +// are checked last (after reconnect/error/handoff/long) so none of their keywords can shadow a +// message that happens to also mention e.g. "error" - none currently do, but this keeps the +// established priority convention rather than assuming it doesn't matter. +private val scenariosByPriority = listOf(reconnectScenario, errorScenario, handoffScenario, longScenario) + cardScenarios private val messageFieldRegex = Regex(""""message"\s*:\s*"((?:[^"\\]|\\.)*)"""") @@ -175,5 +437,6 @@ fun main() { } println("[fake-sse-server] starting on :$PORT ...") println("[fake-sse-server] scenarios: happy-path (default), reconnect, error, handoff, long - trigger by including the keyword in the sent message") + println("[fake-sse-server] card scenarios: " + cardScenarios.joinToString(", ") { it.keyword!! }) server.start(wait = true) } diff --git a/ui-android/build.gradle.kts b/ui-android/build.gradle.kts index aa91a35..97addf9 100644 --- a/ui-android/build.gradle.kts +++ b/ui-android/build.gradle.kts @@ -17,6 +17,15 @@ dependencies { // ChatConfig, ChatState) in their own public signatures, so consumers need them // transitively - this is what lets sample-app-android drop :core entirely. api(projects.core) + // Design tokens (ThreadwireColors/Typography/Theme) moved to their own module (M-Cards) + // so :cards-android can share them without depending on all of :ui-android's chat UI - + // api because ThreadwireTheme/ThreadwireColors appear in this module's own public + // composable signatures, same reasoning as :core above. + api(projects.designTokensAndroid) + // M-Cards: MessagePartRenderer's Card branch renders a real CardView via CardEngineStore + // (see that file's KDoc) - api because CardEngineStore's own constructor/engineFor take + // cards-core types (CardEngine, CardActionHandler, CardElement) in its public signature. + api(projects.cardsAndroid) implementation(libs.androidx.activity.compose) implementation(libs.androidx.fragment.ktx) diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/AttachmentPickerPopover.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/AttachmentPickerPopover.kt index a96a825..f2e9b15 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/AttachmentPickerPopover.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/AttachmentPickerPopover.kt @@ -9,7 +9,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * Paperclip popover - "Photo"/"File" chrome only, both inert (M3/media hasn't started, diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/CardEngineStore.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/CardEngineStore.kt new file mode 100644 index 0000000..78408d2 --- /dev/null +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/CardEngineStore.kt @@ -0,0 +1,57 @@ +package com.fsk.threadwire.ui + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import com.fsk.threadwire.cards.CardActionHandler +import com.fsk.threadwire.cards.CardEngine +import com.fsk.threadwire.cards.CardIntent +import com.fsk.threadwire.cards.schema.CardElement +import com.fsk.threadwire.session.ChatSession + +/** + * Owns one [CardEngine] per card id, above the message list (`ChatScreen`, not + * `MessagePartRenderer`) - see the M-Cards plan's "Engine lifecycle" section on why: an engine + * constructed inside a recycled `LazyColumn` row would lose its live input state (a half-typed + * form, a chosen rating) every time that row scrolls off-screen and gets recomposed fresh. This + * store is the thin adapter the M-Cards plan's "Architecture" section calls for - `cards-core` + * itself never touches [ChatSession] or [CardActionHandler]; this is where a [CardIntent.Notify] + * becomes [ChatSession.sendMessage] and a [CardIntent.HostAction] reaches the host's own handler. + * + * Exposed via [LocalCardEngineStore] (a `CompositionLocal`, the same idiom `ThreadwireTheme` + * already uses for "needed almost everywhere, awkward to thread as an explicit parameter") + * rather than as an explicit parameter threaded through `MessageList`/`AssistantBubble`/etc. - + * keeps this milestone from having to touch every bubble file's signature for one cross-cutting + * concern. + */ +@Stable +class CardEngineStore( + private val session: ChatSession, + private val actionHandler: CardActionHandler?, +) { + private val engines = mutableMapOf() + + /** + * Returns the existing engine for [cardId], calling `resync(currentRoot)` on it first (a + * cheap no-op if nothing actually changed - `CardRuntimeState`'s structural equality means + * `StateFlow` won't re-emit for an unchanged value, so this is safe to call on every + * recomposition without tracking "did the body actually change" separately) - or creates a + * fresh one on first render. + */ + fun engineFor(cardId: String, currentRoot: CardElement): CardEngine { + val existing = engines[cardId] + if (existing != null) { + existing.resync(currentRoot) + return existing + } + val engine = CardEngine.create(cardId, currentRoot) { intent -> + when (intent) { + is CardIntent.Notify -> session.sendMessage(intent.text) + is CardIntent.HostAction -> actionHandler?.handle(intent.cardId, intent.actionId, intent.data) + } + } + engines[cardId] = engine + return engine + } +} + +val LocalCardEngineStore = staticCompositionLocalOf { null } diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatHeaderBar.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatHeaderBar.kt index 6fde267..1669191 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatHeaderBar.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatHeaderBar.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.fsk.threadwire.ui.icons.ThreadwireIcons -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * chatHeader: menu (left), search+close (right), and the title ABSOLUTELY centered so it diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatInputBar.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatInputBar.kt index f4e51da..14a8ad9 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatInputBar.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatInputBar.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.fsk.threadwire.ui.icons.ThreadwireIcons -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * The design's composer (`composerWrap` + `inputRow`): a single rounded-pill row holding diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatScreen.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatScreen.kt index f440b96..be7d08b 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatScreen.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/ChatScreen.kt @@ -31,11 +31,13 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fsk.threadwire.cards.CardActionHandler import com.fsk.threadwire.session.ChatConfig import com.fsk.threadwire.session.ChatSession import com.fsk.threadwire.ui.icons.ThreadwireIcons -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme import kotlinx.coroutines.launch /** @@ -57,8 +59,15 @@ fun ChatScreen( onMenuClick: () -> Unit = {}, onSearchClick: () -> Unit = {}, onClose: () -> Unit = {}, + // M-Cards: routes a card's CardIntent.HostAction (payment, saving a contact - the + // genuinely sensitive actions this library never turns into a chat message on its own, + // design doc §9's principle). Null (the default) means those actions simply do nothing - + // matches this project's established "an unimplemented host capability is inert, not a + // crash" posture (e.g. the M2.6 attachment picker). + cardActionHandler: CardActionHandler? = null, ) { val state by session.state.collectAsStateWithLifecycle() + val cardEngineStore = remember(session, cardActionHandler) { CardEngineStore(session, cardActionHandler) } var inputText by rememberSaveable { mutableStateOf("") } val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -93,6 +102,7 @@ fun ChatScreen( } ThreadwireTheme { + CompositionLocalProvider(LocalCardEngineStore provides cardEngineStore) { Box(modifier = modifier.fillMaxSize().background(ThreadwireTheme.colors.bg)) { Column(modifier = Modifier.fillMaxSize()) { ChatHeaderBar( @@ -162,6 +172,7 @@ fun ChatScreen( ) } } + } } } @@ -182,6 +193,7 @@ fun ChatScreen( onMenuClick: () -> Unit = {}, onSearchClick: () -> Unit = {}, onClose: () -> Unit = {}, + cardActionHandler: CardActionHandler? = null, ) { val session = remember(config, sessionId) { ChatSession.create(config, sessionId) } DisposableEffect(session) { @@ -194,6 +206,7 @@ fun ChatScreen( assistantName = assistantName, suggestedPrompts = suggestedPrompts, onMenuClick = onMenuClick, + cardActionHandler = cardActionHandler, onSearchClick = onSearchClick, onClose = onClose, ) diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessageList.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessageList.kt index 16656f4..e67f0d8 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessageList.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessageList.kt @@ -20,7 +20,7 @@ import com.fsk.threadwire.ui.bubbles.AssistantBubble import com.fsk.threadwire.ui.bubbles.HumanAgentBubble import com.fsk.threadwire.ui.bubbles.SystemBanner import com.fsk.threadwire.ui.bubbles.UserBubble -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme @Composable internal fun MessageList( diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessagePartRenderer.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessagePartRenderer.kt index ae096ae..6f2f081 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessagePartRenderer.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/MessagePartRenderer.kt @@ -3,22 +3,24 @@ package com.fsk.threadwire.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import com.fsk.threadwire.cards.CardParser +import com.fsk.threadwire.cards.ui.CardView import com.fsk.threadwire.session.MessagePart -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.model.rememberMarkdownState /** - * Renders a single [MessagePart]. Non-text parts get a minimal, visually inert - * placeholder chip - real card rendering is the M-Cards milestone's job (a separate - * library), not this one. Using an exhaustive `when` means a new [MessagePart] case + * Renders a single [MessagePart]. Using an exhaustive `when` means a new [MessagePart] case * added later is a compile error here, not a silent gap. * * [textColor] defaults to the design tokens' body text color, but the user bubble @@ -46,7 +48,29 @@ internal fun MessagePartRenderer( } is MessagePart.ToolCall -> InertPlaceholderChip("Tool call", modifier) - is MessagePart.Card -> InertPlaceholderChip("Card", modifier) + + is MessagePart.Card -> { + val body = part.body + val store = LocalCardEngineStore.current + if (body == null || store == null) { + // body is null between card-start and the first card-update (:core's own + // documented window); store is null only if this MessagePartRenderer is used + // outside a real ChatScreen (e.g. a preview) - both render the same waiting + // affordance rather than a misleading "Card" chip. + CircularProgressIndicator(modifier = modifier.padding(8.dp)) + } else { + // No extra token bridging needed here: cards-android's composables already read + // ThreadwireTheme.colors/.typography straight from :design-tokens-android (the + // same module this file's own tokens come from), and CompositionLocal + // propagation crosses Gradle module boundaries fine within one composition tree + // - ChatScreen's outer ThreadwireTheme { } already set the ambient values CardView + // will see, same as any other bubble composable nested inside it. + val root = remember(body) { CardParser.parse(body) } + val engine = remember(part.id, root) { store.engineFor(part.id, root) } + CardView(engine = engine, modifier = modifier) + } + } + is MessagePart.Custom -> InertPlaceholderChip(part.type, modifier) is MessagePart.Unknown -> InertPlaceholderChip("Unsupported", modifier) } diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/PhaseBanner.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/PhaseBanner.kt index 6a36e6e..ce20ccc 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/PhaseBanner.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/PhaseBanner.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.fsk.threadwire.session.SessionPhase -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * A thin banner reflecting [SessionPhase] directly (not per-message) - design doc diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/StreamingIndicator.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/StreamingIndicator.kt index 2fb7211..a3e5ab7 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/StreamingIndicator.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/StreamingIndicator.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.fsk.threadwire.session.ChatState import com.fsk.threadwire.session.MessageAuthor -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** Design doc §14.1: distinguishes "thinking" (no visible output yet) from "generating" (streaming in). */ internal enum class StreamingPhase { THINKING, GENERATING } diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/WelcomeView.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/WelcomeView.kt index 657bd3f..f3dbd6e 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/WelcomeView.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/WelcomeView.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * The design's `welcomeWrap` empty-conversation state: a title and optional diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AssistantBubble.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AssistantBubble.kt index 2d451d9..dfd0f28 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AssistantBubble.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AssistantBubble.kt @@ -25,8 +25,8 @@ import androidx.compose.ui.unit.dp import com.fsk.threadwire.session.ChatMessage import com.fsk.threadwire.ui.MessagePartRenderer import com.fsk.threadwire.ui.icons.ThreadwireIcons -import com.fsk.threadwire.ui.theme.ThreadwireAssistantBubbleShape -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireAssistantBubbleShape +import com.fsk.threadwire.designtokens.ThreadwireTheme /** Left-aligned, neutral surface color, minimal author label - see AuthorLabel's KDoc (§14.2). */ @Composable diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AuthorLabel.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AuthorLabel.kt index 3052fa7..1ed0495 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AuthorLabel.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/AuthorLabel.kt @@ -5,7 +5,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * Design doc §14.2 hard rule: AI and human must always be unambiguously distinguishable diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/HumanAgentBubble.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/HumanAgentBubble.kt index 71e5236..0086763 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/HumanAgentBubble.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/HumanAgentBubble.kt @@ -17,8 +17,8 @@ import androidx.compose.ui.unit.dp import com.fsk.threadwire.session.ChatMessage import com.fsk.threadwire.session.SessionPhase import com.fsk.threadwire.ui.MessagePartRenderer -import com.fsk.threadwire.ui.theme.ThreadwireAssistantBubbleShape -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireAssistantBubbleShape +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * Left-aligned, like [AssistantBubble] but on `surfaceAlt` (a subtly distinct neutral diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/MessageTimestamp.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/MessageTimestamp.kt index f25bf14..8526203 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/MessageTimestamp.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/MessageTimestamp.kt @@ -4,7 +4,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme import java.text.DateFormat import java.util.Date diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/SystemBanner.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/SystemBanner.kt index 8e6022e..3a5d4cf 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/SystemBanner.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/SystemBanner.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.fsk.threadwire.session.ChatMessage -import com.fsk.threadwire.ui.theme.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireTheme /** * Renders a [com.fsk.threadwire.session.MessageAuthor.SYSTEM] message - not produced by diff --git a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/UserBubble.kt b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/UserBubble.kt index 2b87af4..2a6ac1d 100644 --- a/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/UserBubble.kt +++ b/ui-android/src/main/kotlin/com/fsk/threadwire/ui/bubbles/UserBubble.kt @@ -24,8 +24,8 @@ import com.fsk.threadwire.session.ChatMessage import com.fsk.threadwire.session.MessagePart import com.fsk.threadwire.ui.MessagePartRenderer import com.fsk.threadwire.ui.icons.ThreadwireIcons -import com.fsk.threadwire.ui.theme.ThreadwireTheme -import com.fsk.threadwire.ui.theme.ThreadwireUserBubbleShape +import com.fsk.threadwire.designtokens.ThreadwireTheme +import com.fsk.threadwire.designtokens.ThreadwireUserBubbleShape /** * Right-aligned, accent-filled, no author label (matches the design - see `AuthorLabel`'s diff --git a/ui-ios/Sources/ThreadwireUI/Bubbles/MessagePartView.swift b/ui-ios/Sources/ThreadwireUI/Bubbles/MessagePartView.swift index 8220c8c..bece7fd 100644 --- a/ui-ios/Sources/ThreadwireUI/Bubbles/MessagePartView.swift +++ b/ui-ios/Sources/ThreadwireUI/Bubbles/MessagePartView.swift @@ -2,9 +2,10 @@ import SwiftUI import ThreadwireCore import SwiftStreamingMarkdown -/// Renders a single `MessagePart`. Non-text parts get a minimal, visually inert -/// placeholder - real card rendering is the M-Cards milestone's job (a separate -/// library), not this one. +/// Renders a single `MessagePart`. Tool-call/custom/unknown parts get a minimal, visually inert +/// placeholder chip. Card parts render for real when a host has wired M-Cards in (see +/// `CardContentHost` below) - `ui-ios` itself has no dependency on that library (a separate +/// package, `cards-ios`), so this falls back to the same inert placeholder chip otherwise. /// /// [textColor] defaults to the design tokens' body text color, but the user bubble /// (accent-filled, per M2.6) passes white explicitly. Applied via `.foregroundColor` @@ -20,8 +21,8 @@ struct MessagePartView: View { TextPartView(part: text, textColor: textColor) } else if part is MessagePartToolCall { InertPlaceholderChip(label: "Tool call") - } else if part is MessagePartCard { - InertPlaceholderChip(label: "Card") + } else if let card = part as? MessagePartCard { + CardContentHost(card: card) } else if let custom = part as? MessagePartCustom { InertPlaceholderChip(label: custom.type) } else { @@ -30,6 +31,23 @@ struct MessagePartView: View { } } +/// Reads the host-injected `\.cardContentBuilder` (see `CardContentBuilder.swift`) - falls back +/// to the same inert placeholder chip every other unhandled part uses when no host has wired +/// M-Cards in (e.g. a host that only imports `ThreadwireUI`, not `cards-ios`). +private struct CardContentHost: View { + let card: MessagePartCard + + @Environment(\.cardContentBuilder) private var buildCardContent + + var body: some View { + if let buildCardContent { + buildCardContent(card) + } else { + InertPlaceholderChip(label: "Card") + } + } +} + private struct TextPartView: View { let part: MessagePartText let textColor: Color diff --git a/ui-ios/Sources/ThreadwireUI/CardContentBuilder.swift b/ui-ios/Sources/ThreadwireUI/CardContentBuilder.swift new file mode 100644 index 0000000..4fee40a --- /dev/null +++ b/ui-ios/Sources/ThreadwireUI/CardContentBuilder.swift @@ -0,0 +1,22 @@ +import SwiftUI +import ThreadwireCore + +/// Extension point letting a host inject real card rendering (the M-Cards library, `cards-ios`'s +/// `ThreadwireCardsUI` target) without this package depending on it - `cards-ios` depends on +/// `ThreadwireUI` for shared design tokens (see that package's own `Package.swift` comment), so +/// the reverse dependency would be circular. This generic, cards-agnostic closure lets +/// `cards-ios` (or any host) supply the real implementation from *outside* this package instead. +/// Nil (the default) falls back to the pre-M-Cards inert placeholder chip - see +/// `MessagePartView.swift`'s `CardContentHost`. +public typealias CardContentBuilder = (MessagePartCard) -> AnyView + +private struct CardContentBuilderKey: EnvironmentKey { + static let defaultValue: CardContentBuilder? = nil +} + +extension EnvironmentValues { + public var cardContentBuilder: CardContentBuilder? { + get { self[CardContentBuilderKey.self] } + set { self[CardContentBuilderKey.self] = newValue } + } +} diff --git a/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTheme.swift b/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTheme.swift index a744370..2100c1c 100644 --- a/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTheme.swift +++ b/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTheme.swift @@ -12,11 +12,15 @@ private struct ThreadwireTypographyKey: EnvironmentKey { } extension EnvironmentValues { - var threadwireColors: ThreadwireColors { + // public: cards-ios (a separate SPM package/module depending on this one) reads these too, + // so card chrome matches bubble chrome via the same ambient environment values - see the + // M-Cards plan's "Token sharing" section. Was internal until M-Cards needed cross-module + // access; nothing about the values themselves changed. + public var threadwireColors: ThreadwireColors { get { self[ThreadwireColorsKey.self] } set { self[ThreadwireColorsKey.self] = newValue } } - var threadwireTypography: ThreadwireTypography { + public var threadwireTypography: ThreadwireTypography { get { self[ThreadwireTypographyKey.self] } set { self[ThreadwireTypographyKey.self] = newValue } } diff --git a/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTypography.swift b/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTypography.swift index 5dfac82..e8cc234 100644 --- a/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTypography.swift +++ b/ui-ios/Sources/ThreadwireUI/Theme/ThreadwireTypography.swift @@ -14,7 +14,11 @@ public enum ThreadwireFontScale { /// versus Android. This overrides the paragraph/list body to the design's size + the /// caller's color (white in the user bubble, `colors.text` in the assistant bubble). /// Headings/tables/code keep the library defaults on purpose (larger / monospaced). -func threadwireMarkdownConfig(textColor: Color, bodySize: CGFloat = 15.5) -> MarkdownRenderConfig { +/// +/// public: cards-ios's `TextBlockView` renders card text through this same config, so a card's +/// prose matches a bubble's exactly - see the M-Cards plan's finding that every title/subtitle/ +/// fact value in the source design renders inline markdown, same as message text. +public func threadwireMarkdownConfig(textColor: Color, bodySize: CGFloat = 15.5) -> MarkdownRenderConfig { let fonts = TextFonts( normal: .systemFont(ofSize: bodySize, weight: .regular), italic: .italicSystemFont(ofSize: bodySize), @@ -33,7 +37,10 @@ public struct ThreadwireTypography { /// Sizes (pt) transcribed verbatim from the design's `FS` table - converted 1:1 from the /// design's px values, the standard iOS convention. -func threadwireTypography(_ scale: ThreadwireFontScale) -> ThreadwireTypography { +/// +/// public: cards-ios also needs to build a `ThreadwireTypography` directly (not just read the +/// ambient environment value) - see the M-Cards plan's "Token sharing" section. +public func threadwireTypography(_ scale: ThreadwireFontScale) -> ThreadwireTypography { switch scale { case .small: return ThreadwireTypography( @@ -62,8 +69,9 @@ func threadwireTypography(_ scale: ThreadwireFontScale) -> ThreadwireTypography } } -/// Card / generic container corner radius (design: card wrappers, chips use ~14-16). -let threadwireCardCornerRadius: CGFloat = 16 +/// Card / generic container corner radius (design: card wrappers, chips use ~14-16). public: +/// cards-ios reuses this too, for the same "card chrome matches bubble chrome" reason as above. +public let threadwireCardCornerRadius: CGFloat = 16 /// Message-bubble shapes, transcribed from the design's `bubbleUser` (`18 18 4 18`) and /// `bubbleAssistant` (`18 18 18 4`) — asymmetric "tail" corner. CSS order is