From 2c0132a30f3da6aea3713bf92ef3b1b095bf2bf8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 16:13:34 -0400 Subject: [PATCH] feat(chat): stack the composer's header, chips and field The composer layout photos need, without the photos. ChatInput becomes a custom Layout (ComposerMeasurePolicy) that stacks an optional header row (reply strip or photo chips) over the text field and lays out the accessory and send button around it, matching the iOS composer. - composerStacks() decides when the field goes multi-line; it is pure and covered by ComposerStackingTest. - Height changes, the single-line to multi-line switch and the header arriving or leaving all ease on one spring (dampingRatio 1, ~0.28s, the iOS replySurface curve), so the field and its text move together. - Corners stay concentric: the field's corner is the header chip's corner plus the inset, on one line or stacked. - The header strip fades only at an edge it has scrolled past. - Sizes come from the static grid; the one-line field is FieldPadding + AccessorySize + FieldPadding, the same 50dp as the $ button. --- .../getcode/ui/components/chat/ChatInput.kt | 614 ++++++++++++++---- .../components/chat/ComposerStackingTest.kt | 83 +++ 2 files changed, 583 insertions(+), 114 deletions(-) create mode 100644 ui/components/src/test/kotlin/com/getcode/ui/components/chat/ComposerStackingTest.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 748c563cfe..94591d2d34 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -1,7 +1,6 @@ package com.getcode.ui.components.chat import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn @@ -13,6 +12,7 @@ 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.fillMaxWidth @@ -27,6 +27,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,13 +42,58 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem import com.getcode.theme.extraSmall import com.getcode.theme.inputColors import com.getcode.ui.components.R -import com.getcode.ui.components.TextInput +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.animation.core.Animatable +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import com.getcode.ui.utils.rememberKeyboardController +import kotlin.math.roundToInt /** * What the composer will do with the text it holds, and the action that does it. @@ -79,10 +125,148 @@ sealed interface ChatInputSubmit { } object ChatInputDefaults { - /** The field's own fill. A caller that draws its own glass behind the field passes transparent. */ - val ContainerColor = Color(0xAD1E1E1E) + /** The field's own fill: a faint lift over the bar. A caller drawing its own glass passes transparent. */ + val ContainerColor = Color.White.copy(alpha = 0.03f) + + /** The field's padding around its controls; the attach menu's leading edge sits this far out from "+"'s. */ + val FieldPadding: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + + /** The leading and send controls' diameter, and the text row's minimum height. */ + val AccessorySize: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x6 + + /** + * The `$` / cancel slot's diameter, and the height of the one-line field beside it: the + * accessory plus the padding above and below it. The reply strip, the cancel button and the + * attach menu's anchor all read this one value. + */ + val OutsideSize: Dp + @Composable get() = oneLineFieldHeight(FieldPadding, AccessorySize) + + /** + * The corner of the thumbnails the composer's header holds. The field's own corner is this plus + * the header inset, and the photo chips draw with it, so the two curves share a centre. + */ + val HeaderChipCorner: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + + /** The 1dp rim on the field and the circle beside it: bright at the top edge, nearly gone at the bottom. */ + val RimBrush: Brush = Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.04f)) + ) +} + +/** The one-line field's height: its controls plus the padding above and below them. */ +internal fun oneLineFieldHeight(fieldPad: Dp, accessorySize: Dp): Dp = fieldPad * 2 + accessorySize + +/** + * Whether the composer stacks its text over a row of controls instead of keeping everything on one + * row. The text stacks once the [draft] wraps ([draftLineWidth], its width on one line, exceeds + * [inlineWidth], the room beside the controls) or holds a newline, stays stacked while anything is + * left ([wasStacked]), and unstacks only when the draft is cleared. [inlineWidth] of 0 means the + * field has not been measured, when only a newline stacks. + */ +internal fun composerStacks( + draft: CharSequence, + wasStacked: Boolean, + draftLineWidth: Int, + inlineWidth: Int, +): Boolean { + if (draft.isEmpty()) return false + if (wasStacked) return true + if (draft.contains('\n')) return true + return inlineWidth > 0 && draftLineWidth > inlineWidth +} + +/** + * The composer's measurements, all from the theme's grid so they follow the screen's size class. + * Resolved once by [composerMetrics] and handed to the shape, the layout and the stacking + * check, so none of them reads a constant of its own. + */ +@Immutable +internal data class ComposerMetrics( + /** The field's corner around stacked text, from iOS `BarMetrics.fieldCornerRadius`. */ + val fieldCorner: Dp, + /** Where the header's top edge sits below the field's: iOS `fieldPadding` + half `fieldVerticalPadding`. */ + val headerTopInset: Dp, + /** The corner of the thumbnails the header holds, so the field can be concentric with them. */ + val headerInnerCorner: Dp, + /** The field's padding around its controls. */ + val fieldPad: Dp, + /** The leading and send controls' diameter, and the text row's minimum height. */ + val accessorySize: Dp, + /** The text's inset from the field's sides while stacked or with no leading control. */ + val stackedTextInset: Dp, + /** The gap between the controls and the text. */ + val controlSpacing: Dp, + /** The gap between the outside control and the field. */ + val outsideSpacing: Dp, +) + +@Composable +internal fun composerMetrics(): ComposerMetrics { + val grid = CodeTheme.dimens.staticGrid + return ComposerMetrics( + fieldCorner = grid.x6, + headerTopInset = grid.x2, + headerInnerCorner = ChatInputDefaults.HeaderChipCorner, + fieldPad = ChatInputDefaults.FieldPadding, + accessorySize = ChatInputDefaults.AccessorySize, + stackedTextInset = grid.x2, + controlSpacing = grid.x2, + outsideSpacing = grid.x2, + ) } +/** + * The radius that keeps an outer corner concentric with an inner one [inset] away from it: the + * inner radius plus the gap, so both curves share a centre. + */ +internal fun concentricRadius(inner: Float, inset: Float): Float = inner + inset + +/** + * The field's corner radius for a field [height] tall, in the same unit as the other arguments. + * Around a header it is concentric with the header's rounded thumbnails; otherwise it is the stacked + * text's corner. Either is capped at half the height, so a one-line field is a capsule, and because + * [height] is the animated one the corner follows the layout between the arrangements. + */ +internal fun composerCornerRadius( + height: Float, + hasHeader: Boolean, + headerInnerCorner: Float, + headerInset: Float, + stackedCorner: Float, +): Float { + val base = if (hasHeader) concentricRadius(headerInnerCorner, headerInset) else stackedCorner + return base.coerceAtMost(height / 2f).coerceAtLeast(0f) +} + +/** A rounded rectangle whose radius is [composerCornerRadius] of the size it is drawn at. */ +private class ComposerFieldShape( + private val hasHeader: Boolean, + private val metrics: ComposerMetrics, +) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val r = composerCornerRadius( + height = size.height, + hasHeader = hasHeader, + headerInnerCorner = with(density) { metrics.headerInnerCorner.toPx() }, + headerInset = with(density) { metrics.headerTopInset.toPx() }, + stackedCorner = with(density) { metrics.fieldCorner.toPx() }, + ) + return Outline.Rounded(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) + } +} + +private const val MaxLines = 5 + +/** iOS `ChatMotion.replySurface`: duration 0.28, bounce 0, so damping ratio 1 and stiffness (2pi/0.28)^2. */ +private val StackSpring = spring(dampingRatio = 1f, stiffness = 503f) + +private enum class Slot { Header, Text, Leading, Send } + +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatInput( modifier: Modifier = Modifier, @@ -92,136 +276,340 @@ fun ChatInput( focusRequester: FocusRequester = remember { FocusRequester() }, containerColor: Color = ChatInputDefaults.ContainerColor, submit: ChatInputSubmit, + // Whether something other than text is waiting to be sent (staged photos), which shows the send + // control with an empty field. + hasAttachments: Boolean = false, + // Whether a tap on the send control does anything. Dimmed when not, so a chip that failed to + // upload reads as the reason nothing sends. + sendEnabled: Boolean = true, + // The control at the field's leading edge, inside it. Null for none. + leading: (@Composable () -> Unit)? = null, + // Content in the field above the text, such as staged photos. Null for none. + header: (@Composable () -> Unit)? = null, + // A control beside the field, outside it (send cash, cancel edit). Null for none. + outside: (@Composable () -> Unit)? = null, + // Whether [outside] makes way while the field holds text. Off for a control that must stay, + // like cancelling an edit. + outsideCollapses: Boolean = true, ) { - val shape = CodeTheme.shapes.medium - val sendVisible = state.text.isNotEmpty() || submit is ChatInputSubmit.Action + val textStyle = CodeTheme.typography.textMedium.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = CodeTheme.colors.textMain, + ) + val hasText = state.text.isNotEmpty() + val sendVisible = hasText || hasAttachments || submit is ChatInputSubmit.Action val sendSpec = spring(dampingRatio = 0.66f, stiffness = 4000f) - val sendAlpha by animateFloatAsState( - targetValue = if (sendVisible) 1f else 0f, - animationSpec = sendSpec, - label = "send button alpha", + val sendAlpha by animateFloatAsState(if (sendVisible) 1f else 0f, sendSpec, label = "send alpha") + val sendScale by animateFloatAsState(if (sendVisible) 1f else 0.6f, sendSpec, label = "send scale") + + val outsideShown = outside != null && !(outsideCollapses && hasText) + // spring(duration 0.4, bounce 0.3) + val outsideFraction by animateFloatAsState( + targetValue = if (outsideShown) 1f else 0f, + animationSpec = spring(dampingRatio = 0.7f, stiffness = 247f), + label = "outside fraction", ) - val sendScale by animateFloatAsState( - targetValue = if (sendVisible) 1f else 0.6f, - animationSpec = sendSpec, - label = "send button scale", + + var fieldWidth by remember { mutableIntStateOf(0) } + var leadingWidth by remember { mutableIntStateOf(0) } + var sendWidth by remember { mutableIntStateOf(0) } + var stacked by remember { mutableStateOf(false) } + val density = LocalDensity.current + val metrics = composerMetrics() + val outsideSize = ChatInputDefaults.OutsideSize + val textMeasurer = rememberTextMeasurer() + val scope = rememberCoroutineScope() + + LaunchedEffect(state, textMeasurer, textStyle, density, metrics) { + snapshotFlow { + val draft = state.text + val pad = with(density) { metrics.fieldPad.roundToPx() } + val gap = with(density) { metrics.controlSpacing.roundToPx() } + val inlineX = if (leadingWidth > 0) pad + leadingWidth + gap else with(density) { metrics.stackedTextInset.roundToPx() } + val inline = fieldWidth - inlineX - (pad + sendWidth + gap) + val lineWidth = if (draft.isEmpty()) 0 else textMeasurer.measure( + text = draft.toString(), + style = textStyle, + overflow = TextOverflow.Clip, + softWrap = false, + ).size.width + composerStacks(draft, stacked, lineWidth, if (fieldWidth == 0) 0 else inline) + }.collect { stacked = it } + } + val stackFraction by animateFloatAsState( + targetValue = if (stacked) 1f else 0f, + animationSpec = StackSpring, + label = "stack fraction", ) + + val keyboard = rememberKeyboardController() + val focusManager = LocalFocusManager.current + LaunchedEffect(keyboard.visible) { + if (!keyboard.visible) focusManager.clearFocus(true) + } + + val shape = remember(header != null, metrics) { ComposerFieldShape(hasHeader = header != null, metrics = metrics) } Row( - modifier = Modifier - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .clip(shape) - .then(modifier) - .fillMaxWidth() - .animateContentSize(alignment = Alignment.BottomEnd), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.Bottom + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, ) { - TextInput( + if (outside != null) { + Box( + modifier = Modifier + .layout { measurable, constraints -> + val size = outsideSize.roundToPx() + val f = outsideFraction.coerceAtLeast(0f) + val p = measurable.measure(Constraints.fixed(size, size)) + val gap = (metrics.outsideSpacing.roundToPx() * f).roundToInt() + layout(((size * f).roundToInt() + gap).coerceAtLeast(0), size) { + p.placeWithLayer(0, 0) { + val k = 0.4f + 0.6f * f.coerceAtMost(1f) + scaleX = k + scaleY = k + alpha = f.coerceIn(0f, 1f) + } + } + }, + ) { outside() } + } + Layout( modifier = Modifier .weight(1f) - .background(Color.Transparent, shape = CodeTheme.shapes.medium) - .focusRequester(focusRequester), - minHeight = 40.dp, - enabled = enabled, - state = state, - placeholder = hint, - shape = CodeTheme.shapes.medium, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - contentAlignment = Alignment.Bottom, - contentPadding = PaddingValues( - start = CodeTheme.dimens.staticGrid.x3, - top = CodeTheme.dimens.staticGrid.x2, - end = CodeTheme.dimens.staticGrid.x3, - bottom = CodeTheme.dimens.staticGrid.x2, - ), - colors = inputColors( - backgroundColor = containerColor, - borderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - ), - trailingIcon = { + .onSizeChanged { fieldWidth = it.width } + .clip(shape) + .then(modifier) + .background(containerColor, shape) + .border(CodeTheme.dimens.border, ChatInputDefaults.RimBrush, shape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = enabled, + ) { + focusRequester.requestFocus() + keyboard.show() + }, + content = { + if (header != null) Box(Modifier.layoutId(Slot.Header)) { header() } + Box(Modifier.layoutId(Slot.Text)) { + BasicTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + state = state, + enabled = enabled, + textStyle = textStyle, + cursorBrush = SolidColor(CodeTheme.colors.textMain), + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = MaxLines), + decorator = { inner -> + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = metrics.accessorySize), + contentAlignment = Alignment.CenterStart, + ) { + if (!hasText && hint.isNotEmpty()) { + Text( + text = hint, + style = textStyle, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + inner() + } + }, + ) + } + if (leading != null) Box( + Modifier + .layoutId(Slot.Leading) + .onSizeChanged { leadingWidth = it.width } + ) { leading() } Box( modifier = Modifier + .layoutId(Slot.Send) + .onSizeChanged { sendWidth = it.width } .graphicsLayer { - alpha = sendAlpha + alpha = if (sendEnabled) sendAlpha else sendAlpha * 0.4f scaleX = sendScale scaleY = sendScale } - .padding(vertical = CodeTheme.dimens.grid.x2) - .padding(end = CodeTheme.dimens.staticGrid.x2) - .background( - Color.White, - shape = CodeTheme.shapes.extraSmall - ) - .clip(CodeTheme.shapes.extraSmall) + .defaultMinSize(minWidth = metrics.accessorySize, minHeight = metrics.accessorySize) + .clip(CircleShape) + .background(Color.White, CircleShape) // Reads the current submit rather than the one the crossfade happens to // be showing, so a tap mid-transition does what the composer is now for. - .clickable(enabled = sendVisible && enabled) { submit.perform() } - .padding(CodeTheme.dimens.staticGrid.x1), + .clickable(enabled = sendVisible && enabled && sendEnabled) { submit.perform() }, + contentAlignment = Alignment.Center, ) { - // The button stays put and only its glyph changes, so entering and leaving edit - // mode reads as the same control changing meaning rather than two controls - // swapping places. - AnimatedContent( - targetState = submit, - // Keyed by kind, not by value: each submit carries a fresh lambda, so - // equality alone would restart the crossfade on every recomposition. - contentKey = { it::class }, - transitionSpec = { - (fadeIn(sendSpec) + scaleIn(sendSpec, initialScale = 0.6f)) togetherWith - (fadeOut(sendSpec) + scaleOut(sendSpec, targetScale = 0.6f)) - }, - label = "send glyph", - ) { target -> - if (target is ChatInputSubmit.Action) { - // Reads the live submit, not the crossfade's captured target: the busy - // flag changes without changing the kind, so no crossfade runs for it. - val busy = (submit as? ChatInputSubmit.Action)?.busy == true - Box(contentAlignment = Alignment.Center) { - Text( - modifier = Modifier - .padding(horizontal = CodeTheme.dimens.staticGrid.x2) - .graphicsLayer { alpha = if (busy) 0f else 1f }, - text = target.label, - style = CodeTheme.typography.textMedium, - color = Color.Black, - ) - if (busy) { - CircularProgressIndicator( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - color = Color.Black, - strokeWidth = 2.dp, - ) - } - } - } else if (target is ChatInputSubmit.ConfirmEdit) { - Icon( - modifier = Modifier - .testTag("chat_confirm_edit_icon") - .size(CodeTheme.dimens.staticGrid.x5), - imageVector = Icons.Rounded.Check, - tint = Color.Black, - contentDescription = "Confirm edit", - ) - } else { - Icon( - modifier = Modifier - .testTag("chat_send_icon") - .size(CodeTheme.dimens.staticGrid.x5), - painter = painterResource(R.drawable.ic_arrow_up), - tint = Color.Black, - contentDescription = "Send message", - ) - } - } + SendGlyph(submit) } - } + }, + measurePolicy = remember(density, scope, metrics) { + ComposerMeasurePolicy( + metrics = metrics, + stackFraction = { stackFraction }, + stacked = { stacked }, + scope = scope, + ) + }, ) } } +/** + * Lays the composer's four parts out in one of two arrangements without recomposing them, so the + * text field keeps its place in the tree (and its focus and IME session) when the layout changes. + * Inline puts `leading | text | send` on one row; stacked gives the text the full width above a row + * of the controls. [stackFraction] is the one animated progress (iOS `replySurface`, 0.28s, no + * bounce) that the field's height, the controls' and the text's positions all read, and the corner + * follows from the height, so nothing else animates the change; the text is measured for the target + * arrangement. + */ +private class ComposerMeasurePolicy( + private val metrics: ComposerMetrics, + private val stackFraction: () -> Float, + private val stacked: () -> Boolean, + private val scope: kotlinx.coroutines.CoroutineScope, +) : MeasurePolicy { + // A change in content height that is not a stack toggle (a chip arriving, another wrapped line) + // is taken as an offset equal to minus the change, so the field starts where it was, then eased + // to zero on the same spring as the stack progress. Everything is placed relative to the bottom + // edge plus this offset, so the content rides the field's height rather than jumping. + private var residual by mutableFloatStateOf(0f) + private var residualJob: Job? = null + private var lastTarget = -1 + private var lastStacked = false + + private fun easeResidual() { + residualJob?.cancel() + residualJob = scope.launch { + Animatable(residual).animateTo(0f, StackSpring) { residual = value } + } + } + + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints, + ): MeasureResult { + val width = constraints.maxWidth + val pad = metrics.fieldPad.roundToPx() + val gap = metrics.controlSpacing.roundToPx() + val stackedInset = metrics.stackedTextInset.roundToPx() + val loose = constraints.copy(minWidth = 0, minHeight = 0, maxHeight = Constraints.Infinity) + val header = measurables.firstOrNull { it.layoutId == Slot.Header } + ?.measure(loose.copy(maxWidth = width)) + val leading = measurables.firstOrNull { it.layoutId == Slot.Leading }?.measure(loose) + val send = measurables.first { it.layoutId == Slot.Send }.measure(loose) + val leadW = leading?.width ?: 0 + val minRow = metrics.accessorySize.roundToPx() + + val inlineX = if (leading != null) pad + leadW + gap else stackedInset + val inlineW = (width - inlineX - (pad + send.width + gap)).coerceAtLeast(0) + val stackedW = (width - 2 * stackedInset).coerceAtLeast(0) + val target = stacked() + val textW = if (target) stackedW else inlineW + val text = measurables.first { it.layoutId == Slot.Text } + .measure(Constraints(minWidth = textW, maxWidth = textW)) + + val headerTop = metrics.headerTopInset.roundToPx() + val headerBlock = if (header != null) headerTop + header.height + gap else pad + val controlsH = maxOf(minRow, leading?.height ?: 0, send.height) + val inlineRow = maxOf(minRow, text.height, controlsH) + val inlineH = headerBlock + inlineRow + pad + val stackedH = headerBlock + text.height + controlsH + pad + val t = stackFraction().coerceIn(0f, 1f) + val naturalH = lerp(inlineH, stackedH, t) + val heightTarget = if (stacked()) stackedH else inlineH + if (lastTarget >= 0 && heightTarget != lastTarget && stacked() == lastStacked) { + residual -= (heightTarget - lastTarget).toFloat() + easeResidual() + } + lastTarget = heightTarget + lastStacked = stacked() + val shift = residual.roundToInt() + val height = (naturalH + shift).coerceAtLeast(0) + + return layout(width, height) { + header?.place(0, headerTop + shift) + val rowTop = headerBlock + // Inline: controls bottom-aligned on the row; stacked: on the row under the text. + val inlineBottom = rowTop + inlineRow + val stackedBottom = naturalH - pad + val bottom = lerp(inlineBottom, stackedBottom, t) + leading?.place(pad, bottom - leading.height + shift) + send.place(width - pad - send.width, bottom - send.height + shift) + val textX = lerp(inlineX, stackedInset, t) + val textY = lerp(rowTop + (inlineRow - text.height) / 2, rowTop, t) + text.place(textX, textY + shift) + } + } +} + +@Composable +private fun SendGlyph(submit: ChatInputSubmit) { + // The button stays put and only its glyph changes, so entering and leaving edit + // mode reads as the same control changing meaning rather than two controls + // swapping places. + val sendSpec = spring(dampingRatio = 0.66f, stiffness = 4000f) + AnimatedContent( + targetState = submit, + // Keyed by kind, not by value: each submit carries a fresh lambda, so + // equality alone would restart the crossfade on every recomposition. + contentKey = { it::class }, + transitionSpec = { + (fadeIn(sendSpec) + scaleIn(sendSpec, initialScale = 0.6f)) togetherWith + (fadeOut(sendSpec) + scaleOut(sendSpec, targetScale = 0.6f)) + }, + label = "send glyph", + ) { target -> + if (target is ChatInputSubmit.Action) { + // Reads the live submit, not the crossfade's captured target: the busy + // flag changes without changing the kind, so no crossfade runs for it. + val busy = (submit as? ChatInputSubmit.Action)?.busy == true + Box(contentAlignment = Alignment.Center) { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.staticGrid.x2) + .graphicsLayer { alpha = if (busy) 0f else 1f }, + text = target.label, + style = CodeTheme.typography.textMedium, + color = Color.Black, + ) + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + color = Color.Black, + strokeWidth = CodeTheme.dimens.thickBorder, + ) + } + } + } else if (target is ChatInputSubmit.ConfirmEdit) { + Icon( + modifier = Modifier + .testTag("chat_confirm_edit_icon") + .size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.Rounded.Check, + tint = Color.Black, + contentDescription = "Confirm edit", + ) + } else { + Icon( + modifier = Modifier + .testTag("chat_send_icon") + .size(CodeTheme.dimens.staticGrid.x4), + painter = painterResource(R.drawable.ic_arrow_up), + tint = Color.Black, + contentDescription = "Send message", + ) + } + } +} + @Preview @Composable private fun Preview_ChatInput_Empty() { @@ -292,5 +680,3 @@ private fun Preview_ChatInput_Editing() { } } -private val ChatInputButtonSize - @Composable get() = CodeTheme.dimens.staticGrid.x7 \ No newline at end of file diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/chat/ComposerStackingTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/chat/ComposerStackingTest.kt new file mode 100644 index 0000000000..2310c0edf8 --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/chat/ComposerStackingTest.kt @@ -0,0 +1,83 @@ +package com.getcode.ui.components.chat + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ComposerStackingTest { + @Test + fun fitsInline() { + assertFalse(composerStacks("Hi", wasStacked = false, draftLineWidth = 40, inlineWidth = 200)) + } + + @Test + fun wrapStacks() { + assertTrue(composerStacks("a long draft", wasStacked = false, draftLineWidth = 260, inlineWidth = 200)) + } + + @Test + fun newlineStacks() { + assertTrue(composerStacks("Hi\n", wasStacked = false, draftLineWidth = 20, inlineWidth = 200)) + } + + @Test + fun staysStacked() { + // Deleting back under one line does not unstack. + assertTrue(composerStacks("Hi", wasStacked = true, draftLineWidth = 20, inlineWidth = 200)) + } + + @Test + fun clearUnstacks() { + assertFalse(composerStacks("", wasStacked = true, draftLineWidth = 0, inlineWidth = 200)) + } + + @Test + fun unmeasured() { + // Before the field has a width only a newline stacks. + assertFalse(composerStacks("a long draft", wasStacked = false, draftLineWidth = 260, inlineWidth = 0)) + assertTrue(composerStacks("a\nb", wasStacked = false, draftLineWidth = 20, inlineWidth = 0)) + } +} + +class ComposerCornerTest { + // The shipped geometry on the static grid: chip corner 10, header inset 10, field corner 30. + private fun radius(height: Float, hasHeader: Boolean) = composerCornerRadius( + height = height, + hasHeader = hasHeader, + headerInnerCorner = 10f, + headerInset = 10f, + stackedCorner = 30f, + ) + + @kotlin.test.Test + fun concentricIsInnerPlusInset() { + kotlin.test.assertEquals(20f, concentricRadius(10f, 10f)) + } + + @kotlin.test.Test + fun aroundChipsItIsChipPlusInset() { + kotlin.test.assertEquals(20f, radius(height = 120f, hasHeader = true)) + } + + @kotlin.test.Test + fun oneLineIsACapsule() { + kotlin.test.assertEquals(25f, radius(height = 50f, hasHeader = false)) + } + + @kotlin.test.Test + fun stackedTextUsesTheFieldCorner() { + kotlin.test.assertEquals(30f, radius(height = 90f, hasHeader = false)) + } + + @kotlin.test.Test + fun cornerGrowsWithHeightThroughTheTransition() { + kotlin.test.assertTrue(radius(60f, false) in 25f..30f) + } + + @kotlin.test.Test + fun oneLineFieldIsAsTallAsTheOutsideSlot() { + // Shipped: padding 10 + accessory 30 + padding 10 = the 50 the `$` button is. + kotlin.test.assertEquals(50.dp, oneLineFieldHeight(fieldPad = 10.dp, accessorySize = 30.dp)) + } +}