From 23079b0abdf51896bea961c32928be3263e5cc88 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 10:10:46 -0400 Subject: [PATCH 1/3] feat(cash): move Give to the left-aligned amount header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give still drew the centred v1 amount field with its currency flag while Buy and Convert had moved to the left-aligned display-extra-large header. Node 9641:16762 puts Give on the same header, so turn on AmountEntryScreen's largeHeader and state the ceiling as "$X available" the way Convert and the v2 Get do. The v2 header carries no flag, so tapping the amount no longer opens region selection — canChangeCurrency now says as much, and changing currency is still reachable from the wallet balance. The over-max line stays give-specific: the cap is the lower of the balance and the per-transaction send limit, and "available" alone wouldn't explain a limit-bound cap. --- apps/flipcash/core/src/main/res/values/strings.xml | 1 - .../com/flipcash/app/cash/internal/CashScreenContent.kt | 6 +----- .../com/flipcash/app/cash/internal/CashScreenViewModel.kt | 8 +++++++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 573d62f2a7..73ab052914 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -52,7 +52,6 @@ You do not have enough funds to complete this transaction. You can deposit more funds in Settings You do not have enough funds to complete this withdrawal. Please try a smaller amount - Enter up to %1$s You can only give up to %1$s Enter up to %1$s diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt index 00cbdcb854..ad77a7d9be 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt @@ -1,17 +1,13 @@ package com.flipcash.app.cash.internal import androidx.compose.runtime.Composable -import com.flipcash.app.core.AppRoute import com.flipcash.shared.amountentry.AmountEntryScreen -import com.getcode.navigation.core.LocalCodeNavigator @Composable internal fun GiveScreenContent(viewModel: CashScreenViewModel) { - val navigator = LocalCodeNavigator.current - AmountEntryScreen( controller = viewModel.amountDelegate, onConfirm = { viewModel.dispatchEvent(CashScreenViewModel.Event.OnGive) }, - onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) }, + largeHeader = true, ) } diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index 5f0cb92a1e..dda1777b4c 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -81,7 +81,13 @@ internal class CashScreenViewModel @Inject constructor( scope = viewModelScope, style = AmountEntryStyle( actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)), - infoHint = { resources.getString(R.string.subtitle_giveCashHint, it) }, + // The v2 header carries no currency flag, so there's nothing left to tap to reach + // region selection. Changing currency lives on the wallet balance instead. + canChangeCurrency = false, + // States the ceiling the way Convert and the v2 Get do. The over-max line stays + // give-specific: the cap is the lower of the balance and the per-transaction send + // limit, and when the limit is what binds, "available" alone wouldn't explain it. + infoHint = { resources.getString(R.string.subtitle_amountAvailable, it) }, overMaxHint = { resources.getString(R.string.subtitle_giveCashHintLimitExceeded, it) }, ), loadingState = stateFlow.map { it.generatingBill } From 05817f2df283e68d40290cba53a14b986644bedd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 10:10:48 -0400 Subject: [PATCH 2/3] fix(cash): hold Give's success checkmark before the bill appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirming an amount dispatched the success state and the bill on the same frame, so the button went from spinner straight to the bill overlay and the checkmark was never drawn. dispatchSuccessThen holds it for SuccessHoldDuration first — the same beat the username, name and photo screens already wait. --- .../flipcash/app/cash/internal/CashScreenViewModel.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index dda1777b4c..cffeaf377a 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -273,8 +273,13 @@ internal class CashScreenViewModel @Inject constructor( verifiedState = result.verifiedState, ) - dispatchEvent(Event.UpdateLoadingState(loading = false, success = true)) - dispatchEvent(Event.PresentBill(bill)) + // Hold the checkmark before the bill takes over. Dispatching success and the bill + // on the same frame drew the checkmark and replaced it in one pass, so the button + // went from spinner straight to the bill. Nothing resets the state afterwards: + // presenting the bill pops this screen, so the next Give builds a fresh view model. + dispatchSuccessThen(Event.UpdateLoadingState(loading = false, success = true)) { + dispatchEvent(Event.PresentBill(bill)) + } }.launchIn(viewModelScope) eventFlow From 08455f716236590567c8c22a8c98e0f565b2bcb1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 10:25:38 -0400 Subject: [PATCH 3/3] fix(bills): place the bill above the send/cancel row on the first frame The bill's bottom inset is the management row's measured height, which only arrives a frame after the bill composes. Animating that first 0 -> measured change put the card half its own inset too low, so it appeared with the "Send as a Link" and "Cancel" pills over its bottom edge, held there for the 450ms animation delay, then slid up over another 450ms. Only the tip card needs the inset animated: it moves in lockstep with the tip modal sliding up. Everywhere else the inset now snaps, which lands while the enter spring is still carrying the card up from off-screen. Recorded Give at 15fps on an emulator: before, the card moves between frames 39 and 45; after, it is at its final position from the first frame it is visible and stays there. The tip card's full-screen present is unchanged. --- .../com/flipcash/app/bills/BillOverlay.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt index ba81b38900..25d4ae3de9 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box @@ -146,12 +147,22 @@ fun BillOverlay(modifier: Modifier = Modifier) { // When the tip modal is up, pin the tip card just above it: reserve the modal's height as // bottom inset AND bottom-align the card (bias 0 = centered, 1 = bottom). Both animated so the // card slides from centered down to just above the modal, and back, in lockstep with the modal. - val tipModalUp = managementHeight > 0.dp && updatedBillState.bill is Scannable.TipCard + val isTipCard = updatedBillState.bill is Scannable.TipCard + val tipModalUp = managementHeight > 0.dp && isTipCard val modalSpeed = ModalAnimationSpeed.Normal(updatedBillState.confirmationDelayMillis) - val offset = if (updatedBillState.bill is Scannable.TipCard) CodeTheme.dimens.grid.x8 else CodeTheme.dimens.grid.x2 + val offset = if (isTipCard) CodeTheme.dimens.grid.x8 else CodeTheme.dimens.grid.x2 + // Only the tip card's inset is animated. Everywhere else the inset's one and only change is + // 0 -> the management row's measured height, which lands a frame after the bill composes; + // animating it slid the card up from behind the send/cancel pills half a second after it had + // already appeared over them. Snapping puts the card at its final position while the enter + // spring is still carrying it up from off-screen, so there is nothing to see. val billBottomInset by animateDpAsState( targetValue = managementHeight + offset, - animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay), + animationSpec = if (isTipCard) { + tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay) + } else { + snap() + }, label = "billBottomInset", ) val billVerticalBias by animateFloatAsState(