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
Expand Up @@ -111,7 +111,7 @@ fun CurrencyInfoExpansion(
val viewModel = hiltViewModel<TokenInfoViewModel>()
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 /
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Token>, val shortFall: Fiat? = null) : Event
data class OnMarketCapChanged(val mcap: Fiat?) : Event
data class LoadHistoricalDataForPeriod(val period: Period, val evict: Boolean = false) : Event
Expand Down Expand Up @@ -215,8 +224,10 @@ class TokenInfoViewModel @Inject constructor(

eventFlow
.filterIsInstance<Event.OnMarketCapPeriodSelected>()
// 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)

Expand Down Expand Up @@ -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<Event.OnBalanceUpdated>()
.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<Event.OnMarketCapChanged>()
.mapNotNull { it.mcap }
.mapNotNull { event -> event.mcap?.let { stateFlow.value.mint to it } }
.distinctUntilChanged()
.onEach { dispatchEvent(Event.LoadHistoricalDataForPeriod(stateFlow.value.selectedPeriod)) }
.launchIn(viewModelScope)
Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TokenCoordinator>(relaxed = true)
private val exchange = mockk<Exchange>(relaxed = true)
private val accountController = mockk<AccountController>(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<Mint>()) } 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<ShareSheetController>(relaxed = true),
resources = FakeResourceHelper(),
purchaseMethodController = mockk<PurchaseMethodController>(relaxed = true),
feedCoordinator = mockk<ActivityFeedCoordinator>(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()) }
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
}
}
Loading