Skip to content
Open
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,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
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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
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.seconds
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,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<Mint?>(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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -293,4 +349,10 @@ 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.
*/
private val EntryRetention = 1.seconds
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
Loading
Loading