From 3f707d8cf541b1296357dd6d6a8287589a4f907a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 11:19:02 -0400 Subject: [PATCH 1/2] fix(tokens): animate market cap period changes and reset the period on each open The currency-info overlay passes animateChartOpen = false to skip the chart's draw-in, but that nulled Vico's animationSpec, which also drives the animation between datasets. Period switches stopped animating. Keep animationSpec and put the flag on initialAnimationSpec, which only covers the first draw. The overlay reuses one TokenInfoViewModel across opens, so the selected period carried over from the last open. OnMintProvided now takes resetChartPeriod, which the overlay sets. The pushed TokenInfoScreen leaves it off because it re-dispatches OnMintProvided when a screen pushed over it pops, and resetting there would drop the user's pick. Chart data is keyed by period only, so a different mint now clears it. The period-selection flow no longer uses distinctUntilChanged: after a reset it would swallow re-picking the previous open's period. The tab row ignores taps on the selected period instead. --- .../app/tokens/CurrencyInfoExpansion.kt | 2 +- .../components/marketcap/MarketCapChart.kt | 10 +++-- .../app/tokens/ui/TokenInfoViewModel.kt | 28 +++++++++++-- .../tokens/ui/TokenInfoViewModelStateTest.kt | 40 +++++++++++++++++++ 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt index 112a1cfea5..bc07dcccc1 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt @@ -111,7 +111,7 @@ fun CurrencyInfoExpansion( val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() LaunchedEffect(mint) { - viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint)) + viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, resetChartPeriod = true)) } // One `Token Info: Opened From …` per open. The flag is saveable because a pushed action (Give / diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt index 2e4bcee993..018fa84c96 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt @@ -202,7 +202,7 @@ private fun MarketCapChart( shape = CodeTheme.shapes.extraSmall ) .clip(CodeTheme.shapes.extraSmall) - .clickable { onPeriodSelected(period) } + .clickable { if (!isSelected) onPeriodSelected(period) } .padding( vertical = CodeTheme.dimens.grid.x2 ), @@ -332,9 +332,11 @@ private fun MarketCapChartContent( modifier = modifier, chart = chart, modelProducer = producer, - // New UI (card-expand overlay) opens with its own transition, so the chart's draw-in animation - // is suppressed there — it just appears with the rest of the detail instead of sweeping up. - animationSpec = if (animateOpen) tween(durationMillis = 300) else null, + // Period changes always animate between curves. Only the draw-in on first model is optional: the + // card-expand overlay opens with its own transition, so there the chart appears with the rest of + // the detail instead of sweeping up. + animationSpec = tween(durationMillis = 300), + initialAnimationSpec = if (animateOpen) tween(durationMillis = 300) else null, scrollState = rememberVicoScrollState(scrollEnabled = false), placeholder = placeholder ) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 6899611489..7f72cbaf13 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -81,7 +81,16 @@ class TokenInfoViewModel @Inject constructor( } sealed interface Event { - data class OnMintProvided(val mint: Mint, val shortFall: Fiat? = null) : Event + /** + * @param resetChartPeriod start the chart back at [Period.All]. The card-expand overlay reuses one VM + * across opens and sets this on each open; the pushed screen gets a fresh VM per entry and leaves it + * off, so returning to it from a pushed screen keeps the period the user picked. + */ + data class OnMintProvided( + val mint: Mint, + val shortFall: Fiat? = null, + val resetChartPeriod: Boolean = false, + ) : Event data class OnTokenChanged(val token: Loadable, val shortFall: Fiat? = null) : Event data class OnMarketCapChanged(val mcap: Fiat?) : Event data class LoadHistoricalDataForPeriod(val period: Period, val evict: Boolean = false) : Event @@ -215,8 +224,10 @@ class TokenInfoViewModel @Inject constructor( eventFlow .filterIsInstance() + // No distinctUntilChanged: OnMintProvided resets the period to All in state, so a stream-level + // filter would swallow re-picking the period chosen on the previous open. The tab row skips + // taps on the already-selected period instead. .map { it.period } - .distinctUntilChanged() .onEach { dispatchEvent(Event.LoadHistoricalDataForPeriod(it)) } .launchIn(viewModelScope) @@ -369,7 +380,18 @@ class TokenInfoViewModel @Inject constructor( val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - is Event.OnMintProvided -> { state -> state.copy(mint = event.mint) } + // Chart data is keyed only by period, so a different mint drops the previous mint's data. + is Event.OnMintProvided -> { state -> + state.copy( + mint = event.mint, + selectedPeriod = if (event.resetChartPeriod) Period.All else state.selectedPeriod, + historicalMarketCapData = if (event.mint == state.mint) { + state.historicalMarketCapData + } else { + emptyMap() + }, + ) + } is Event.OnTokenChanged -> { state -> state.copy(token = event.token) } is Event.OnMarketCapChanged -> { state -> state.copy(marketCap = event.mcap) } is Event.OnBalanceUpdated -> { state -> state.copy(balance = event.balance) } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt index b77907d135..f1a3e7ce85 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt @@ -1,5 +1,8 @@ package com.flipcash.app.tokens.ui +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.tokens.data.MarketCapPoint +import com.flipcash.app.tokens.data.Period import com.getcode.solana.keys.Mint import kotlin.test.Test import kotlin.test.assertEquals @@ -53,4 +56,41 @@ class TokenInfoViewModelStateTest { )(TokenInfoViewModel.State()) assertEquals(setOf(other), state.fundableBalanceMints) } + + // The card-expand overlay reuses one keyless VM across opens, so each open re-points it with + // OnMintProvided. The chart period must not carry over from the previous open. + @Test + fun `OnMintProvided resets the chart period to all time when asked`() { + val state = reduce( + TokenInfoViewModel.Event.OnMintProvided(target, resetChartPeriod = true) + )(TokenInfoViewModel.State(mint = target, selectedPeriod = Period.Week)) + assertEquals(Period.All, state.selectedPeriod) + } + + // The pushed screen re-dispatches OnMintProvided when a screen pushed over it pops. + @Test + fun `OnMintProvided keeps the chart period by default`() { + val state = reduce( + TokenInfoViewModel.Event.OnMintProvided(target) + )(TokenInfoViewModel.State(mint = target, selectedPeriod = Period.Week)) + assertEquals(Period.Week, state.selectedPeriod) + } + + @Test + fun `OnMintProvided keeps the chart data when reopening the same mint`() { + val data = mapOf(Period.All to Loadable.Loaded(listOf(MarketCapPoint(1L, 2.0)))) + val state = reduce( + TokenInfoViewModel.Event.OnMintProvided(target) + )(TokenInfoViewModel.State(mint = target, historicalMarketCapData = data)) + assertEquals(data, state.historicalMarketCapData) + } + + @Test + fun `OnMintProvided drops the previous mint's chart data`() { + val data = mapOf(Period.All to Loadable.Loaded(listOf(MarketCapPoint(1L, 2.0)))) + val state = reduce( + TokenInfoViewModel.Event.OnMintProvided(target) + )(TokenInfoViewModel.State(mint = other, historicalMarketCapData = data)) + assertTrue(state.historicalMarketCapData.isEmpty()) + } } From 80cf133e6745f210c672170e0f8585146cccdd46 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 11:23:00 -0400 Subject: [PATCH 2/2] fix(tokens): load the chart when the next mint has the same market cap The chart's history load fires when the market cap arrives, behind two distinctUntilChanged calls keyed on the market cap alone. The overlay reuses one TokenInfoViewModel across opens, so opening a mint whose market cap equals the previous mint's skipped the load and the chart stayed empty until Retry or a period change. Key both de-dupes on the mint as well. --- .../app/tokens/ui/TokenInfoViewModel.kt | 16 +-- .../ui/TokenInfoViewModelChartLoadTest.kt | 104 ++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 7f72cbaf13..7f2fca304d 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -281,27 +281,29 @@ class TokenInfoViewModel @Inject constructor( dispatchEvent(Event.OnTransactionHistoryEnabled(hasAccount)) }.launchIn(viewModelScope) + // Both de-dupes below key on the mint as well as the market cap: the overlay reuses this VM across + // opens, and two mints can share a market cap, which would otherwise skip the second mint's chart load. eventFlow .filterIsInstance() .map { _ -> - val token = stateFlow.value.token.dataOrNull ?: return@map null - token.marketCap() + val token = stateFlow.value.token.dataOrNull ?: return@map null to null + token.address to token.marketCap() } - .flatMapLatest { mcap -> + .flatMapLatest { (mint, mcap) -> combine( flowOf(mcap), exchange.observePreferredRate(), ) { usdMcap, rate -> - usdMcap?.convertingTo(rate) + mint to usdMcap?.convertingTo(rate) } - }.distinctUntilChanged().onEach { - dispatchEvent(Event.OnMarketCapChanged(it)) + }.distinctUntilChanged().onEach { (_, mcap) -> + dispatchEvent(Event.OnMarketCapChanged(mcap)) } .launchIn(viewModelScope) eventFlow .filterIsInstance() - .mapNotNull { it.mcap } + .mapNotNull { event -> event.mcap?.let { stateFlow.value.mint to it } } .distinctUntilChanged() .onEach { dispatchEvent(Event.LoadHistoricalDataForPeriod(stateFlow.value.selectedPeriod)) } .launchIn(viewModelScope) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt new file mode 100644 index 0000000000..0a12d6ed2f --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt @@ -0,0 +1,104 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.AccountController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.DataSource +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenResult +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * The card-expand overlay reuses one [TokenInfoViewModel] across opens. The chart's history load is + * triggered by the market cap arriving, so it must fire for each mint even when two mints report the + * same market cap. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TokenInfoViewModelChartLoadTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val accountController = mockk(relaxed = true) + + private val mintA = Mint(ByteArray(32) { 1 }.toList()) + private val mintB = Mint(ByteArray(32) { 2 }.toList()) + + private fun token(mint: Mint): Token = mockk(relaxed = true) { + every { address } returns mint + every { marketCap() } returns Fiat(100.0, CurrencyCode.USD) + } + + @Before + fun setUp() { + BottomBarManager.clear() + every { exchange.observePreferredRate() } returns + MutableStateFlow(Rate(fx = 1.0, currency = CurrencyCode.USD)) + every { accountController.observeHasAccountFor(any()) } returns MutableStateFlow(false) + every { tokenCoordinator.tokenBalances } returns MutableStateFlow(emptyList()) + every { tokenCoordinator.balanceForToken(any()) } returns MutableStateFlow(Fiat.Zero) + every { tokenCoordinator.appreciationForToken(any()) } returns MutableStateFlow(Fiat.Zero) + coEvery { tokenCoordinator.getHistoricalMarketCapData(any(), any(), any()) } returns + Result.success(emptyList()) + + listOf(mintA, mintB).forEach { mint -> + val token = token(mint) + every { tokenCoordinator.cachedToken(mint) } returns token + coEvery { tokenCoordinator.getTokenMetadata(mint) } returns + Result.success(TokenResult(token, DataSource.Cache)) + } + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + @Test + fun `opening a second mint with the same market cap loads its chart`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = TokenInfoViewModel( + accountController = accountController, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + shareController = mockk(relaxed = true), + resources = FakeResourceHelper(), + purchaseMethodController = mockk(relaxed = true), + feedCoordinator = mockk(relaxed = true), + dispatchers = TestDispatchers(testScheduler), + ) + + vm.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mintA, resetChartPeriod = true)) + advanceUntilIdle() + coVerify(atLeast = 1) { tokenCoordinator.getHistoricalMarketCapData(mintA, any(), any()) } + + vm.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mintB, resetChartPeriod = true)) + advanceUntilIdle() + coVerify(atLeast = 1) { tokenCoordinator.getHistoricalMarketCapData(mintB, any(), any()) } + } +}