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)) + } +}