From 9da5c8bd9c8d9cb7d63d3a721198799f95306b94 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:46:20 -0400 Subject: [PATCH 1/3] feat(chat): move Send Cash beside the composer's trailing edge The round $ button now sits after the message field instead of before it. It still shows only when Send Cash is offered, no edit is open and the draft is empty, and animates in and out as the draft fills and empties. Cancel-edit keeps the leading slot. ChatInput gains a trailingOutside slot next to outside; outsideCollapses goes, since only the trailing control collapses. --- .../screens/components/ChatBottomBar.kt | 42 ++++++----- .../shared/chat/ui/media/AttachSurface.kt | 2 +- .../getcode/ui/components/chat/ChatInput.kt | 72 ++++++++++++------- 3 files changed, 69 insertions(+), 47 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 99d0b5b49..8a78189d4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -345,27 +345,25 @@ internal fun UserControlBottomBar( dispatch = dispatch, mentionListModifier = Modifier.measured { mentionListHeight = it.height }, ) - // Editing swaps the leading control rather than adding a banner above the bar: - // send-cash is not reachable mid-edit anyway, and cancel is what the slot is - // for while the edit is open. - val outsideControl: @Composable () -> Unit = { + // Editing swaps the send-cash button for a cancel control on the other side of + // the field rather than adding a banner above the bar: send-cash is not + // reachable mid-edit anyway, and cancel is what the leading slot is for while + // the edit is open. + val cancelEdit: @Composable () -> Unit = { + CancelEditButton(onClick = { dispatch(ChatViewModel.Event.CancelEdit) }) + } + val sendCash: @Composable () -> Unit = { Row { - if (state.editing != null) { - CancelEditButton( - onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, - ) - } else { - SendCashButton( - state = state, - hazeState = hazeState, - hazeMaterial = material, - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.OnSendCash) - } + SendCashButton( + state = state, + hazeState = hazeState, + hazeMaterial = material, + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.OnSendCash) } - ) - } + } + ) } } // Without a composer the send-cash button is the whole bar. @@ -374,7 +372,7 @@ internal fun UserControlBottomBar( modifier = Modifier .fillMaxWidth() .padding(horizontal = sideInset), - ) { outsideControl() } + ) { if (state.editing != null) cancelEdit() else sendCash() } } Row( modifier = Modifier @@ -401,8 +399,8 @@ internal fun UserControlBottomBar( modifier = Modifier .testTag("chat_message_input") .hazeBlur(HazeInput.Sources(hazeState), material), - outside = outsideControl, - outsideCollapses = state.editing == null, + outside = if (state.editing != null) cancelEdit else null, + trailingOutside = if (state.editing == null) sendCash else null, focusRequester = focusRequester, hint = stringResource( if (state.replyingTo != null) R.string.hint_chatReply diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt index 2975ffb92..39db92084 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt @@ -228,7 +228,7 @@ internal data class AttachMetrics( val menuCorner: Dp, /** The composer field's padding: the menu's leading edge sits this far out from "+"'s. */ val fieldPadding: Dp, - /** The menu reaches out past the field to this inset from the screen's edge, over the "$" button. */ + /** The menu reaches out past the field to this inset from the screen's edge. */ val menuLeadingInset: Dp, /** Height of a one-line composer field plus its padding: iOS `BarMetrics.contentHeight`. */ val barContentHeight: Dp, 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 94591d2d3..95e638082 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 @@ -286,11 +286,12 @@ fun ChatInput( 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. + // A control before the field, outside it (cancel edit). Stays while the field holds text. 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, + // A control after the field, outside it (send cash). Makes way while the field holds text. + // Null for none. + trailingOutside: (@Composable () -> Unit)? = null, ) { val textStyle = CodeTheme.typography.textMedium.copy( fontSize = 16.sp, @@ -303,13 +304,18 @@ fun ChatInput( 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 outsideSpec = spring(dampingRatio = 0.7f, stiffness = 247f) val outsideFraction by animateFloatAsState( - targetValue = if (outsideShown) 1f else 0f, - animationSpec = spring(dampingRatio = 0.7f, stiffness = 247f), + targetValue = if (outside != null) 1f else 0f, + animationSpec = outsideSpec, label = "outside fraction", ) + val trailingOutsideFraction by animateFloatAsState( + targetValue = if (trailingOutside != null && !hasText) 1f else 0f, + animationSpec = outsideSpec, + label = "trailing outside fraction", + ) var fieldWidth by remember { mutableIntStateOf(0) } var leadingWidth by remember { mutableIntStateOf(0) } @@ -355,23 +361,7 @@ fun ChatInput( verticalAlignment = Alignment.Bottom, ) { 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() } + OutsideSlot(outsideFraction, outsideSize, metrics.outsideSpacing, gapAtStart = false, outside) } Layout( modifier = Modifier @@ -458,9 +448,43 @@ fun ChatInput( ) }, ) + if (trailingOutside != null) { + OutsideSlot(trailingOutsideFraction, outsideSize, metrics.outsideSpacing, gapAtStart = true, trailingOutside) + } } } +/** + * A control beside the field that scales and fades in with [fraction] and takes its width, and the + * [spacing] between it and the field, from the row as it does. The gap sits on the field's side: + * after the control, or before it when [gapAtStart]. + */ +@Composable +private fun OutsideSlot( + fraction: Float, + size: Dp, + spacing: Dp, + gapAtStart: Boolean, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier.layout { measurable, _ -> + val sizePx = size.roundToPx() + val f = fraction.coerceAtLeast(0f) + val p = measurable.measure(Constraints.fixed(sizePx, sizePx)) + val gap = (spacing.roundToPx() * f).roundToInt() + layout(((sizePx * f).roundToInt() + gap).coerceAtLeast(0), sizePx) { + p.placeWithLayer(if (gapAtStart) gap else 0, 0) { + val k = 0.4f + 0.6f * f.coerceAtMost(1f) + scaleX = k + scaleY = k + alpha = f.coerceIn(0f, 1f) + } + } + }, + ) { content() } +} + /** * 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. From cbbf93d848197b1c93df13c684ebdeee7e90225d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:54:58 -0400 Subject: [PATCH 2/3] feat(chat): fade Send Cash out under the widening field The trailing $ now stays put, shrinks and fades while the field, drawn above it, widens across its slot, and plays the same in reverse when the draft empties. --- .../getcode/ui/components/chat/ChatInput.kt | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) 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 95e638082..7ad16d398 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 @@ -43,6 +43,7 @@ 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.zIndex import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem @@ -361,11 +362,13 @@ fun ChatInput( verticalAlignment = Alignment.Bottom, ) { if (outside != null) { - OutsideSlot(outsideFraction, outsideSize, metrics.outsideSpacing, gapAtStart = false, outside) + OutsideSlot(outsideFraction, outsideSize, metrics.outsideSpacing, outside) } Layout( modifier = Modifier .weight(1f) + // Above the trailing control, which the field slides over as it widens. + .zIndex(1f) .onSizeChanged { fieldWidth = it.width } .clip(shape) .then(modifier) @@ -449,22 +452,20 @@ fun ChatInput( }, ) if (trailingOutside != null) { - OutsideSlot(trailingOutsideFraction, outsideSize, metrics.outsideSpacing, gapAtStart = true, trailingOutside) + TrailingOutsideSlot(trailingOutsideFraction, outsideSize, metrics.outsideSpacing, trailingOutside) } } } /** - * A control beside the field that scales and fades in with [fraction] and takes its width, and the - * [spacing] between it and the field, from the row as it does. The gap sits on the field's side: - * after the control, or before it when [gapAtStart]. + * The control before the field. It scales and fades in with [fraction] and takes its width, and + * the [spacing] between it and the field, from the row as it does. */ @Composable private fun OutsideSlot( fraction: Float, size: Dp, spacing: Dp, - gapAtStart: Boolean, content: @Composable () -> Unit, ) { Box( @@ -474,7 +475,7 @@ private fun OutsideSlot( val p = measurable.measure(Constraints.fixed(sizePx, sizePx)) val gap = (spacing.roundToPx() * f).roundToInt() layout(((sizePx * f).roundToInt() + gap).coerceAtLeast(0), sizePx) { - p.placeWithLayer(if (gapAtStart) gap else 0, 0) { + p.placeWithLayer(0, 0) { val k = 0.4f + 0.6f * f.coerceAtMost(1f) scaleX = k scaleY = k @@ -485,6 +486,37 @@ private fun OutsideSlot( ) { content() } } +/** + * The control after the field. The row gives its width up to the field as [fraction] falls, but the + * control stays where it is: it fades out and grows slightly while the field, drawn above it, + * widens across it, and plays the same in reverse as [fraction] rises. + */ +@Composable +private fun TrailingOutsideSlot( + fraction: Float, + size: Dp, + spacing: Dp, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier.layout { measurable, _ -> + val sizePx = size.roundToPx() + val f = fraction.coerceAtLeast(0f) + val p = measurable.measure(Constraints.fixed(sizePx, sizePx)) + val width = ((sizePx + spacing.roundToPx()) * f).roundToInt().coerceAtLeast(0) + layout(width, sizePx) { + // Pinned to the row's trailing edge however much width the slot reports. + p.placeWithLayer(width - sizePx, 0) { + val k = 1f + 0.15f * (1f - f.coerceIn(0f, 1f)) + scaleX = k + scaleY = k + alpha = f.coerceIn(0f, 1f) + } + } + }, + ) { content() } +} + /** * 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. From 7e735b0cb50c3514a1d8ef42af13b17a78610d69 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:56:12 -0400 Subject: [PATCH 3/3] fix(chat): shrink Send Cash as it fades under the field --- .../main/kotlin/com/getcode/ui/components/chat/ChatInput.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7ad16d398..9906901ca 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 @@ -488,7 +488,7 @@ private fun OutsideSlot( /** * The control after the field. The row gives its width up to the field as [fraction] falls, but the - * control stays where it is: it fades out and grows slightly while the field, drawn above it, + * control stays where it is: it fades out and shrinks in place while the field, drawn above it, * widens across it, and plays the same in reverse as [fraction] rises. */ @Composable @@ -507,7 +507,7 @@ private fun TrailingOutsideSlot( layout(width, sizePx) { // Pinned to the row's trailing edge however much width the slot reports. p.placeWithLayer(width - sizePx, 0) { - val k = 1f + 0.15f * (1f - f.coerceIn(0f, 1f)) + val k = 0.5f + 0.5f * f.coerceIn(0f, 1f) scaleX = k scaleY = k alpha = f.coerceIn(0f, 1f)