From 4cac264f92fc6d003159f9346bfb073806570f16 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 17:00:09 -0400 Subject: [PATCH 1/8] feat(bills): land a scanned tip card in the chat --- .../app/bills/decor/TipCardDecorator.kt | 96 +++++-------------- 1 file changed, 23 insertions(+), 73 deletions(-) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index 177c634733..06e2888697 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -1,83 +1,58 @@ package com.flipcash.app.bills.decor -import androidx.compose.animation.EnterExitState -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment.Companion.BottomCenter -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.tipping.TipEvent -import com.flipcash.app.bills.modals.TipUserModal -import com.flipcash.app.session.LocalSessionController import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.core.measured -import com.getcode.ui.utils.AnimationUtils +import com.getcode.ui.utils.ModalAnimationSpeed +import kotlinx.coroutines.delay /** - * Decorator owned by a [Scannable.TipCard]. Placeholder home for the tip card's bottom modal - * (analogous to the payable "received funds" confirmation). Wire it here, gating `visible` on - * `context.liveBill is Scannable.TipCard` so it animates out on dismiss, and use the retained - * [tipCard] for content so it persists through the exit: + * Decorator owned by a [Scannable.TipCard] (node 10074:18893). Scanning a card is a way of reaching + * a person, so it ends where reaching them by `@handle` ends: in their chat, started or not. What + * used to happen here — a modal over the card taking a tip amount, sending it, then handing off to + * the chat — is now the chat's own job, and the fee it charges is the same one either route pays. * - * ``` - * AnimatedVisibility( - * modifier = Modifier.align(BottomCenter), - * visible = context.liveBill is Scannable.TipCard, - * enter = AnimationUtils.modalEnter(0), - * exit = AnimationUtils.modalExit, - * ) { - * TipCardModal(tipCard = tipCard, onDone = { context.onDismiss() }) - * } - * ``` + * The card still gets a beat on screen before the chat takes over. It is the same 450ms the modal + * used to take sliding up ([ModalAnimationSpeed.Normal] at the tip card's zero confirmation delay), + * so the scan still reads as "I found this person" rather than as a screen that flashed past. */ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : ScannableDecorator { @Composable override fun BoxScope.Content(context: ScannableDecoratorContext) { val billState = context.billState - val session = LocalSessionController.current val navigator = LocalCodeNavigator.current val tipCoordinator = LocalTipCoordinator.current - val selection by tipCoordinator.selection.collectAsState() val tipPresented = context.liveBill is Scannable.TipCard - // Can't afford the minimum tip → the modal stays hidden (gated by - // showManagementOptions); prompt to add money instead. The navigator lives here in - // the UI, so we route directly rather than plumbing a callback through the session. - LaunchedEffect(tipPresented, selection.canTip) { - if (tipPresented && !selection.canTip) { - session?.presentDepositOptions( - // No giveable balance to tip with → steer to add-money / discover, and dismiss the - // tip card so we don't leave it stranded behind the prompt. - onDismiss = { context.onDismiss() } - ) { route -> - navigator.openAsSheet(route) - } - } + // The id is server-provided, so a card that resolved to a locally-constructed profile has + // no chat to open. Leave it up rather than dismissing into nothing. + val userId = tipCard.user.userId + + LaunchedEffect(tipPresented, userId) { + if (!tipPresented || userId == null) return@LaunchedEffect + delay(ModalAnimationSpeed.Normal(billState.confirmationDelayMillis).delay.toLong()) + navigator.push( + AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId, tipCard.user)), + ) + context.onDismiss() } - // Handle the coordinator's one-shot UI events (e.g. open the add money flow after the - // insufficient-balance prompt's "Add Money"). Collected here, not in the modal, so it - // fires whether the tip modal is on screen or not. + // The coordinator's one-shot UI events. Collected here, not in a modal, so it fires + // regardless of what is on screen. LaunchedEffect(tipCoordinator) { tipCoordinator.events.collect { event -> when (event) { is TipEvent.OpenRoute -> { navigator.openAsSheet(event.route) } - // Open the completed tip's chat with the tips list beneath it, so back returns - // to the list: navigateAll switches to the Chats tab and pushes the chat onto it. is TipEvent.LaunchChat -> { navigator.navigateAll( listOf( @@ -90,30 +65,5 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S } } } - - AnimatedScannableDecorator( - visible = tipPresented && context.showManagementOptions, - enter = AnimationUtils.modalEnter(billState.confirmationDelayMillis), - exit = AnimationUtils.modalExit, - modifier = Modifier.align(BottomCenter), - ) { - // Report the modal's height as soon as it starts entering (target Visible), not only - // once it settles — this lets the container move the tip card in sync with the modal's - // slide (both share the same enter timing). Reports 0 on exit so the card re-centers. - var modalHeight by remember { mutableStateOf(0.dp) } - val entering = transition.targetState == EnterExitState.Visible - LaunchedEffect(modalHeight, entering) { - context.onManagementHeightMeasured(if (entering) modalHeight else 0.dp) - } - - Box( - modifier = Modifier.measured { modalHeight = it.height }, - contentAlignment = BottomCenter, - ) { - TipUserModal( - card = tipCard, - ) - } - } } } From 334ec133803c20581d8e4c6c1c9853cd94ca9539 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 17:00:35 -0400 Subject: [PATCH 2/8] feat(bills): delete the tip modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing presents it: the scanned card now goes straight to the chat, and the amount it used to take is entered in the chat instead. action_swipeToTip stays for now — the chat's own swipe label still reads it, and that caller goes away with the "Start Chatting" CTA change. --- .../flipcash/app/bills/modals/TipUserModal.kt | 215 ------------------ 1 file changed, 215 deletions(-) delete mode 100644 apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt deleted file mode 100644 index cd81aa2eed..0000000000 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.flipcash.app.bills.modals - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.Image -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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.MoreHoriz -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEach -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.extensions.openAsSheet -import com.flipcash.app.core.tipping.LocalTipCoordinator -import com.flipcash.app.core.tipping.TipAmount -import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.core.ui.TokenSelectionPill -import com.flipcash.app.core.util.abbreviated -import com.flipcash.shared.bills.R -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.Token -import com.getcode.theme.CodeTheme -import com.getcode.theme.White10 -import com.getcode.ui.components.Modal -import com.getcode.ui.components.SlideToConfirm - -@Composable -internal fun TipUserModal( - card: Scannable.TipCard, -) { - val tip = LocalTipCoordinator.current - val navigator = LocalCodeNavigator.current - - val selection by tip.selection.collectAsState() - - // A newly presented tip card starts with a clean selection. - LaunchedEffect(card) { tip.selectAmount(null) } - - Modal( - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), - contentPadding = PaddingValues( - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, - top = CodeTheme.dimens.inset, - bottom = CodeTheme.dimens.grid.x2 - ), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(id = R.string.title_sendTip), - style = CodeTheme.typography.displaySmall, - color = CodeTheme.colors.textMain, - ) - - TipTokenRow( - token = selection.token, - onSelectToken = { - navigator.openAsSheet(AppRoute.Sheets.TokenSelection(TokenPurpose.Tip(selection.minimum))) - }, - ) - } - - PresetOptions( - // Presets follow the selected region reactively (see TippingCoordinator.selection). - presets = selection.presets, - selected = selection.amount?.value, - modifier = Modifier.fillMaxWidth(), - onPresetClicked = { tip.selectAmount(TipAmount.Preset(it)) }, - onCustomClicked = { navigator.openAsSheet(AppRoute.Sheets.TipAmountEntry) }, - ) - - SlideToConfirm( - onConfirm = { tip.confirmTip() }, - modifier = Modifier.fillMaxWidth(), - enabled = selection.canConfirm, - isLoading = selection.sendState.loading, - isSuccess = selection.sendState.success, - label = stringResource(R.string.action_swipeToTip), - ) - } -} - -@Composable -private fun TipTokenRow( - token: Token?, - onSelectToken: () -> Unit, - modifier: Modifier = Modifier, -) { - TokenSelectionPill( - modifier = modifier, - token = token, - background = White10, - textStyle = CodeTheme.typography.textMedium, - imageSize = CodeTheme.dimens.staticGrid.x3, - contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.grid.x1 + 1.dp, - vertical = CodeTheme.dimens.grid.x1 - 1.dp, - ), - onClick = onSelectToken, - ) -} - -@Composable -private fun PresetOptions( - presets: List, - selected: Fiat?, - modifier: Modifier = Modifier, - onPresetClicked: (Fiat) -> Unit, - onCustomClicked: () -> Unit, -) { - val isCustomSelected = selected != null && selected !in presets - - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.CenterVertically, - ) { - presets.fastForEach { preset -> - TipAmount( - fiat = preset, - selected = preset == selected, - onClick = { onPresetClicked(preset) }, - ) - } - - // Custom-amount slot: shows the entered value when a custom amount is set, otherwise a - // "more" affordance that opens the amount-entry sheet. - TipAmount( - fiat = selected.takeIf { isCustomSelected }, - selected = isCustomSelected, - onClick = onCustomClicked, - ) - } -} - -@Composable -private fun RowScope.TipAmount( - fiat: Fiat?, - selected: Boolean, - onClick: () -> Unit, -) { - val backgroundAlpha by animateFloatAsState( - if (selected) 1f else 0.10f - ) - - val foregroundColor by animateColorAsState( - if (selected) Color.Black else Color.White - ) - - val customAmountDescription = stringResource(R.string.content_description_customTipAmount) - - Box( - modifier = Modifier - .weight(1f) - .clip(CodeTheme.shapes.medium) - .background( - color = Color.White.copy(alpha = backgroundAlpha), - shape = CodeTheme.shapes.medium - ) - .clickable(onClick = onClick) - .padding(vertical = CodeTheme.dimens.grid.x4) - .semantics { - contentDescription = fiat?.formatted(rule = Fiat.FormattingRule.Truncated) - ?: customAmountDescription - }, - contentAlignment = Alignment.Center, - ) { - if (fiat != null) { - Text( - // Abbreviated so a high-denomination currency (Rp332K, ₫526K) still fits the - // button; the full amount stays in the content description. - text = fiat.abbreviated(), - style = CodeTheme.typography.textLarge, - color = foregroundColor, - textAlign = TextAlign.Center, - ) - } else { - Image( - modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x6), - imageVector = Icons.Rounded.MoreHoriz, - colorFilter = ColorFilter.tint(foregroundColor), - contentDescription = null, - ) - } - } -} From 1c305022ce2c320329913bfcb8e0da9fe4203f80 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 17:04:29 -0400 Subject: [PATCH 3/8] chore(bills): drop the tip modal's layout choreography The inset and vertical-bias animations existed to slide the tip card up out of the modal's way. Nothing reports a management height for a tip card now, so both animations ran to a target they already sat at. The min-tip affordability read went with them: the modal was its only consumer, and the chat does its own balance check. --- .../com/flipcash/app/bills/BillOverlay.kt | 48 +++++-------------- 1 file changed, 11 insertions(+), 37 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 25d4ae3de9..6c69340dfb 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 @@ -3,9 +3,7 @@ package com.flipcash.app.bills 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 import androidx.compose.foundation.layout.PaddingValues @@ -32,7 +30,6 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.navigation.asNavBarTab -import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.session.BillDeterminationResult import com.flipcash.app.session.Grabbed import com.flipcash.app.session.LocalSessionController @@ -41,7 +38,6 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.scrim.LocalScrimController import com.getcode.theme.CodeTheme import com.getcode.ui.utils.AnimationUtils -import com.getcode.ui.utils.ModalAnimationSpeed import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.milliseconds @@ -63,9 +59,6 @@ fun BillOverlay(modifier: Modifier = Modifier) { val state by session.state.collectAsStateWithLifecycle() val billState by session.billState.collectAsStateWithLifecycle() - // Tip affordability (min-tip balance check), surfaced through the shared selection state. - val tipSelection by LocalTipCoordinator.current.selection.collectAsStateWithLifecycle() - // A bill is a focused modal — it must never share the screen with a keyboard. What makes that // reachable is a tip-card deeplink handled while a chat input still holds focus: App takes the // keyboard down before routing, and this catches an IME the platform restores afterwards, on @@ -136,40 +129,21 @@ fun BillOverlay(modifier: Modifier = Modifier) { var managementHeight by remember { mutableStateOf(0.dp) } val showManagementOptions by remember(updatedBillState) { derivedStateOf { - // The tip card always shows, but its modal only slides up when the viewer can afford - // the minimum tip; otherwise the tip decorator prompts to add money. billDismissState.targetValue == DismissValue.Default && - (updatedBillState.valuation != null || - (updatedBillState.bill is Scannable.TipCard && tipSelection.canTip)) + updatedBillState.valuation != null } } - // 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 isTipCard = updatedBillState.bill is Scannable.TipCard - val tipModalUp = managementHeight > 0.dp && isTipCard - val modalSpeed = ModalAnimationSpeed.Normal(updatedBillState.confirmationDelayMillis) - 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. + // 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 = if (isTipCard) { - tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay) - } else { - snap() - }, + targetValue = managementHeight + CodeTheme.dimens.grid.x2, + animationSpec = snap(), label = "billBottomInset", ) - val billVerticalBias by animateFloatAsState( - targetValue = if (tipModalUp) 1f else 0f, - animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay), - label = "billVerticalBias", - ) AnimatedScannable( modifier = Modifier.fillMaxSize(), @@ -181,7 +155,7 @@ fun BillOverlay(modifier: Modifier = Modifier) { top = CodeTheme.dimens.grid.x2, bottom = billBottomInset, ), - scannableAlignment = BiasAlignment(horizontalBias = 0f, verticalBias = billVerticalBias), + scannableAlignment = BiasAlignment(horizontalBias = 0f, verticalBias = 0f), bill = updatedBillState.bill, transitionSpec = { when (updatedState.billResult) { @@ -222,8 +196,8 @@ fun BillOverlay(modifier: Modifier = Modifier) { * Deliberately not a plain `LaunchedEffect(presented)`. [BillOverlay] is hosted per navigation * entry, so a screen opened *while* a bill is up composes a fresh copy with the bill already * present, and an unguarded effect would fire on that first pass. For the keyboard that would stomp - * the post-tip hand-off, which opens the chat with the keyboard up on purpose (see - * TipCardDecorator's LaunchChat). Seeding `wasPresented` from the value at first composition makes + * a hand-off that opens the chat with the keyboard up on purpose. Seeding `wasPresented` from the + * value at first composition makes * an already-presented bill a no-op, so only a bill that appears *while this copy is watching* * counts as a presentation. */ From 6316b047fdab2118e21ecdfbf642240d5d2767fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 17:05:21 -0400 Subject: [PATCH 4/8] test(maestro): assert the scanned card lands in the chat The flow waited on "Swipe to Tip" and asserted "Tip Brandon McAnsh", both of which belonged to the deleted modal. It now waits on chat_screen, the id the route registration derives, so the assertion survives the chat's call-to-action copy changing. --- maestro/tip_deeplink.yaml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/maestro/tip_deeplink.yaml b/maestro/tip_deeplink.yaml index 0928b148fd..e18c260300 100644 --- a/maestro/tip_deeplink.yaml +++ b/maestro/tip_deeplink.yaml @@ -1,12 +1,10 @@ appId: com.flipcash.app.android -name: "Tip deeplink — opens the tip flow" +name: "Tip deeplink — lands in the recipient's chat" tags: - tipping --- # Seed-login. A fresh login clears local state, so give balances a moment to sync -# (opening the Wallet forces the token balances to load) before opening the deeplink — -# otherwise hasGiveableBalance is transiently false and the app shows the -# add-money/discover gate instead of the tip flow. +# (opening the Wallet forces the token balances to load) before opening the deeplink. - runFlow: file: subflows/login_with_flags.yaml env: @@ -14,10 +12,11 @@ tags: - runFlow: subflows/navigate_to_wallet.yaml - assertVisible: { id: wallet_screen } -# Open the tip card deeplink -> presents the tip flow for that recipient. +# Open the tip card deeplink -> the card holds for its reveal beat, then the chat takes over. +# Wait on the chat rather than on the card, and anchor on the route's own id rather than on the +# call-to-action copy, which the chat changes as the conversation progresses. - openLink: ${TIPCARD_DEEPLINK} - extendedWaitUntil: - visible: - text: "Swipe to Tip" + visible: { id: chat_screen } timeout: 12000 -- assertVisible: "Tip Brandon McAnsh" +- assertVisible: "Brandon McAnsh" From 2202c1910512e49a67f2dbe7b4dc61755e63ace8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 10:55:56 -0400 Subject: [PATCH 5/8] fix(bills): pop the tip card with the push into the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanned card's dismissal used PutInWallet, whose exit is a 600ms slide back down to the wallet. The bill overlay lives inside the navigation entry being pushed away from, so the entry starts sliding out roughly 50ms later and takes the card with it — a recording of the handoff shows the card leaving at full size and full opacity, never having animated at all. Dismiss with Grabbed instead: fadeOut(100ms) + scaleOut, the same pop the card already enters with, short enough to finish inside the handoff. Call it before the push so both start on the same frame. ScannableDecoratorContext.onDismiss now takes the BillDeterminationResult, since the result is what picks the exit; PayableDecorator passes the PutInWallet it had hardcoded. --- .../kotlin/com/flipcash/app/bills/BillOverlay.kt | 2 +- .../flipcash/app/bills/decor/PayableDecorator.kt | 5 +++-- .../flipcash/app/bills/decor/ScannableDecorator.kt | 10 ++++++++-- .../flipcash/app/bills/decor/TipCardDecorator.kt | 13 ++++++++++--- 4 files changed, 22 insertions(+), 8 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 6c69340dfb..08de61796a 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 @@ -183,7 +183,7 @@ fun BillOverlay(modifier: Modifier = Modifier) { isRemoteSendLoading = updatedState.isRemoteSendLoading, showManagementOptions = showManagementOptions, onManagementHeightMeasured = { managementHeight = it }, - onDismiss = { session.dismissBill(PutInWallet) }, + onDismiss = { result -> session.dismissBill(result) }, ) with(overlays) { Content(overlayContext) } } diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt index 61c7dbcef2..90a5f168b3 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt @@ -24,6 +24,7 @@ import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.bills.BillManagementOptions import com.flipcash.app.bills.modals.ReceivedFundsConfirmation import com.flipcash.app.session.LocalSessionController +import com.flipcash.app.session.PutInWallet import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.core.measured import com.getcode.ui.utils.AnimationUtils @@ -75,7 +76,7 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan } BackHandler(canCancel) { - context.onDismiss() + context.onDismiss(PutInWallet) } } @@ -97,7 +98,7 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan // grab time. A cash link is claimed from a link rather than from the scanner, // so it has no snapshot to reveal and stays where it is. onClaim = { - if (session == null) context.onDismiss() + if (session == null) context.onDismiss(PutInWallet) else if (session.claimReceivedFunds()) { navigator.navigateAll(listOf(AppRoute.Sheets.Wallet)) } diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt index fb4c468ec4..75d81cd05b 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt @@ -12,6 +12,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.session.BillDeterminationResult +import com.flipcash.app.session.Grabbed +import com.flipcash.app.session.PutInWallet /** * Below-bill decor content owned by a [Scannable] type rather than decided by @@ -82,7 +85,10 @@ internal data object NoOpScannableDecorator : ScannableDecorator { * @param showManagementOptions whether the management options row should be shown. * @param onManagementHeightMeasured reports the measured management-row height back to the * container so the card floats above it. - * @param onDismiss dismisses the current bill (equivalent to `session.dismissBill(PutInWallet)`). + * @param onDismiss dismisses the current bill. The [BillDeterminationResult] passed in picks the + * card's exit animation as well as recording the outcome — [PutInWallet] slides it back down to + * the wallet, [Grabbed] pops it (fade + scale). A decorator that hands off to another screen + * wants the pop, because the slide takes longer than the handoff leaves it on screen for. */ data class ScannableDecoratorContext( val liveBill: Scannable?, @@ -90,5 +96,5 @@ data class ScannableDecoratorContext( val isRemoteSendLoading: Boolean, val showManagementOptions: Boolean, val onManagementHeightMeasured: (Dp) -> Unit, - val onDismiss: () -> Unit, + val onDismiss: (BillDeterminationResult) -> Unit, ) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index 06e2888697..891812f2b9 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -10,6 +10,7 @@ import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.tipping.TipEvent +import com.flipcash.app.session.Grabbed import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.utils.ModalAnimationSpeed import kotlinx.coroutines.delay @@ -22,7 +23,8 @@ import kotlinx.coroutines.delay * * The card still gets a beat on screen before the chat takes over. It is the same 450ms the modal * used to take sliding up ([ModalAnimationSpeed.Normal] at the tip card's zero confirmation delay), - * so the scan still reads as "I found this person" rather than as a screen that flashed past. + * so the scan still reads as "I found this person" rather than as a screen that flashed past. Then + * the card pops and the push runs together, so the two are one movement rather than two. */ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : ScannableDecorator { @Composable @@ -39,10 +41,15 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S LaunchedEffect(tipPresented, userId) { if (!tipPresented || userId == null) return@LaunchedEffect delay(ModalAnimationSpeed.Normal(billState.confirmationDelayMillis).delay.toLong()) + // Pop and push in the same frame, and pop with [Grabbed] rather than [PutInWallet]: + // the card leaves as a 100ms fade-and-scale instead of a 600ms slide back to the + // wallet. The overlay lives inside this navigation entry, so the entry slides out from + // under it either way — the slide outlasts the handoff and is never seen, while the + // pop lands inside it and reads as the card handing you to the chat. + context.onDismiss(Grabbed) navigator.push( AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId, tipCard.user)), ) - context.onDismiss() } // The coordinator's one-shot UI events. Collected here, not in a modal, so it fires @@ -60,7 +67,7 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S AppRoute.Messaging.Chat(event.identifier, openKeyboard = true), ), ) - context.onDismiss() + context.onDismiss(Grabbed) } } } From 13dd81a06688f975683a394db89f656dfd7f31b4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 11:05:32 -0400 Subject: [PATCH 6/8] feat(bills): name the person on the tip card, not the action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card read "Tip Erik" over the handle. Scanning it no longer takes a tip — it opens the chat, where sending money is one of several things you can do — so the face now carries the display name alone and leaves the verb out of it. `label_tipUser` stays; the tip flow and menu still use it. --- .../com/flipcash/app/bills/components/cards/TipCard.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt index 8cdff7ee09..da96818782 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt @@ -23,14 +23,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.bills.components.ScannableCode import com.flipcash.services.models.UserProfile import com.flipcash.services.models.handle -import com.flipcash.shared.bills.R import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme @@ -150,7 +148,9 @@ internal fun TipCard( verticalArrangement = Arrangement.spacedBy(width * TipCardHandleGapFraction), ) { Text( - text = stringResource(R.string.label_tipUser, user.displayName), + // The name alone, not "Tip ": scanning a card reaches the person, + // and what you do once you are there is the chat's business. + text = user.displayName, style = CodeTheme.typography.textMedium.copy( fontSize = nameFontSize, lineHeight = nameFontSize * TipCardNameLineHeightRatio, From b4d210f8db8e38f27fb1046bf3b54b0cbe524569 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 11:14:48 -0400 Subject: [PATCH 7/8] fix(bills): let the tip card's pop start before the chat push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dismissing the card and pushing the chat in the same frame removed the card instead of animating it out. A 60fps capture shows it fully opaque in one frame and gone in the next, 34ms later, where the 100ms fade-and-scale should have spanned several. A 50ms head start puts that fade under way before the push takes the outgoing entry; the same capture now shows the card half-faded and scaled up while the chat is already sliding in. The delay also has to outlive the effect it runs in. Dismissing sets the bill to null, which flips `tipPresented` and restarts `LaunchedEffect`, and that was cancelling the pending push — the chat never opened at all. `NonCancellable` around the hand-off keeps it. --- .../app/bills/decor/TipCardDecorator.kt | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index 891812f2b9..1e2694b279 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -13,7 +13,9 @@ import com.flipcash.app.core.tipping.TipEvent import com.flipcash.app.session.Grabbed import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.utils.ModalAnimationSpeed +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext /** * Decorator owned by a [Scannable.TipCard] (node 10074:18893). Scanning a card is a way of reaching @@ -24,7 +26,7 @@ import kotlinx.coroutines.delay * The card still gets a beat on screen before the chat takes over. It is the same 450ms the modal * used to take sliding up ([ModalAnimationSpeed.Normal] at the tip card's zero confirmation delay), * so the scan still reads as "I found this person" rather than as a screen that flashed past. Then - * the card pops and the push runs together, so the two are one movement rather than two. + * the card's pop overlaps the push into the chat, so the hand-off reads as one movement. */ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : ScannableDecorator { @Composable @@ -41,15 +43,21 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S LaunchedEffect(tipPresented, userId) { if (!tipPresented || userId == null) return@LaunchedEffect delay(ModalAnimationSpeed.Normal(billState.confirmationDelayMillis).delay.toLong()) - // Pop and push in the same frame, and pop with [Grabbed] rather than [PutInWallet]: - // the card leaves as a 100ms fade-and-scale instead of a 600ms slide back to the - // wallet. The overlay lives inside this navigation entry, so the entry slides out from - // under it either way — the slide outlasts the handoff and is never seen, while the - // pop lands inside it and reads as the card handing you to the chat. - context.onDismiss(Grabbed) - navigator.push( - AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId, tipCard.user)), - ) + // Dismiss with [Grabbed] rather than [PutInWallet]: the card leaves as a 100ms + // fade-and-scale instead of a 600ms slide back to the wallet, which the entry sliding + // out from under it would swallow whole. [PopLeadMillis] then gives that pop a head + // start, so it is already running when the push takes the entry away — dismissed and + // pushed in the same frame, the card is removed rather than seen to leave. + // + // Uncancellable because dismissing sets the bill to null, which flips `tipPresented` + // and restarts this effect; a plain delay here would take the push down with it. + withContext(NonCancellable) { + context.onDismiss(Grabbed) + delay(PopLeadMillis) + navigator.push( + AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId, tipCard.user)), + ) + } } // The coordinator's one-shot UI events. Collected here, not in a modal, so it fires @@ -74,3 +82,10 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S } } } + +/** + * How long the card's exit gets to itself before the chat is pushed. Long enough for the + * fade-and-scale to be under way when the navigation transition starts, short enough that the two + * still read as one movement. + */ +private const val PopLeadMillis = 50L From 65a82a7828f4f8f86550580e91e39df3ad628930 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 13:34:17 -0400 Subject: [PATCH 8/8] fix(session): put a cooldown on tip card rescans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-off into the chat dismisses the bill ~50ms before it pushes, and the push is what eventually detaches the analyzer. For the length of the transition the camera is still on the card with every gate open: `onCodeScan`'s "a bill is up" check has just been released, `scannedRendezvous` is only ever written on the cash path, and `TipCardDelegate.inFlight` cleared when the first profile fetch returned. The card re-resolves and re-presents over the chat it just opened, and that copy of the decorator pushes a second chat behind it. Tip payloads now carry their own suppression, keyed on user id. A cooldown rather than the permanent entry cash gets, because a tip card is a person and scanning them again later is legitimate — 3s, the same window the QR analyzer already debounces the other scan route to this card by. Accepting a scan refreshes the window and a suppressed frame does not, so holding the camera on a card runs the cooldown out instead of extending it. Synchronized: MultiCodeAnalyzer decodes each frame in its own coroutine, so an unguarded check-then-set lets two frames both through. --- .../internal/delegates/CodeScanDelegate.kt | 45 ++++++++++++++++++- .../delegates/CodeScanDelegateTest.kt | 30 +++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt index 6d4498cc99..ed291d0597 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt @@ -37,7 +37,9 @@ import kotlin.time.Clock * 5. On success: adds the token to the balance and emits [Event.BillReady], * [Event.CheckPendingFeed], and [Event.RefreshFeed] for the shell to route. * - * The rendezvous dedup map is cleared per-key on grab error so the user can retry. + * The rendezvous dedup map is cleared per-key on grab error so the user can retry. Tip + * payloads carry no grab, so they are deduplicated on a cooldown instead — see + * [isTipCardOnCooldown]. * * @see com.flipcash.app.session.internal.RealSessionController */ @@ -66,6 +68,7 @@ class CodeScanDelegate @Inject constructor( val events: Flow = _events.consumeAsFlow() private val scannedRendezvous = mutableMapOf() + private val scannedTipCards = mutableMapOf() override fun onCameraScanning(scanning: Boolean) { stateHolder.update { it.copy(isCameraUp = scanning) } @@ -82,6 +85,16 @@ class CodeScanDelegate @Inject constructor( return } + // A tip card is a person rather than a one-shot grab, so it gets a cooldown where cash + // gets the permanent rendezvous suppression above. Without one the only thing between the + // camera and a second present is the "a bill is up" check at the top of this function, and + // the hand-off into the chat drops the bill before it pushes (see TipCardDecorator) — for + // the length of that transition the card is still in frame with every gate open, and it + // re-presents over the chat it just opened. + if (codePayload.kind == PayloadKind.Tip && isTipCardOnCooldown(codePayload.userId)) { + return + } + if (stateHolder.current.vibrateOnScan) { vibrator.tick() } @@ -160,4 +173,34 @@ class CodeScanDelegate @Inject constructor( analytics.tipCardScanned() _events.trySend(Event.TipCardScanned(userId)) } + + /** + * Whether [userId]'s card was accepted within [TipCardRescanCooldownMillis]. Accepting refreshes + * the window and a suppressed frame does not, so holding the camera on a card runs the cooldown + * out rather than extending it indefinitely. + * + * Synchronized because the analyzer decodes frames concurrently (each frame gets its own + * coroutine in MultiCodeAnalyzer), and an unguarded check-then-set lets two of them both pass. + */ + @Synchronized + private fun isTipCardOnCooldown(userId: ID?): Boolean { + userId ?: return false + val now = Clock.System.now().toEpochMilliseconds() + val lastScanned = scannedTipCards[userId] + if (lastScanned != null && now - lastScanned < TipCardRescanCooldownMillis) { + return true + } + scannedTipCards[userId] = now + return false + } + + private companion object { + /** + * How long the same tip card is ignored after one has been accepted. Long enough to cover + * the card's beat on screen and the push into the chat behind it, short enough that + * deliberately scanning the same person again isn't blocked. The same 3s the QR analyzer + * debounces by, which is what already covers the other scan route to this card. + */ + const val TipCardRescanCooldownMillis = 3_000L + } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt index b5efce0d7c..4d28795a60 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt @@ -293,4 +293,34 @@ class CodeScanDelegateTest { ) } } + + // --- Tip cards: a cooldown, not the cash path's permanent rendezvous suppression --- + + @Test + fun `onCodeScan ignores a tip card already scanned within the cooldown`() = runTest { + every { mockPayload.kind } returns PayloadKind.Tip + every { mockPayload.userId } returns listOf(1.toByte()) + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + // The camera keeps decoding the same card through the hand-off into the chat, and the + // dismissed bill has already re-opened the "a bill is up" gate at the top of onCodeScan. + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 1) { analytics.tipCardScanned() } + } + + @Test + fun `onCodeScan accepts a different tip card during another card's cooldown`() = runTest { + every { mockPayload.kind } returns PayloadKind.Tip + every { mockPayload.userId } returns listOf(1.toByte()) + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + every { mockPayload.userId } returns listOf(2.toByte()) + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 2) { analytics.tipCardScanned() } + } }