From 10b4a63b9260a8dec2676529b8357ced8db0f141 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 17:46:38 -0400 Subject: [PATCH] feat(wallet): tick the balance up when a scanned bill is claimed "Put in Wallet" dismissed the bill and left the user on the scanner, so money they had just taken only showed up the next time they opened the wallet, already counted. The button now hands them to the wallet tab and the balance rolls up on arrival. That roll needs a "before" to start from, and there isn't one by then: `tokenCoordinator.add` credits the account when the bill is *grabbed*, several taps earlier, so every flow the wallet reads has already moved. `WalletRevealCoordinator` captures the pre-credit totals at the grab, `claimReceivedFunds` arms them, and the wallet opens on that snapshot for 450ms before releasing to live values. Both the header and each card's balance already draw through `AnimatedNumberText`, so the roll is just the rendered string changing. Only a scanned bill routes. A cash link is claimed from a link rather than from the scanner, so nothing is captured for it, `arm()` reports that, and its confirmation dismisses where it stands as before. A claim in a currency the wallet didn't hold is withheld from the deck for the same beat, then passed to `TokenCardStack` as `enteringMint`, which opens a slot for it and fades it in. The caller names the card rather than the stack diffing its own token list, because the case that most needs the animation, a wallet that held nothing so the stack was never composed, is the one a diff can't see. The hold is timed from the wallet reporting itself drawn rather than from the tap, so a slow tab doesn't spend it behind the loading spinner, and a reveal nobody collects releases itself after 3s. --- .../flipcash/app/core/ui/TokenCardStack.kt | 45 +++++- .../balance/internal/WalletScreenContent.kt | 68 ++++++++- .../app/balance/internal/WalletViewModel.kt | 28 ++++ .../balance/internal/BalanceViewModelTest.kt | 7 + .../internal/WalletMilestoneGatingTest.kt | 6 + .../app/bills/decor/PayableDecorator.kt | 18 ++- .../flipcash/app/session/SessionController.kt | 14 ++ .../delegates/BillPresentationDelegate.kt | 8 + .../internal/delegates/CodeScanDelegate.kt | 6 + .../SessionControllerGiftCardErrorTest.kt | 4 + .../delegates/BillPresentationDelegateTest.kt | 45 ++++++ .../delegates/CodeScanDelegateTest.kt | 38 +++++ .../delegates/DelegateEventEmissionTest.kt | 5 + .../flipcash/app/tokens/TokenCoordinator.kt | 14 ++ .../app/tokens/WalletRevealCoordinator.kt | 123 ++++++++++++++++ .../app/tokens/WalletRevealCoordinatorTest.kt | 138 ++++++++++++++++++ 16 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/WalletRevealCoordinator.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/WalletRevealCoordinatorTest.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt index 031f57a005..0a6ff61348 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt @@ -1,7 +1,10 @@ package com.flipcash.app.core.ui +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect @@ -49,6 +52,13 @@ import com.getcode.solana.keys.Mint * The flying card is hosted in the transition overlay while **opening** (so it lifts cleanly above the * parting deck) but **in-layer** while closing, so on the way back it re-inserts at its natural deck * z-order and slides under its neighbours instead of landing on top and snapping under. + * + * ## Card entry + * [enteringMint] names a card that has just joined the deck (a claim in a currency the wallet did not + * hold), and the deck opens a slot for it: it starts overlapped with its neighbour and fades in as the + * gap widens to the full [fannedReveal]. The caller names the card rather than the stack diffing its + * own token list, because the case that most needs the animation — a wallet that held nothing, so the + * stack was not composed at all — is exactly the one a diff cannot see. */ @Composable fun TokenCardStack( @@ -63,11 +73,26 @@ fun TokenCardStack( expandProgress: () -> Float = { 0f }, heroTarget: Rect? = null, pullOffset: () -> Float = { 0f }, + enteringMint: Mint? = null, onCardClick: (TokenWithLocalizedBalance, Rect) -> Unit = { _, _ -> }, ) { val tappedIndex = remember(expandingMint, tokens) { if (expandingMint == null) -1 else tokens.indexOfFirst { it.token.address == expandingMint } } + val enteringIndex = remember(enteringMint, tokens) { + if (enteringMint == null) -1 else tokens.indexOfFirst { it.token.address == enteringMint } + } + // Seeded closed only when there is a card to let in, so an ordinary deck draws at rest on its + // first frame instead of popping open. + val entryAnim = remember(enteringMint) { + Animatable(if (enteringMint == null) 1f else 0f) + } + LaunchedEffect(enteringMint, enteringIndex) { + if (enteringIndex >= 0) { + entryAnim.animateTo(1f, tween(EntryDurationMillis)) + } + } + // Screen height, so cards below the selected one travel off the bottom edge (read live in the reorg // layer; changes rarely). val windowHeightPx = with(LocalDensity.current) { @@ -80,6 +105,7 @@ fun TokenCardStack( content = { tokens.forEachIndexed { index, token -> val isTapped = index == tappedIndex + val isEntering = index == enteringIndex val cardBounds = remember(token.token.address) { mutableStateOf(Rect.Zero) } TokenCard( tokenWithBalance = token, @@ -137,6 +163,11 @@ fun TokenCardStack( translationY = (clearedTop - cardBounds.value.top) * hp alpha = 1f - hp } + } else if (isEntering) { + // Fades in over the first half of the slot opening, so the card has + // arrived by the time it is fully uncovered. Last branch on purpose: + // a card-expand in flight owns the whole deck's opacity. + alpha = (entryAnim.value * 2f).coerceIn(0f, 1f) } }, height = cardHeight, @@ -160,6 +191,11 @@ fun TokenCardStack( // the deck pin `pinInset` px below its own top, pushing the front card past the bottom of the // item and under the following row (the wallet's "Recent" section overlapping a lone card). val collapseComplete = (placeables.size - 1) * (fannedPx - collapsedPx) - pinInsetPx + // The slot the entering card is taking, 0 (closed, overlapped) → fannedPx (open). Cards + // *below* the new one carry the shift; only when it joins at the back does it move itself, + // sliding down out from under its predecessor. + val entering = enteringIndex + val shiftFrom = if (entering == placeables.lastIndex) entering else entering + 1 layout(constraints.maxWidth, height) { // Read scroll offset HERE (placement) — not in the measure scope — so scrolling only // re-places the cards; reading it while measuring would re-run each card's SubcomposeLayout. @@ -168,11 +204,18 @@ fun TokenCardStack( // negative — at rest the stack sits below the top chrome, and that negative keeps `pinnedY` // under `fannedY` so every card (including the last) stays fanned instead of collapsing. val past = scrolledPast().coerceAtMost(collapseComplete.toFloat()) + // Read in placement like `scrolledPast`, so the entry only re-places cards. + val entryClosed = + if (entering < 0 || shiftFrom <= 0) 0 + else (fannedPx * (1f - entryAnim.value)).toInt() placeables.forEachIndexed { index, placeable -> - val fannedY = index * fannedPx + val fannedY = index * fannedPx - if (index >= shiftFrom) entryClosed else 0 val pinnedY = (past + pinInsetPx + index * collapsedPx).toInt() placeable.placeRelative(0, maxOf(fannedY, pinnedY)) } } } } + +/** How long a newly-claimed card takes to open its slot in the deck. */ +private const val EntryDurationMillis = 450 diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 880cd53105..6159a32f14 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -21,8 +21,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer @@ -33,8 +37,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.cardexpand.LocalCardExpansion import com.flipcash.app.core.AppRoute +import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds import com.flipcash.app.core.ui.AppreciationStyle import com.flipcash.app.core.ui.TokenCardStack import com.flipcash.app.balance.internal.components.BalanceHeader @@ -95,6 +102,30 @@ internal fun WalletScreenContent( return } + // A claim the user just accepted, held for a beat so the money arrives on screen rather than + // being there already. Reported as displayed *after* the loading gate above, so a slow tab + // doesn't spend the hold behind a spinner. + val reveal = balanceState.reveal + LaunchedEffect(reveal != null) { + if (reveal != null) dispatchEvent(WalletViewModel.Event.OnRevealDisplayed) + } + + // The card the reveal is withholding. Outlives the reveal on purpose: the deck can only animate + // the card in once it is allowed to draw it, which is the moment the reveal ends. + var enteringMint by remember { mutableStateOf(null) } + LaunchedEffect(reveal?.mint, reveal?.isNewToken) { + val mint = reveal?.mint ?: return@LaunchedEffect + if (reveal.isNewToken) enteringMint = mint + } + LaunchedEffect(enteringMint) { + if (enteringMint == null) return@LaunchedEffect + // Long enough to cover the deck's own entry; clearing it stops a later return to the tab + // replaying the animation. + delay(EntryRetention) + enteringMint = null + } + val withheldMint = reveal?.mint?.takeIf { reveal.isNewToken } + val listState = rememberLazyListState() // Px the token stack has scrolled above the viewport top, read live so the stack collapses (then // releases and scrolls off) as the list scrolls. A lambda so the stack reads it in its placement @@ -137,7 +168,10 @@ internal fun WalletScreenContent( .fillMaxWidth() // Fade the balance out as the deck parts behind the opening card (iOS deckOpacity). .graphicsLayer { alpha = 1f - heroProgress() }, - balance = tokenState.totalBalance, + // AnimatedNumberText rolls whichever digits change, so opening on the pre-claim + // total is all the tick-up needs. + balance = reveal?.let { LocalFiat.fromUsd(it.totalBefore, tokenState.rate) } + ?: tokenState.totalBalance, appreciation = tokenState.aggregateAppreciation, topPadding = 96.dp, bottomPadding = 44.dp, @@ -171,10 +205,32 @@ internal fun WalletScreenContent( } } - tokenState.tokens?.takeIf { it.isNotEmpty() }?.let { tokens -> + tokenState.tokens + ?.let { tokens -> + when { + // A currency the wallet didn't hold: keep its card out of the deck until the + // reveal ends, so it can animate in rather than being there on arrival. + withheldMint != null -> tokens.filterNot { it.token.address == withheldMint } + // One it did: hold the card's own number back too, so it rolls in step with the + // total above it instead of sitting there already updated. + reveal != null -> tokens.map { entry -> + if (entry.token.address != reveal.mint) entry + else entry.copy( + balance = LocalFiat.fromUsd( + usdf = reveal.mintBalanceBefore, + rate = tokenState.rate, + mint = reveal.mint, + ) + ) + } + else -> tokens + } + } + ?.takeIf { it.isNotEmpty() }?.let { tokens -> item(key = TokenStackKey) { TokenCardStack( tokens = tokens, + enteringMint = enteringMint, modifier = Modifier.fillMaxWidth(), pinInset = statusBarInset + CodeTheme.dimens.grid.x2, scrolledPast = scrolledPast, @@ -293,4 +349,10 @@ internal fun WalletScreenContent( } } } -} \ No newline at end of file +} + +/** + * How long a newly-claimed mint stays flagged as entering — the deck's own entry animation plus + * slack, after which the card is just another card in the deck. + */ +private val EntryRetention = 1.seconds diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index 640dd2feca..a5a77cd4f9 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -10,6 +10,8 @@ import com.flipcash.shared.transactionhistory.FeedSyncState import com.flipcash.shared.transactionhistory.TransactionListItem import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletReveal +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -37,6 +39,7 @@ internal class WalletViewModel @Inject constructor( chatCoordinator: ChatCoordinator, feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, + walletReveal: WalletRevealCoordinator, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -73,6 +76,12 @@ internal class WalletViewModel @Inject constructor( val feedSyncState: FeedSyncState = FeedSyncState.Unknown, /** Whether the account currently holds a balance in any token (see [isAwaitingActivity]). */ val holdsBalance: Boolean = false, + /** + * The wallet as it stood before a claim the user has just accepted, or null to draw live + * values. Present only for the moment after "Put in Wallet" hands the user here, so the + * balance can roll up to the money that already landed rather than opening on it. + */ + val reveal: WalletReveal? = null, ) { val hasReceivedMoney: Boolean get() = onboardingItems?.find { it is TutorialItem.AddMoney }?.isCompleted == true @@ -119,6 +128,11 @@ internal class WalletViewModel @Inject constructor( data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event data class OnFeedSyncStateChanged(val syncState: FeedSyncState) : Event + data class OnRevealChanged(val reveal: WalletReveal?) : Event + + /** The screen has drawn [State.reveal]; starts the hold before the balance rolls up. */ + data object OnRevealDisplayed : Event + data object OpenCurrencySelection : Event data class OpenScreen(val screen: AppRoute) : Event @@ -126,6 +140,18 @@ internal class WalletViewModel @Inject constructor( } init { + // A claim the user accepted with "Put in Wallet". The balance was credited when the bill was + // grabbed, so what arrives here is the *pre-claim* picture to open on. + walletReveal.pending + .onEach { dispatchEvent(Event.OnRevealChanged(it)) } + .launchIn(viewModelScope) + + // The hold is timed from the screen, not from the tap, so a slow entry doesn't eat it. + eventFlow + .filterIsInstance() + .onEach { walletReveal.onDisplayed() } + .launchIn(viewModelScope) + // Preview of recent activity (bounded to RECENT_PREVIEW_COUNT by the coordinator). feedCoordinator.recentTransactions(limit = RECENT_PREVIEW_COUNT) .onEach { dispatchEvent(Event.OnTransactionsUpdated(it)) } @@ -191,6 +217,8 @@ internal class WalletViewModel @Inject constructor( val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { Event.OpenCurrencySelection -> { state -> state } + is Event.OnRevealChanged -> { state -> state.copy(reveal = event.reveal) } + Event.OnRevealDisplayed -> { state -> state } is Event.OnPreferredOnRampProviderChanged -> { state -> state.copy(preferredOnRampProvider = event.provider) } diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt index afe22bc1d3..26542feeac 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt @@ -8,6 +8,7 @@ import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -17,6 +18,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -49,6 +51,10 @@ class BalanceViewModelTest { every { hasAnyBalance } returns flowOf(false) } + private val walletReveal: WalletRevealCoordinator = mockk(relaxed = true) { + every { pending } returns MutableStateFlow(null) + } + private lateinit var dispatchers: TestDispatchers private fun createViewModel() = WalletViewModel( @@ -60,6 +66,7 @@ class BalanceViewModelTest { chatCoordinator = chatCoordinator, feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, ) @Test diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt index 0a2461499a..d02fa555ae 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt @@ -7,6 +7,7 @@ import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator @@ -55,6 +56,10 @@ class WalletMilestoneGatingTest { every { hasAnyBalance } returns flowOf(true) } + private val walletReveal: WalletRevealCoordinator = mockk(relaxed = true) { + every { pending } returns MutableStateFlow(null) + } + private lateinit var dispatchers: TestDispatchers private fun createViewModel() = WalletViewModel( @@ -66,6 +71,7 @@ class WalletMilestoneGatingTest { chatCoordinator = chatCoordinator, feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, ) @Test 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 81c2d213ed..61c7dbcef2 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 @@ -18,9 +18,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.BottomCenter import androidx.compose.ui.Modifier +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable +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.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.core.measured import com.getcode.ui.utils.AnimationUtils import kotlinx.coroutines.delay @@ -37,6 +41,8 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan @Composable override fun BoxScope.Content(context: ScannableDecoratorContext) { val billState = context.billState + val session = LocalSessionController.current + val navigator = LocalCodeNavigator.current // Bill management options AnimatedScannableDecorator( @@ -85,7 +91,17 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan ) { ReceivedFundsConfirmation( bill = bill, - onClaim = { context.onDismiss() } + // Claiming is a distinct outcome from the dismissals `onDismiss` covers: a + // scanned bill hands the user to the wallet, where the reveal armed here rolls + // the balance up from the pre-claim total. The funds were already credited at + // 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() + else if (session.claimReceivedFunds()) { + navigator.navigateAll(listOf(AppRoute.Sheets.Wallet)) + } + } ) } } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 7d0b80c9f3..43bf2c8944 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -25,6 +25,20 @@ interface BillOperations { val billState: StateFlow fun showBill(bill: Scannable.Payable) fun dismissBill(action: BillDeterminationResult) + + /** + * The user accepted funds they just received ("Put in Wallet"). Dismisses the bill and arms the + * wallet reveal, so the tab they land on can tick the balance up from where it stood before the + * claim rather than opening on a number that already moved. + * + * Separate from `dismissBill(PutInWallet)` because that same result also covers a grab timeout, + * a cancel, and a swipe-away — none of which are the user asking to be shown their wallet. + * + * Returns whether the caller should take the user to their wallet. Only a scanned bill is + * snapshotted, so a cash link, claimed from a link rather than from the scanner, dismisses + * without routing. + */ + fun claimReceivedFunds(): Boolean } interface CodeScanOperations { diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt index 4fb65d8866..8d2dfa17c8 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt @@ -14,6 +14,7 @@ import com.flipcash.app.session.PutInWallet import com.flipcash.app.session.internal.SessionStateHolder import com.flipcash.app.session.internal.toast.SessionToastController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -57,6 +58,7 @@ class BillPresentationDelegate @Inject constructor( private val stateHolder: SessionStateHolder, private val toastController: SessionToastController, private val tokenCoordinator: TokenCoordinator, + private val walletReveal: WalletRevealCoordinator, private val analytics: FlipcashAnalyticsService, private val vibrator: Vibrator, private val resources: ResourceHelper, @@ -127,6 +129,12 @@ class BillPresentationDelegate @Inject constructor( stateHolder.update { it.copy(billResult = Grabbed) } } + override fun claimReceivedFunds(): Boolean { + val armed = walletReveal.arm() + dismissBill(PutInWallet) + return armed + } + override fun dismissBill(action: BillDeterminationResult) { scope.launch { stateHolder.update { it.copy(billResult = action) } 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 95fcbb488a..6d4498cc99 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 @@ -7,6 +7,7 @@ import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.CodeScanOperations import com.flipcash.app.session.internal.SessionStateHolder import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager @@ -45,6 +46,7 @@ class CodeScanDelegate @Inject constructor( private val stateHolder: SessionStateHolder, private val billController: BillController, private val tokenCoordinator: TokenCoordinator, + private val walletReveal: WalletRevealCoordinator, private val analytics: FlipcashAnalyticsService, private val vibrator: Vibrator, private val userManager: UserManager, @@ -115,6 +117,10 @@ class CodeScanDelegate @Inject constructor( owner = owner, payload = payload, onGrabbed = { token, amount, verifiedState -> + // Take the wallet's "before" picture first: the credit below lands here, at grab + // time, but the user doesn't reach the wallet until they tap "Put in Wallet". Snapshot + // it afterwards and there is nothing left for the balance to tick up from. + walletReveal.capture(token.address) tokenCoordinator.add(token, amount) val grabStart = scannedRendezvous[payload.rendezvous.publicKey] val grabTime = grabStart?.let { diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 4b113c3cf4..0daee54d4a 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -13,6 +13,7 @@ import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.user.UserManager import com.flipcash.shared.session.R @@ -50,6 +51,7 @@ class SessionControllerGiftCardErrorTest { private val userManager = mockk(relaxed = true) private val resources = FakeResourceHelper() private val tokenCoordinator = mockk(relaxed = true) + private val walletReveal = mockk(relaxed = true) private val analytics = mockk(relaxed = true) private val networkObserver = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) @@ -78,6 +80,7 @@ class SessionControllerGiftCardErrorTest { stateHolder = stateHolder, toastController = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, analytics = analytics, vibrator = mockk(relaxed = true), resources = resources, @@ -90,6 +93,7 @@ class SessionControllerGiftCardErrorTest { stateHolder = stateHolder, billController = billController, tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, analytics = analytics, vibrator = mockk(relaxed = true), userManager = userManager, diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt index 59dd38a57b..b3bcfaf809 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt @@ -9,6 +9,7 @@ import com.flipcash.app.session.PutInWallet import com.flipcash.app.session.internal.SessionStateHolder import com.flipcash.app.session.internal.toast.SessionToastController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.core.R import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.user.UserManager @@ -30,6 +31,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -45,6 +47,7 @@ class BillPresentationDelegateTest { private val analytics = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) + private val walletReveal = mockk(relaxed = true) private val networkObserver = mockk(relaxed = true) private val vibrator = mockk(relaxed = true) private val toastController = mockk(relaxed = true) @@ -58,6 +61,7 @@ class BillPresentationDelegateTest { stateHolder = stateHolder, toastController = toastController, tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, analytics = analytics, vibrator = vibrator, resources = resources, @@ -82,6 +86,47 @@ class BillPresentationDelegateTest { BottomBarManager.clear() } + // --- claiming received funds --- + + @Test + fun `claiming a scanned bill arms the wallet reveal and asks to be routed`() = runTest { + every { walletReveal.arm() } returns true + + val stateHolder = SessionStateHolder() + val delegate = createDelegate(stateHolder) + + assertTrue(delegate.claimReceivedFunds()) + + verify { billController.reset() } + assertEquals(PutInWallet, stateHolder.state.value.billResult) + } + + @Test + fun `claiming funds that were never scanned dismisses without routing`() = runTest { + // A cash link is claimed from a link rather than from the scanner, so nothing was + // snapshotted and there is no reveal to take the user to. + every { walletReveal.arm() } returns false + + val stateHolder = SessionStateHolder() + val delegate = createDelegate(stateHolder) + + assertFalse(delegate.claimReceivedFunds()) + + verify { billController.reset() } + assertEquals(PutInWallet, stateHolder.state.value.billResult) + } + + @Test + fun `the other PutInWallet dismissals leave the reveal alone`() = runTest { + val delegate = createDelegate() + + // A grab timeout, a cancel, and a swipe-away all land here; none of them is the user + // asking to be taken to their wallet. + delegate.dismissBill(PutInWallet) + + verify(exactly = 0) { walletReveal.arm() } + } + // --- showBill guards --- @Test 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 9bf90145ae..b5efce0d7c 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 @@ -6,16 +6,22 @@ import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.internal.SessionStateHolder import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.OpenCodePayload +import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.model.core.PayloadKind +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token import com.getcode.util.vibration.Vibrator import com.kik.kikx.models.ScannableKikCode +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -39,6 +45,7 @@ class CodeScanDelegateTest { private val analytics = mockk(relaxed = true) private val vibrator = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) + private val walletReveal = mockk(relaxed = true) private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) private val stateHolder = SessionStateHolder() @@ -53,6 +60,7 @@ class CodeScanDelegateTest { stateHolder = stateHolder, billController = billController, tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, analytics = analytics, vibrator = vibrator, userManager = userManager, @@ -222,6 +230,36 @@ class CodeScanDelegateTest { } } + // --- the grab callback --- + + @Test + fun `a grabbed bill is snapshotted for the wallet before it is credited`() = runTest { + val onGrabbed = slot Unit>() + every { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = capture(onGrabbed), + onError = any(), + ) + } answers {} + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + val token = mockk(relaxed = true) + val amount = mockk(relaxed = true) + onGrabbed.captured.invoke(token, amount, null) + + // Order is the whole feature: the credit lands here, at grab time, but the wallet is not + // shown until the user taps "Put in Wallet". Capture after it and there is nothing left + // for the balance to tick up from. + coVerifyOrder { + walletReveal.capture(token.address) + tokenCoordinator.add(token, amount) + } + } + // --- Error clears rendezvous so re-scan is possible --- @Test diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt index aed7df8081..577aeefd53 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt @@ -8,6 +8,7 @@ import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.internal.SessionStateHolder import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.user.UserManager import com.getcode.opencode.internal.manager.VerifiedState @@ -45,6 +46,7 @@ class DelegateEventEmissionTest { private val userManager = mockk(relaxed = true) private val analytics = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) + private val walletReveal = mockk(relaxed = true) private val networkObserver = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) @@ -73,6 +75,7 @@ class DelegateEventEmissionTest { stateHolder = stateHolder, toastController = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, analytics = analytics, vibrator = mockk(relaxed = true), resources = resources, @@ -118,6 +121,7 @@ class DelegateEventEmissionTest { stateHolder = stateHolder, toastController = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, analytics = analytics, vibrator = mockk(relaxed = true), resources = resources, @@ -177,6 +181,7 @@ class DelegateEventEmissionTest { stateHolder = stateHolder, billController = billController, tokenCoordinator = tokenCoordinator, + walletReveal = walletReveal, analytics = analytics, vibrator = mockk(relaxed = true), userManager = userManager, diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 13d975f8f0..e4029a51a1 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -286,6 +286,20 @@ class TokenCoordinator @Inject constructor( .map { state -> state.balances.values.sum() } .distinctUntilChanged() + /** Synchronous, network-free read of [observeTotalBalance]'s current value. */ + fun currentTotalBalance(): Fiat = _state.value.balances.values.sum() + + /** Synchronous, network-free read of what this account holds in [mint]. */ + fun currentBalance(mint: Mint): Fiat = _state.value.balances[mint] ?: Fiat.Zero + + /** + * Whether [mint] currently reads as a held balance — i.e. whether the wallet's card deck already + * has a card for it. Dust that rounds away in the UI counts as *not* held, matching the deck's + * own `hasDisplayableValue` filter rather than [hasAnyBalance]'s "holds anything at all". + */ + fun holdsDisplayableBalance(mint: Mint): Boolean = + _state.value.balances[mint]?.hasDisplayableValue == true + suspend fun add(token: Token, fiat: LocalFiat) { val rate = exchange.rateToUsd(fiat.rate.currency) val amount = rate?.let { fiat.nativeAmount.convertingTo(it) } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/WalletRevealCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/WalletRevealCoordinator.kt new file mode 100644 index 0000000000..4d83543f25 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/WalletRevealCoordinator.kt @@ -0,0 +1,123 @@ +package com.flipcash.app.tokens + +import androidx.compose.runtime.Immutable +import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * The wallet as it stood immediately before a claim landed. + * + * @param mint the token that was claimed. + * @param isNewToken whether the wallet held no displayable balance in [mint] before the claim — + * i.e. whether the card deck is about to gain a card rather than update one. + * @param totalBefore the summed balance across every token, USD-denominated like + * [TokenCoordinator.observeTotalBalance]. + * @param mintBalanceBefore [mint]'s own balance, so its card rolls in step with the total rather + * than showing the post-claim number beside a pre-claim one. + */ +@Immutable +data class WalletReveal( + val mint: Mint, + val isNewToken: Boolean, + val totalBefore: Fiat, + val mintBalanceBefore: Fiat, +) + +/** + * Carries the pre-claim wallet across the "Put in Wallet" hand-off, so the tab the user lands on can + * show the money arriving instead of a total that has already moved. + * + * Both claim paths — a scanned bill ([com.flipcash.app.session] `CodeScanDelegate`) and a cash link + * — credit the balance the moment the funds are grabbed, which is well before the user taps through + * the confirmation. By then the wallet's own flows already carry the new number, so there is nothing + * left to animate. [capture] takes the "before" picture at the point of credit; [arm] publishes it + * only when the user actually asks to be taken to the wallet. + * + * The reveal releases itself, so a stale one can never colour a later visit: [arm] starts a + * [UnclaimedTimeout] fuse for the case where the wallet never appears, and [onDisplayed] — reported + * by the wallet once it is drawn — replaces that with the short [HoldDuration] the animation runs + * off. Timing the hold from the screen rather than from the tap keeps a slow entry from eating it. + */ +@Singleton +class WalletRevealCoordinator @Inject constructor( + private val tokenCoordinator: TokenCoordinator, + dispatchers: DispatcherProvider, +) { + private val scope = CoroutineScope(dispatchers.Default + SupervisorJob()) + + private var captured: WalletReveal? = null + private var held = false + private var release: Job? = null + + private val _pending = MutableStateFlow(null) + + /** The reveal the wallet should currently be rendering, or null to render live values. */ + val pending: StateFlow = _pending.asStateFlow() + + /** + * Snapshots the wallet before [mint]'s incoming balance is applied. Call this *immediately* + * before crediting — a snapshot taken afterwards is just the post-claim state. + */ + fun capture(mint: Mint) { + captured = WalletReveal( + mint = mint, + isNewToken = !tokenCoordinator.holdsDisplayableBalance(mint), + totalBefore = tokenCoordinator.currentTotalBalance(), + mintBalanceBefore = tokenCoordinator.currentBalance(mint), + ) + } + + /** + * Publishes the captured snapshot, and reports whether there was one. Nothing is captured + * unless the funds came from a scanned bill, so a `false` return is also the answer to + * "should the caller take the user to their wallet?". + */ + fun arm(): Boolean { + val snapshot = captured ?: return false + captured = null + held = false + _pending.value = snapshot + releaseAfter(UnclaimedTimeout) + return true + } + + /** Reported by the wallet the first time it draws a pending reveal; starts the hold. */ + fun onDisplayed() { + if (_pending.value == null || held) return + held = true + releaseAfter(HoldDuration) + } + + private fun releaseAfter(duration: Duration) { + release?.cancel() + release = scope.launch { + delay(duration) + _pending.value = null + } + } + + companion object { + /** + * How long the pre-claim picture stays up once the wallet is on screen. Long enough to read + * as a starting value rather than a flicker, short enough that the tab doesn't feel stalled. + */ + val HoldDuration = 450.milliseconds + + /** Fuse for a reveal nobody came to collect (the user backed out before the wallet drew). */ + val UnclaimedTimeout = 3.seconds + } +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/WalletRevealCoordinatorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/WalletRevealCoordinatorTest.kt new file mode 100644 index 0000000000..f2dfa5afbd --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/WalletRevealCoordinatorTest.kt @@ -0,0 +1,138 @@ +package com.flipcash.app.tokens + +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class WalletRevealCoordinatorTest { + + private val mint = Mint.usdf + private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) + + private fun coordinator(dispatchers: TestDispatchers) = + WalletRevealCoordinator(tokenCoordinator = tokenCoordinator, dispatchers = dispatchers) + + private fun wallet(total: Fiat, mintBalance: Fiat, holdsMint: Boolean) { + every { tokenCoordinator.currentTotalBalance() } returns total + every { tokenCoordinator.currentBalance(mint) } returns mintBalance + every { tokenCoordinator.holdsDisplayableBalance(mint) } returns holdsMint + } + + @Test + fun `arm publishes the wallet as it stood when the claim was captured`() = + runTest { + val reveal = coordinator(TestDispatchers(testScheduler)) + wallet(total = Fiat(10), mintBalance = Fiat(4), holdsMint = true) + + reveal.capture(mint) + // The claim credits the balance right after the capture; the reveal must not follow it. + wallet(total = Fiat(15), mintBalance = Fiat(9), holdsMint = true) + reveal.arm() + + val pending = requireNotNull(reveal.pending.value) + assertEquals(mint, pending.mint) + assertEquals(Fiat(10), pending.totalBefore) + assertEquals(Fiat(4), pending.mintBalanceBefore) + assertEquals(false, pending.isNewToken) + } + + @Test + fun `a currency the wallet did not hold is flagged as a new card`() = runTest { + val reveal = coordinator(TestDispatchers(testScheduler)) + wallet(total = Fiat(0), mintBalance = Fiat(0), holdsMint = false) + + reveal.capture(mint) + reveal.arm() + + assertTrue(requireNotNull(reveal.pending.value).isNewToken) + } + + @Test + fun `arming without a capture publishes nothing, and says so`() = runTest { + val reveal = coordinator(TestDispatchers(testScheduler)) + + // What a cash link claim looks like: nothing was scanned, so nothing was captured, and the + // caller is told there is no wallet to route to. + assertFalse(reveal.arm()) + + assertNull(reveal.pending.value) + } + + @Test + fun `a capture is consumed once, so a later tap cannot replay it`() = runTest { + val dispatchers = TestDispatchers(testScheduler) + val reveal = coordinator(dispatchers) + wallet(total = Fiat(10), mintBalance = Fiat(4), holdsMint = true) + + reveal.capture(mint) + reveal.arm() + reveal.onDisplayed() + advanceUntilIdle() + assertNull(reveal.pending.value) + + reveal.arm() + + assertNull(reveal.pending.value) + } + + @Test + fun `the reveal is released once the wallet has held it on screen`() = runTest { + val reveal = coordinator(TestDispatchers(testScheduler)) + wallet(total = Fiat(10), mintBalance = Fiat(4), holdsMint = true) + + reveal.capture(mint) + reveal.arm() + reveal.onDisplayed() + + advanceTimeBy(WalletRevealCoordinator.HoldDuration - 1.milliseconds) + assertEquals(mint, reveal.pending.value?.mint) + + advanceTimeBy(2.milliseconds) + assertNull(reveal.pending.value) + } + + @Test + fun `a reveal nobody comes to collect releases itself`() = runTest { + val reveal = coordinator(TestDispatchers(testScheduler)) + wallet(total = Fiat(10), mintBalance = Fiat(4), holdsMint = true) + + reveal.capture(mint) + reveal.arm() + + // The user backed out before the wallet drew: no onDisplayed ever arrives. + advanceTimeBy(WalletRevealCoordinator.HoldDuration + 1.milliseconds) + assertEquals(mint, reveal.pending.value?.mint) + + advanceTimeBy(WalletRevealCoordinator.UnclaimedTimeout) + assertNull(reveal.pending.value) + } + + @Test + fun `a redraw part way through the hold does not extend it`() = runTest { + val reveal = coordinator(TestDispatchers(testScheduler)) + wallet(total = Fiat(10), mintBalance = Fiat(4), holdsMint = true) + + reveal.capture(mint) + reveal.arm() + reveal.onDisplayed() + + advanceTimeBy(WalletRevealCoordinator.HoldDuration - 1.milliseconds) + reveal.onDisplayed() + + advanceTimeBy(2.milliseconds) + assertNull(reveal.pending.value) + } +}