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..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 @@ -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) @@ -270,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) @@ -369,7 +382,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/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()) } + } +} 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()) + } }