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..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 @@ -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) { @@ -209,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) } } @@ -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. */ 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, 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 177c634733..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 @@ -1,83 +1,73 @@ 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.flipcash.app.session.Grabbed 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.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext /** - * 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. Then + * 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 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()) + // 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)), + ) } } - // 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( @@ -85,35 +75,17 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S AppRoute.Messaging.Chat(event.identifier, openKeyboard = true), ), ) - context.onDismiss() + context.onDismiss(Grabbed) } } } } - - 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, - ) - } - } } } + +/** + * 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 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, - ) - } - } -} 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() } + } } 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"