From d3e23988a4f25fddcba697786ed16ce0e3494239 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:07:46 -0400 Subject: [PATCH 1/2] fix(chat): space the jump-to-bottom button twice the composer gap above the $ button The button's bottom offset is now derived from the composer's gap between the field and the $ button (ChatInputDefaults.OutsideSpacing), so its edge sits twice that gap above the $ circle instead of a hard-coded 20dp above the bar. --- .../internal/screens/components/ChatBottomBar.kt | 8 ++++++-- .../internal/screens/components/JumpToBottom.kt | 9 +++++++++ .../internal/screens/components/MessageList.kt | 8 +++++++- .../app/messenger/internal/JumpToBottomTest.kt | 13 +++++++++++++ .../com/getcode/ui/components/chat/ChatInput.kt | 9 ++++++++- 5 files changed, 43 insertions(+), 4 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 069f7ee9c..cb3dd2e2e 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 @@ -293,7 +293,7 @@ internal fun UserControlBottomBar( // Toasts rest above the composer and as wide as its row, rather than the tab bar's. The // bar's measured height includes the padding above the composer, which the host's own // gap replaces. - ToastBottomClearance((buttonHeight - CodeTheme.dimens.staticGrid.x3).coerceAtLeast(0.dp)) + ToastBottomClearance((buttonHeight - composerBarVerticalPadding).coerceAtLeast(0.dp)) ToastHorizontalInset(sideInset) ChatUpdateToast(enabled = hasComposer) Box { @@ -327,7 +327,7 @@ internal fun UserControlBottomBar( placeable.place(0, 0) } } - .padding(vertical = CodeTheme.dimens.staticGrid.x3) + .padding(vertical = composerBarVerticalPadding) .navigationBarsPadding() // typingConstraints.enabled starts false and only resolves a frame or two after // open, once Room confirms whether the chat has a cash message. Rendering the @@ -652,6 +652,10 @@ private fun DeactivatedChatBottomBar() { } } +/** The bar's padding above and below its controls; the `$` circle's top sits this far under the bar's top. */ +internal val composerBarVerticalPadding: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x3 + /** * The margin either side of the bar. Compact at rest once there is a composer, opening out to the * normal inset as the keyboard ([keyboardOpen], 0 to 1) comes up. The `$` button's trailing edge sits diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index becc2843c..aa461201d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -49,6 +50,14 @@ internal const val JumpToBottomAnimateMaxRows = 100 internal val JumpToBottomButtonSize = 48.dp private val BadgeOutline = 2.dp +/** + * How far above the screen's bottom the jump button sits so its bottom edge is twice [composerGap] + * above the `$` circle's top edge. [barHeight] is the bar's height (the list's bottom padding); the + * circle starts [barVerticalPadding] under the bar's top, so that padding is taken back off. + */ +internal fun jumpToBottomBottomOffset(barHeight: Dp, barVerticalPadding: Dp, composerGap: Dp): Dp = + barHeight - barVerticalPadding + composerGap * 2 + /** Whether a tap at [firstVisibleIndex] rows from the newest jumps instead of animating. */ internal fun jumpsToBottom(firstVisibleIndex: Int): Boolean = firstVisibleIndex > JumpToBottomAnimateMaxRows diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index c4680007a..a229b1d58 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -718,7 +718,13 @@ internal fun MessageList( modifier = Modifier .align(Alignment.BottomEnd) // The scaffold already rides the keyboard, so the bar-height padding does too. - .padding(bottom = contentPadding.calculateBottomPadding() + 20.dp) + .padding( + bottom = jumpToBottomBottomOffset( + barHeight = contentPadding.calculateBottomPadding(), + barVerticalPadding = composerBarVerticalPadding, + composerGap = ChatInputDefaults.OutsideSpacing, + ), + ) .alignedToDollarColumn( insets = composerInsets, dollarColumn = dollarColumn, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt index 587621cd5..f10a90bde 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt @@ -1,5 +1,7 @@ package com.flipcash.app.messenger.internal +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.components.jumpToBottomBottomOffset import com.flipcash.app.messenger.internal.screens.components.jumpsToBottom import com.getcode.ui.components.unreadCountLabel import org.junit.Test @@ -24,4 +26,15 @@ class JumpToBottomTest { assertFalse(jumpsToBottom(100)) assertTrue(jumpsToBottom(101)) } + + @Test + fun `button's bottom edge is twice the composer gap above the dollar circle`() { + val barHeight = 80.dp + val barPad = 12.dp + val gap = 8.dp + val offset = jumpToBottomBottomOffset(barHeight, barPad, gap) + // The button's bottom edge rises (offset - barHeight) above the bar's top; the circle's top is + // barPad below it. The gap between the two is the sum. + assertEquals(gap * 2, (offset - barHeight) + barPad) + } } 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 8837bff52..b83a9b5d3 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 @@ -153,6 +153,13 @@ object ChatInputDefaults { val OutsideSize: Dp @Composable get() = oneLineFieldHeight(FieldPadding, AccessorySize) + /** + * The gap between the `$` / cancel slot and the field. The jump-to-bottom button rides twice this + * above the `$` button, so it is named here rather than repeated. + */ + val OutsideSpacing: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + /** * 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. @@ -231,7 +238,7 @@ internal fun composerMetrics(): ComposerMetrics { stackedTextTopInset = 16.dp, stackedTextGap = 8.dp, controlSpacing = grid.x2, - outsideSpacing = grid.x2, + outsideSpacing = ChatInputDefaults.OutsideSpacing, ) } From 7157dcfab634ee6b902cda82203b8b199060d131 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:36:31 -0400 Subject: [PATCH 2/2] fix(chat): spring the jump button in and seat its badge on the circle The jump button now springs in and eases out on tokens in ChatAnimations, and its unread badge sits on the circle's edge at 45 degrees instead of floating at the bounding-box corner. The badge scales in after the button, and a rising count slides and bumps while a falling one crossfades. --- .../screens/components/JumpToBottom.kt | 120 +++++++++++++++--- .../messenger/internal/JumpToBottomTest.kt | 7 + .../flipcash/shared/chat/ui/ChatAnimations.kt | 18 +++ 3 files changed, 126 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index aa461201d..7ca00647b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -1,7 +1,12 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -10,6 +15,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -22,13 +28,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +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.graphicsLayer +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -38,11 +48,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme -import com.getcode.ui.components.Badge +import com.flipcash.shared.chat.ui.ChatAnimations +import com.getcode.ui.components.unreadCountLabel import com.getcode.ui.components.chat.ChatInputDefaults import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur +import kotlinx.coroutines.delay +import androidx.compose.material3.Text +import kotlin.math.sqrt /** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */ internal const val JumpToBottomAnimateMaxRows = 100 @@ -50,6 +64,17 @@ internal const val JumpToBottomAnimateMaxRows = 100 internal val JumpToBottomButtonSize = 48.dp private val BadgeOutline = 2.dp +/** Past this the label reads "99+", so counts at or above it are the same state to animate. */ +private const val BadgeCountCap = 100 +private val BadgeMinSize = 20.dp + +/** + * How far in from the button's bounding-box corner, on both axes, the badge's centre sits so it is on + * the circle's edge at 45 degrees: the edge point is r/sqrt(2) from the circle's centre, and the + * corner is r from it on each axis. + */ +internal fun jumpToBottomBadgeInset(buttonSize: Dp): Dp = buttonSize / 2 * (1f - (1f / sqrt(2f))) + /** * How far above the screen's bottom the jump button sits so its bottom edge is twice [composerGap] * above the `$` circle's top edge. [barHeight] is the bar's height (the list's bottom padding); the @@ -91,8 +116,10 @@ internal fun JumpToBottomButton( AnimatedVisibility( modifier = modifier, visible = visible, - enter = fadeIn(tween(200)) + scaleIn(tween(200), initialScale = 0.8f), - exit = fadeOut(tween(150)) + scaleOut(tween(150), targetScale = 0.8f), + enter = fadeIn(ChatAnimations.jumpEnter) + + scaleIn(ChatAnimations.jumpEnter, initialScale = ChatAnimations.jumpScale), + exit = fadeOut(ChatAnimations.jumpExit) + + scaleOut(ChatAnimations.jumpExit, targetScale = ChatAnimations.jumpScale), ) { Box { Box( @@ -123,34 +150,89 @@ internal fun JumpToBottomButton( modifier = Modifier.size(28.dp), ) } + // While the button is up the badge follows the count; once the button starts leaving it + // keeps what it showed, so a count cleared by the jump doesn't shrink it on the way out. + var heldBadge by remember { mutableStateOf(unreadCount > 0) } + LaunchedEffect(visible, unreadCount) { if (visible) heldBadge = unreadCount > 0 } + val badgeWanted = if (visible) unreadCount > 0 else heldBadge + // Springs carry no delay, so the badge is held back after the button instead. + var badgeShown by remember { mutableStateOf(false) } + LaunchedEffect(badgeWanted) { + if (badgeWanted) delay(ChatAnimations.badgeEnterDelayMs) + badgeShown = badgeWanted + } + val capped = unreadCount.coerceAtMost(BadgeCountCap) + var previousCapped by remember { mutableIntStateOf(capped) } + val bump = remember { Animatable(1f) } + LaunchedEffect(capped) { + val rose = previousCapped > 0 && capped > previousCapped + previousCapped = capped + if (rose) { + bump.animateTo(ChatAnimations.badgeBumpScale, ChatAnimations.badgeBump) + bump.animateTo(1f, ChatAnimations.badgeBump) + } + } + val inset = with(LocalDensity.current) { jumpToBottomBadgeInset(JumpToBottomButtonSize).roundToPx() } AnimatedVisibility( modifier = Modifier .align(Alignment.TopEnd) - // The pill, outline included, is centred on the top-trailing corner of the button's - // bounds: placed half its own size out, without changing the size it reports. + // The pill, outline included, is centred on the circle's edge at 45 degrees: its + // centre starts on the bounding box's corner, then moves [inset] in on both axes. + // Placed without changing the size it reports. .layout { measurable, constraints -> val placeable = measurable.measure(constraints) - val dx = if (layoutDirection == LayoutDirection.Rtl) -placeable.width / 2 else placeable.width / 2 - layout(placeable.width, placeable.height) { placeable.place(dx, -placeable.height / 2) } + val rtl = layoutDirection == LayoutDirection.Rtl + val dx = (placeable.width / 2 - inset) * if (rtl) -1 else 1 + layout(placeable.width, placeable.height) { + placeable.place(IntOffset(dx, -placeable.height / 2 + inset)) + } }, - visible = unreadCount > 0, - enter = fadeIn(tween(200)) + scaleIn(tween(200)), - exit = fadeOut(tween(150)) + scaleOut(tween(150)), + visible = badgeShown, + enter = scaleIn(ChatAnimations.badgeEnter, initialScale = 0f), + // It leaves with the button, which is already scaling and fading away. + exit = ExitTransition.None, ) { // The outline is the chat background, so the pill reads as cut out of the button. Box( Modifier + .graphicsLayer { + scaleX = bump.value + scaleY = bump.value + } .background(background, CircleShape) .padding(BadgeOutline), ) { - Badge( - count = shownCount, - color = CodeTheme.colors.badgeFocus, - textStyle = CodeTheme.typography.caption.copy( - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - ), - ) + Box( + modifier = Modifier + .clip(CircleShape) + .background(CodeTheme.colors.badgeFocus) + // Never narrower than tall, so a single digit is a circle. + .defaultMinSize(minWidth = BadgeMinSize, minHeight = BadgeMinSize) + .padding(horizontal = CodeTheme.dimens.grid.x1, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = shownCount.coerceAtMost(BadgeCountCap), + transitionSpec = { + if (targetState > initialState) { + (slideInVertically { it } + fadeIn()) togetherWith + (slideOutVertically { -it } + fadeOut()) + } else { + fadeIn() togetherWith fadeOut() + } + }, + label = "unreadCount", + ) { count -> + Text( + text = unreadCountLabel(count), + style = CodeTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ), + color = Color.White, + ) + } + } } } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt index f10a90bde..be8c35fd4 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.components.jumpToBottomBadgeInset import com.flipcash.app.messenger.internal.screens.components.jumpToBottomBottomOffset import com.flipcash.app.messenger.internal.screens.components.jumpsToBottom import com.getcode.ui.components.unreadCountLabel @@ -37,4 +38,10 @@ class JumpToBottomTest { // barPad below it. The gap between the two is the sum. assertEquals(gap * 2, (offset - barHeight) + barPad) } + + @Test + fun `badge centre sits on the circle's edge at 45 degrees`() { + // 48/2 * (1 - 1/sqrt(2)) = 7.03dp in from the bounding box's corner on both axes. + assertEquals(7.03f, jumpToBottomBadgeInset(48.dp).value, 0.01f) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 0168df261..7d0c5c43f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.SpringSpec @@ -110,6 +111,23 @@ object ChatAnimations { const val attentionHoldMs = 250L val attentionFade: TweenSpec = tween(durationMillis = 750, easing = LinearEasing) + // The jump-to-bottom button. Stiffness is (2pi / response)^2, damping ratio is 1 - bounce. + // Entering — iOS jumpEnter: .spring(response: 0.3, bounce: 0.25), from 80%. + val jumpEnter: SpringSpec = spring(dampingRatio = 0.75f, stiffness = 439f) + const val jumpScale = 0.8f + // Leaving — iOS jumpExit: .easeIn(duration: 0.15), to 80%. An accelerating tween with no bounce, + // so the button doesn't rebound as it goes. + val jumpExit: TweenSpec = tween(durationMillis = 150, easing = FastOutLinearInEasing) + // Its unread badge arriving, [badgeEnterDelayMs] after the button — iOS badgeEnter: + // .spring(response: 0.25, bounce: 0.4), from 0, delayed 0.06s. Springs carry no delay in Compose, + // so the caller holds the badge back that long. + val badgeEnter: SpringSpec = spring(dampingRatio = 0.6f, stiffness = 631f) + const val badgeEnterDelayMs = 60L + // The badge's 1 -> [badgeBumpScale] -> 1 bump when the count goes up. iOS badgeBump is the same + // spring as badgeEnter: .spring(response: 0.25, bounce: 0.4). + val badgeBump: SpringSpec = spring(dampingRatio = 0.6f, stiffness = 631f) + const val badgeBumpScale = 1.15f + // Receipt label exit when a new message is sent — the row closes the gap on [reflow] while the // line fades out fast enough to be gone before the bubble below reaches it. iOS // receiptExitFade: 0.08 s.