Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
Expand Down Expand Up @@ -49,6 +53,17 @@ 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 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(
Expand All @@ -63,11 +78,24 @@ fun TokenCardStack(
expandProgress: () -> Float = { 0f },
heroTarget: Rect? = null,
pullOffset: () -> Float = { 0f },
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 }
}
// 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(arrivingMint, arrivalHeld) {
if (arrivingMint != null && !arrivalHeld) {
arrival.animateTo(1f, tween(ArrivalDurationMillis, easing = FastOutSlowInEasing))
}
}

// 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) {
Expand All @@ -80,6 +108,7 @@ fun TokenCardStack(
content = {
tokens.forEachIndexed { index, token ->
val isTapped = index == tappedIndex
val isArriving = token.token.address == arrivingMint
val cardBounds = remember(token.token.address) { mutableStateOf(Rect.Zero) }
TokenCard(
tokenWithBalance = token,
Expand All @@ -91,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()
Expand Down Expand Up @@ -176,3 +212,9 @@ fun TokenCardStack(
}
}
}

/** 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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.milliseconds
import com.flipcash.app.core.ui.AppreciationStyle
import com.flipcash.app.core.ui.TokenCardStack
import com.flipcash.app.balance.internal.components.BalanceHeader
Expand Down Expand Up @@ -95,6 +102,31 @@ internal fun WalletScreenContent(
return
}

// 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)
}

// 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<Mint?>(null) }
LaunchedEffect(reveal?.mint, reveal?.isNewToken) {
val mint = reveal?.mint ?: return@LaunchedEffect
if (reveal.isNewToken) arrivingMint = mint
}
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 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
Expand Down Expand Up @@ -137,7 +169,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,
Expand Down Expand Up @@ -171,10 +206,30 @@ internal fun WalletScreenContent(
}
}

tokenState.tokens?.takeIf { it.isNotEmpty() }?.let { tokens ->
tokenState.tokens
?.let { tokens ->
// 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,
)
)
}
}
?.takeIf { it.isNotEmpty() }?.let { tokens ->
item(key = TokenStackKey) {
TokenCardStack(
tokens = tokens,
arrivingMint = arrivingMint,
arrivalHeld = reveal != null,
modifier = Modifier.fillMaxWidth(),
pinInset = statusBarInset + CodeTheme.dimens.grid.x2,
scrolledPast = scrolledPast,
Expand Down Expand Up @@ -293,4 +348,10 @@ internal fun WalletScreenContent(
}
}
}
}
}

/**
* 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 ArrivalRetention = 1500.milliseconds
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,6 +39,7 @@ internal class WalletViewModel @Inject constructor(
chatCoordinator: ChatCoordinator,
feedCoordinator: ActivityFeedCoordinator,
tokenCoordinator: TokenCoordinator,
walletReveal: WalletRevealCoordinator,
) : BaseViewModel<WalletViewModel.State, WalletViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -119,13 +128,30 @@ 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
data object PresentDepositOptions: Event
}

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<Event.OnRevealDisplayed>()
.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)) }
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -60,6 +66,7 @@ class BalanceViewModelTest {
chatCoordinator = chatCoordinator,
feedCoordinator = feedCoordinator,
tokenCoordinator = tokenCoordinator,
walletReveal = walletReveal,
)

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -66,6 +71,7 @@ class WalletMilestoneGatingTest {
chatCoordinator = chatCoordinator,
feedCoordinator = feedCoordinator,
tokenCoordinator = tokenCoordinator,
walletReveal = walletReveal,
)

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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))
}
}
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ interface BillOperations {
val billState: StateFlow<BillState>
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 {
Expand Down
Loading
Loading