From 10b4a63b9260a8dec2676529b8357ced8db0f141 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 17:46:38 -0400 Subject: [PATCH 1/4] 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) + } +} From ef7f0f3ff3959f555d057d8267edca2405d7492b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 11:42:54 -0400 Subject: [PATCH 2/4] fix(wallet): hold the claim reveal until the bill is off screen The reveal released 450 ms after the wallet reported itself drawn, a clock unrelated to when the bill it came from finishes leaving. The bill returns on a 600 ms slide and the tab crossfades under it over 300 ms, so the balance regularly ran up while it was still covered and the arrival went unseen. Release now waits on the later of two clocks, both started at the tap: 450 ms for the bill to clear, and 150 ms from the wallet reporting itself drawn, so a tab that took a while to appear still shows the pre-claim figures instead of rolling them on its first frame. The unclaimed-reveal fuse is unchanged and now bounds the whole wait. Same sequencing as iOS's depositRevealDelay (#697). The card-entry retention window runs from the reveal ending rather than from the mint being flagged, because the hold before it no longer has a fixed length. --- .../balance/internal/WalletScreenContent.kt | 15 +++-- .../app/tokens/WalletRevealCoordinator.kt | 66 ++++++++++++------- .../app/tokens/WalletRevealCoordinatorTest.kt | 33 ++++++++-- 3 files changed, 80 insertions(+), 34 deletions(-) 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 6159a32f14..c832190725 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 @@ -102,9 +102,9 @@ 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. + // A claim the user just accepted, held until the bill it came from is off screen so the money + // arrives rather than being there already. Reported as displayed *after* the loading gate above, + // so a slow tab doesn't spend its share of the hold behind a spinner. val reveal = balanceState.reveal LaunchedEffect(reveal != null) { if (reveal != null) dispatchEvent(WalletViewModel.Event.OnRevealDisplayed) @@ -117,10 +117,11 @@ internal fun WalletScreenContent( 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. + LaunchedEffect(enteringMint, reveal == null) { + if (enteringMint == null || reveal != null) return@LaunchedEffect + // Timed from the reveal ending rather than from the flag being set, because that is the + // frame the entry starts on — the hold before it has no fixed length. 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 } 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 index 4d83543f25..727e622bb0 100644 --- 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 @@ -4,17 +4,19 @@ 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.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -47,10 +49,12 @@ data class WalletReveal( * 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. + * The reveal ends on the later of two clocks, both started by [arm]: [BillClearDelay] from the tap, + * so the balance does not move while the bill is still sliding off over it, and [MinimumHold] from + * the wallet reporting itself drawn ([onDisplayed]), so a tab that took a while to appear still + * shows the pre-claim figures for a beat rather than rolling them on its first frame. It releases + * itself either way — an [UnclaimedTimeout] bounds the wait, so a reveal nobody came to collect can + * never colour a later visit. */ @Singleton class WalletRevealCoordinator @Inject constructor( @@ -60,7 +64,7 @@ class WalletRevealCoordinator @Inject constructor( private val scope = CoroutineScope(dispatchers.Default + SupervisorJob()) private var captured: WalletReveal? = null - private var held = false + private var displayed = CompletableDeferred() private var release: Job? = null private val _pending = MutableStateFlow(null) @@ -89,33 +93,51 @@ class WalletRevealCoordinator @Inject constructor( fun arm(): Boolean { val snapshot = captured ?: return false captured = null - held = false + displayed = CompletableDeferred() _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) + // Both clocks run from here and the reveal ends on the slower of them, so neither the + // bill still coming down nor a tab that has not drawn yet can eat the animation. The + // timeout covers the wallet never arriving at all, in which case `displayed` never + // completes and this would otherwise wait forever. + withTimeoutOrNull(UnclaimedTimeout) { + coroutineScope { + launch { delay(BillClearDelay) } + launch { + displayed.await() + delay(MinimumHold) + } + } + } _pending.value = null } + return true + } + + /** Reported by the wallet the first time it draws a pending reveal; starts the [MinimumHold]. */ + fun onDisplayed() { + if (_pending.value == null) return + displayed.complete(Unit) } 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. + * How long the pre-claim picture stays up after the tap, before the balance is allowed to + * move. The bill returns on a 600 ms slide and the tab crossfades under it over 300 ms; + * rolling the total while that is still happening spends the animation where it cannot be + * seen. Matches iOS's own `depositRevealDelay`. + */ + val BillClearDelay = 450.milliseconds + + /** + * The least time the wallet holds the pre-claim figures once it has actually drawn them, + * for the case where it took longer than [BillClearDelay] to appear. Without it a slow tab + * would arrive on a number that rolls on the same frame, which reads as a glitch rather + * than as money landing. */ - val HoldDuration = 450.milliseconds + val MinimumHold = 150.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 index f2dfa5afbd..1b049a4f6f 100644 --- 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 @@ -89,7 +89,7 @@ class WalletRevealCoordinatorTest { } @Test - fun `the reveal is released once the wallet has held it on screen`() = runTest { + fun `a wallet that draws straight away still waits for the bill to clear`() = runTest { val reveal = coordinator(TestDispatchers(testScheduler)) wallet(total = Fiat(10), mintBalance = Fiat(4), holdsMint = true) @@ -97,7 +97,29 @@ class WalletRevealCoordinatorTest { reveal.arm() reveal.onDisplayed() - advanceTimeBy(WalletRevealCoordinator.HoldDuration - 1.milliseconds) + // The wallet has had its beat, but the bill is still coming down over it. Releasing on the + // faster clock would run the balance up underneath the bill, where it cannot be seen. + advanceTimeBy(WalletRevealCoordinator.MinimumHold + 1.milliseconds) + assertEquals(mint, reveal.pending.value?.mint) + + advanceTimeBy(WalletRevealCoordinator.BillClearDelay) + assertNull(reveal.pending.value) + } + + @Test + fun `a wallet that takes longer than the bill still holds the figures on arrival`() = runTest { + val reveal = coordinator(TestDispatchers(testScheduler)) + wallet(total = Fiat(10), mintBalance = Fiat(4), holdsMint = true) + + reveal.capture(mint) + reveal.arm() + + // The tab was slow: the bill has long since cleared by the time anything is drawn. + advanceTimeBy(WalletRevealCoordinator.BillClearDelay * 2) + assertEquals(mint, reveal.pending.value?.mint) + + reveal.onDisplayed() + advanceTimeBy(WalletRevealCoordinator.MinimumHold - 1.milliseconds) assertEquals(mint, reveal.pending.value?.mint) advanceTimeBy(2.milliseconds) @@ -112,8 +134,9 @@ class WalletRevealCoordinatorTest { reveal.capture(mint) reveal.arm() - // The user backed out before the wallet drew: no onDisplayed ever arrives. - advanceTimeBy(WalletRevealCoordinator.HoldDuration + 1.milliseconds) + // The user backed out before the wallet drew: no onDisplayed ever arrives, so the clock + // waiting on it never finishes and only the timeout ends the reveal. + advanceTimeBy(WalletRevealCoordinator.BillClearDelay + 1.milliseconds) assertEquals(mint, reveal.pending.value?.mint) advanceTimeBy(WalletRevealCoordinator.UnclaimedTimeout) @@ -129,7 +152,7 @@ class WalletRevealCoordinatorTest { reveal.arm() reveal.onDisplayed() - advanceTimeBy(WalletRevealCoordinator.HoldDuration - 1.milliseconds) + advanceTimeBy(WalletRevealCoordinator.BillClearDelay - 1.milliseconds) reveal.onDisplayed() advanceTimeBy(2.milliseconds) From f05e5efc2ba4578f7f7154d3a0d81f4a6bd8b690 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 11:49:51 -0400 Subject: [PATCH 3/4] feat(wallet): let a new card rise into the deck instead of pushing it open The claimed card was kept out of the token list until the reveal ended, then inserted, so the cards below it shifted down as its slot opened. iOS animates the same arrival with the deck's layout final from the first frame: only the arriving card's own offset and opacity move, rising 40dp into a slot already made for it. Match that. TokenCardStack takes `arrivingMint` + `arrivalHeld` in place of `enteringMint`: the card is in the deck throughout, held off-stage while the reveal is up, and released to rise over 900ms, iOS's own duration. The placement pass loses its slot-opening shift, so nothing around the card moves. The mint-balance rewind now applies only to a currency the wallet already held. A card that is only now arriving has no earlier number to roll from, and rises showing what it holds. --- .../flipcash/app/core/ui/TokenCardStack.kt | 71 +++++++++---------- .../balance/internal/WalletScreenContent.kt | 64 ++++++++--------- 2 files changed, 66 insertions(+), 69 deletions(-) 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 0a6ff61348..e19977fcd4 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,6 +1,7 @@ package com.flipcash.app.core.ui import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable @@ -53,12 +54,16 @@ import com.getcode.solana.keys.Mint * 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. + * ## Card arrival + * [arrivingMint] names a card that has just joined the deck (a claim in a currency the wallet did not + * hold). It rises [ArrivalRise] into its slot and fades up; the deck's layout is final from the first + * frame, so nothing around it moves — matching iOS's own `arrivalProgress` effect. [arrivalHeld] keeps + * it off-stage until the caller is ready, which is how the wallet waits for the bill it was claimed + * from to leave before the card is seen to land. + * + * 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( @@ -73,23 +78,21 @@ fun TokenCardStack( expandProgress: () -> Float = { 0f }, heroTarget: Rect? = null, pullOffset: () -> Float = { 0f }, - enteringMint: Mint? = null, + arrivingMint: Mint? = null, + arrivalHeld: Boolean = false, 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) + // Seeded off-stage only when there is a card to let in, so an ordinary deck draws at rest on its + // first frame instead of rising into place. + val arrival = remember(arrivingMint) { + Animatable(if (arrivingMint == null) 1f else 0f) } - LaunchedEffect(enteringMint, enteringIndex) { - if (enteringIndex >= 0) { - entryAnim.animateTo(1f, tween(EntryDurationMillis)) + LaunchedEffect(arrivingMint, arrivalHeld) { + if (arrivingMint != null && !arrivalHeld) { + arrival.animateTo(1f, tween(ArrivalDurationMillis, easing = FastOutSlowInEasing)) } } @@ -105,7 +108,7 @@ fun TokenCardStack( content = { tokens.forEachIndexed { index, token -> val isTapped = index == tappedIndex - val isEntering = index == enteringIndex + val isArriving = token.token.address == arrivingMint val cardBounds = remember(token.token.address) { mutableStateOf(Rect.Zero) } TokenCard( tokenWithBalance = token, @@ -117,7 +120,14 @@ fun TokenCardStack( // neighbours) and carries the hand-off on collapse without a z snap. The other // cards part around it: above slide off the top, below off the bottom, dissolving. .graphicsLayer { - if (isTapped) { + if (isArriving && arrival.value < 1f) { + // Rises into its slot and fades up, on its own — the deck around it + // is already laid out where it will end. Ahead of the expand + // branches, as on iOS: a card still arriving cannot also be the one + // being opened. + translationY = ArrivalRise.toPx() * (1f - arrival.value) + alpha = arrival.value + } else if (isTapped) { val src = cardBounds.value val tgt = heroTarget val p = expandProgress() @@ -163,11 +173,6 @@ 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, @@ -191,11 +196,6 @@ 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. @@ -204,12 +204,8 @@ 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 - if (index >= shiftFrom) entryClosed else 0 + val fannedY = index * fannedPx val pinnedY = (past + pinInsetPx + index * collapsedPx).toInt() placeable.placeRelative(0, maxOf(fannedY, pinnedY)) } @@ -217,5 +213,8 @@ fun TokenCardStack( } } -/** How long a newly-claimed card takes to open its slot in the deck. */ -private const val EntryDurationMillis = 450 +/** How far below its slot an arriving card starts. Matches iOS's `arrivalRise`. */ +private val ArrivalRise = 40.dp + +/** How long a newly-claimed card takes to rise into the deck. Matches iOS's own arrival. */ +private const val ArrivalDurationMillis = 900 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 c832190725..60f6ba255f 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 @@ -41,7 +41,7 @@ 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 kotlin.time.Duration.Companion.milliseconds import com.flipcash.app.core.ui.AppreciationStyle import com.flipcash.app.core.ui.TokenCardStack import com.flipcash.app.balance.internal.components.BalanceHeader @@ -110,22 +110,22 @@ internal fun WalletScreenContent( 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) } + // A card in a currency the wallet did not hold. Set while the reveal is up so the deck has it in + // its layout from the first frame — held off-stage, not withheld — and outlives the reveal, which + // is the moment it is released to rise into the slot already made for it. + var arrivingMint by remember { mutableStateOf(null) } LaunchedEffect(reveal?.mint, reveal?.isNewToken) { val mint = reveal?.mint ?: return@LaunchedEffect - if (reveal.isNewToken) enteringMint = mint + if (reveal.isNewToken) arrivingMint = mint } - LaunchedEffect(enteringMint, reveal == null) { - if (enteringMint == null || reveal != null) return@LaunchedEffect - // Timed from the reveal ending rather than from the flag being set, because that is the - // frame the entry starts on — the hold before it has no fixed length. 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 + LaunchedEffect(arrivingMint, reveal == null) { + if (arrivingMint == null || reveal != null) return@LaunchedEffect + // Timed from the reveal ending rather than from the flag being set, because that is the frame + // the arrival starts on — the hold before it has no fixed length. Long enough to cover the + // rise; clearing it stops a later return to the tab replaying the animation. + delay(ArrivalRetention) + arrivingMint = 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 @@ -208,30 +208,28 @@ internal fun WalletScreenContent( 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, - ) + // A currency the wallet already held: hold that card's own number back too, so it + // rolls in step with the total above it instead of sitting there already updated. A + // card that is only now arriving has no earlier number to roll from — it rises into + // the deck showing what it holds. + if (reveal == null || reveal.isNewToken) tokens + else 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, + arrivingMint = arrivingMint, + arrivalHeld = reveal != null, modifier = Modifier.fillMaxWidth(), pinInset = statusBarInset + CodeTheme.dimens.grid.x2, scrolledPast = scrolledPast, @@ -353,7 +351,7 @@ internal fun WalletScreenContent( } /** - * 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. + * How long a newly-claimed mint stays flagged as arriving — the rise plus slack, after which the + * card is just another card in the deck. */ -private val EntryRetention = 1.seconds +private val ArrivalRetention = 1500.milliseconds From efdba1d3eb4caa6c0f396febf00f1b90180df3fd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 12:12:44 -0400 Subject: [PATCH 4/4] fix(wallet): order the deck by the figure on the card, not the digits below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dollars and Dad Cash traded places in the card stack while the user watched. Both read $1.00, so nothing visible decided which sat on top — but the sort compared raw `Fiat`, which carries six decimal places against the two USD displays, and a launchpad currency's value moves in those hidden digits on every price refresh. Each refresh re-decided the order and the cards swapped. Sort on the displayed value instead, leaving the existing name tiebreak to settle cards that read the same. The comparator moves to a named `BalanceOrder` so the behaviour can be tested directly. The arrival animation made this easy to hit: the new card is now in the deck from the first frame, so refreshes land while it is on screen rather than before it is drawn. iOS sorts on the exact value too and has the same swap latent in it. --- .../app/tokens/ui/SelectTokenViewModel.kt | 24 ++++++++++---- .../ui/SelectTokenViewModelStateTest.kt | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index e3b0984023..70f08b72af 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -164,13 +164,7 @@ class SelectTokenViewModel @Inject constructor( } ) } - .sortedWith( - // Match iOS (Session.balances): descending by exact value, then - // alphabetical by name (not by mint address, which reordered equal-value - // tokens like LaunchIt/Teddies differently than iOS). - compareByDescending { it.balance.nativeAmount } - .thenBy { it.token.name } - ) + .sortedWith(BalanceOrder) .filter { val hasBalance = it.balance.nativeAmount.hasDisplayableValue when (purpose) { @@ -235,6 +229,22 @@ class SelectTokenViewModel @Inject constructor( } companion object { + /** + * Descending by displayed value, then alphabetical by name (not by mint address, which + * reordered equal-value tokens like LaunchIt/Teddies differently than iOS). + * + * Compared at display precision rather than on the raw [Fiat], which carries six decimal + * places against the two USD shows. Two cards both reading $1.00 differ in digits nobody can + * see, and a launchpad currency's value moves in those digits on every price refresh — so an + * exact comparison had Dollars and Dad Cash trading places under the user while the deck was + * on screen. Rounding first means cards showing the same figure hold a stable order. + * + * iOS (Session.balances) still sorts on the exact value and has the same swap latent in it. + */ + val BalanceOrder: Comparator = + compareByDescending { it.balance.nativeAmount.toDouble() } + .thenBy { it.token.name } + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnRateChanged -> { state -> diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt index 071e8bb045..a9d02d6eaf 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt @@ -28,6 +28,38 @@ class SelectTokenViewModelStateTest { ) } + private fun namedTokenWorth(name: String, nativeUsd: Double): TokenWithLocalizedBalance = + tokenWorth(nativeUsd).let { it.copy(token = it.token.copy(name = name)) } + + /** + * Both cards read $1.00, so nothing the user can see decides which sits above the other — but + * `Fiat` carries six decimal places, and a launchpad currency's value moves in the digits below + * the cent on every price refresh. Comparing raw values let that noise reorder the deck: Dollars + * and Dad Cash traded places while the wallet was on screen. Order by the displayed figure and + * the name breaks the tie instead, the same way on every refresh. + */ + @Test + fun `cards showing the same figure keep a stable order as sub-cent values move`() { + val dollars = namedTokenWorth("Dollars", 1.0) + val dadCashLow = namedTokenWorth("Dad Cash", 0.999_6) + val dadCashHigh = namedTokenWorth("Dad Cash", 1.000_4) + + // Every card here displays $1.00; only the invisible digits differ between the two refreshes. + val before = listOf(dollars, dadCashLow).sortedWith(SelectTokenViewModel.BalanceOrder) + val after = listOf(dollars, dadCashHigh).sortedWith(SelectTokenViewModel.BalanceOrder) + + assertEquals(listOf("Dad Cash", "Dollars"), before.map { it.token.name }) + assertEquals(listOf("Dad Cash", "Dollars"), after.map { it.token.name }) + } + + @Test + fun `a difference the user can see still orders the deck`() { + val sorted = listOf(namedTokenWorth("Dollars", 1.0), namedTokenWorth("Dad Cash", 1.01)) + .sortedWith(SelectTokenViewModel.BalanceOrder) + + assertEquals(listOf("Dad Cash", "Dollars"), sorted.map { it.token.name }) + } + /** * The wallet total must be round(sum(x)), not sum(round(x)): rounding each token to cents first * drifts the total by up to a penny and diverged from iOS (ExchangedFiat.total sums unrounded).