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 031f57a00..0a6ff6134 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 880cd5310..6159a32f1 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 640dd2fec..a5a77cd4f 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 afe22bc1d..26542feea 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 0a2461499..d02fa555a 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 81c2d213e..61c7dbcef 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 7d0b80c9f..43bf2c894 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 4fb65d886..8d2dfa17c 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 95fcbb488..6d4498cc9 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 4b113c3cf..0daee54d4 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 59dd38a57..b3bcfaf80 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 9bf90145a..b5efce0d7 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 aed7df808..577aeefd5 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 13d975f8f..e4029a51a 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 000000000..4d83543f2 --- /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 000000000..f2dfa5afb --- /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) + } +}