diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index eebf78ffa..a22a5527c 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -225,6 +225,7 @@ internal fun CurrencyInfoContentV2( isHeld = isHeld, tokenMint = token.address, shortfall = shortfall, + convertEnabled = state.canConvert, dispatch = dispatch, ) } @@ -349,6 +350,7 @@ private fun CurrencyActionTiles( isHeld: Boolean, tokenMint: Mint, shortfall: Fiat?, + convertEnabled: Boolean, dispatch: (TokenInfoViewModel.Event) -> Unit, modifier: Modifier = Modifier, ) { @@ -387,6 +389,7 @@ private fun CurrencyActionTiles( ) }, onClick = { dispatch(TokenInfoViewModel.Event.OnConvert) }, + enabled = convertEnabled, ) } @@ -462,19 +465,23 @@ private fun CurrencyActionTiles( } } +private const val DisabledActionTileAlpha = 0.38f + @Composable private fun ActionTile( label: String, icon: @Composable () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Column( modifier = modifier + .alpha(if (enabled) 1f else DisabledActionTileAlpha) .height(CodeTheme.dimens.staticGrid.x18) .clip(CodeTheme.shapes.extraSmall) .background(Color.White.copy(alpha = 0.1f)) - .clickable(onClick = onClick), + .clickable(enabled = enabled, onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 1ef2eb8a3..cbaa12dd2 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -852,7 +852,12 @@ class SwapViewModel @Inject constructor( ) { balances, rate -> if (destination == source) { val fallback = if (source == Mint.usdf) { - balances.filter { it.token.address != source } + // Same candidates as the destination picker: held currencies with a + // displayable balance. The coordinator keeps zero-balance rows for + // accounts it no longer sees, and the server rejects a buy into one. + balances.filter { + it.token.address != source && it.balance.hasDisplayableValue + } .maxByOrNull { it.balance }?.token?.address } else { Mint.usdf @@ -1186,11 +1191,16 @@ class SwapViewModel @Inject constructor( .filterIsInstance() .onEach { dispatchEvent(Event.UpdateSellState(loading = true)) } .mapNotNull { event -> - val owner = userManager.accountCluster ?: return@mapNotNull null - stateFlow.value.purpose as? SwapPurpose.Convert ?: return@mapNotNull null - val source = stateFlow.value.tokenWithBalance?.token ?: return@mapNotNull null + val owner = userManager.accountCluster + val source = stateFlow.value.tokenWithBalance?.token val destination = stateFlow.value.destinationTokenWithBalance?.token - ?: return@mapNotNull null + if (owner == null || stateFlow.value.purpose !is SwapPurpose.Convert || + source == null || destination == null + ) { + // Nothing to submit; drop the spinner the confirm tap turned on. + dispatchEvent(Event.UpdateSellState(loading = false)) + return@mapNotNull null + } Triple(owner, source to destination, event.amount) } .onEach { (owner, tokens, amount) -> 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 7f2fca304..36346d02d 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 @@ -78,6 +78,15 @@ class TokenInfoViewModel @Inject constructor( val isCashReserve: Boolean get() = token.dataOrNull?.address == Mint.usdf + + /** + * Whether Convert has somewhere to go. Out of any other currency, Dollars is always a valid + * destination. Out of Dollars, the user needs another currency with a displayable balance: + * the same `hasDisplayableValue` filter the swap screen uses to pick a default destination. + * [fundableBalanceMints] is empty until balances load, so Convert from Dollars starts off. + */ + val canConvert: Boolean + get() = mint != Mint.usdf || hasFundableBalance } sealed interface Event { @@ -341,8 +350,9 @@ class TokenInfoViewModel @Inject constructor( .filterIsInstance() .onEach { val mint = stateFlow.value.mint ?: return@onEach - // No gate, matching iOS: the tile only shows for a held currency, and the - // destination needs no balance of its own. + // The tile is disabled when this is false; checked again here so nothing else can + // open a Convert out of Dollars with no destination. + if (!stateFlow.value.canConvert) return@onEach // Dollars is the default landing spot; when Dollars *is* the source the swap // view model substitutes the user's largest other holding. diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index c21e85138..ff827ec4c 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -518,4 +518,67 @@ class SwapViewModelErrorTest { // Rate.ignore would convert this to ~0 and clear every ceiling. Fail closed instead. assertTrue(vm.checkFundingAmount()) } + + private fun held(mint: Mint, usd: Double): TokenWithBalance { + val token = mockk(relaxed = true) { every { address } returns mint } + return mockk(relaxed = true) { + every { this@mockk.token } returns token + every { this@mockk.balance } returns Fiat(usd) + } + } + + private fun convertFromDollars(balances: List): SwapViewModel { + every { tokenCoordinator.tokenBalances } returns MutableStateFlow(balances) + // The destination resolver waits on a rate; a relaxed mock's flow never emits. + every { exchange.observePreferredRate() } returns + MutableStateFlow(Rate(fx = 1.0, currency = CurrencyCode.USD)) + val vm = createViewModel() + vm.dispatchEvent( + SwapViewModel.Event.OnPurposeChanged( + SwapPurpose.Convert(mint = Mint.usdf, destinationMint = Mint.usdf) + ) + ) + return vm + } + + @Test + fun `converting from Dollars defaults to the largest other holding`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val small = Mint(ByteArray(32) { 2 }.toList()) + val large = Mint(ByteArray(32) { 3 }.toList()) + val vm = convertFromDollars( + listOf(held(Mint.usdf, 5.0), held(small, 1.0), held(large, 3.0)) + ) + advanceUntilIdle() + + val purpose = assertIs(vm.stateFlow.value.purpose) + assertEquals(large, purpose.destinationMint) + } + + @Test + fun `converting from Dollars never defaults to an empty holding`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + // A zero-balance row the coordinator kept after the account left the server's list. + val stale = Mint(ByteArray(32) { 4 }.toList()) + val vm = convertFromDollars(listOf(held(Mint.usdf, 5.0), held(stale, 0.0))) + advanceUntilIdle() + + val purpose = assertIs(vm.stateFlow.value.purpose) + assertEquals(Mint.usdf, purpose.destinationMint) + } + + @Test + fun `confirming a conversion without a destination clears the loading state`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = convertFromDollars(listOf(held(Mint.usdf, 5.0))) + advanceUntilIdle() + + vm.dispatchEvent(SwapViewModel.Event.OnConvertConfirmed) + advanceUntilIdle() + + assertFalse(vm.stateFlow.value.sellProgress.loading) + } } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt new file mode 100644 index 000000000..ce80126f5 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt @@ -0,0 +1,165 @@ +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.app.core.AppRoute +import com.flipcash.app.core.tokens.SwapPurpose +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.opencode.model.financial.TokenWithBalance +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.TestScope +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 +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Convert on the Dollars screen is enabled only when the user holds another currency with a + * displayable balance. Bugsnag 6ac36f88991833fd09434aad: a Dollars-only user reached the convert + * flow with a leftover zero-balance USDC row as the only "other" holding. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TokenInfoViewModelConvertTest { + + @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 balances = MutableStateFlow>(emptyList()) + + private val other = Mint(ByteArray(32) { 2 }.toList()) + + private fun held(mint: Mint, usd: Double): TokenWithBalance { + val token = mockk(relaxed = true) { every { address } returns mint } + return mockk(relaxed = true) { + every { this@mockk.token } returns token + every { this@mockk.balance } returns Fiat(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 balances + 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(Mint.usdf, other).forEach { mint -> + val token = mockk(relaxed = true) { every { address } returns mint } + every { tokenCoordinator.cachedToken(mint) } returns token + coEvery { tokenCoordinator.getTokenMetadata(mint) } returns + Result.success(TokenResult(token, DataSource.Cache)) + } + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun TestScope.openToken(mint: Mint): TokenInfoViewModel { + 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(mint)) + advanceUntilIdle() + return vm + } + + private fun TestScope.openedRoutes(vm: TokenInfoViewModel): List { + val routes = mutableListOf() + vm.eventFlow.filterIsInstance() + .onEach { routes += it.screen } + .launchIn(backgroundScope) + return routes + } + + @Test + fun `Convert from Dollars is disabled while balances load`() = runTest(mainCoroutineRule.dispatcher) { + val vm = openToken(Mint.usdf) + assertFalse(vm.stateFlow.value.canConvert) + } + + @Test + fun `Convert from Dollars is disabled when only Dollars is held`() = runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0)) + val vm = openToken(Mint.usdf) + val routes = openedRoutes(vm) + + assertFalse(vm.stateFlow.value.canConvert) + vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert) + advanceUntilIdle() + assertTrue(routes.isEmpty(), routes.toString()) + } + + @Test + fun `Convert from Dollars is disabled when the only other holding is empty`() = + runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0), held(other, 0.0)) + val vm = openToken(Mint.usdf) + assertFalse(vm.stateFlow.value.canConvert) + } + + @Test + fun `Convert from Dollars is enabled when another currency has a balance`() = + runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0), held(other, 2.0)) + val vm = openToken(Mint.usdf) + val routes = openedRoutes(vm) + + assertTrue(vm.stateFlow.value.canConvert) + vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert) + advanceUntilIdle() + val route = assertIs(routes.single()) + assertEquals(SwapPurpose.Convert(mint = Mint.usdf, destinationMint = Mint.usdf), route.purpose) + } + + @Test + fun `Convert from another currency stays enabled with only Dollars held`() = + runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0)) + val vm = openToken(other) + assertTrue(vm.stateFlow.value.canConvert) + } +}