From 7c13013c4014ce05285d4d0f5830de2c683e8d3c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 12:53:21 -0400 Subject: [PATCH 1/2] refactor(tokens): replace Token.Info's origin flags with TokenInfoEntry Token.Info carried four parameters that each encoded where the screen was opened from: shortfall, fromDeeplink, asPush and returnAfterBuy. Nothing stopped a caller from combining them in ways that mean nothing, such as a deeplink that returns after a buy. TokenInfoEntry names the origin instead (Wallet, Deeplink, Discovery, Chat, ChatGate), and each origin decides whether the screen is pushed and whether a successful buy pops back. Analytics sources are unchanged: Deeplink still reports Deeplink, and every other origin reports Wallet, as before. shortfall is dropped rather than modelled as a Give origin. Nothing has passed it since 6419a95da, when Give's shortfall started going straight to Token.Swap. AppContent still matches the scene key's toString(), now on the entry name instead of "asPush=true". --- .../app/internal/ui/navigation/AppContent.kt | 17 ++++-- .../ui/navigation/AppScreenContent.kt | 2 +- .../navigation/BuildNavGraphForLaunchTest.kt | 3 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 12 +---- .../app/core/tokens/TokenInfoEntry.kt | 53 +++++++++++++++++++ .../app/discovery/TokenDiscoveryScreen.kt | 3 +- .../internal/screens/MessengerScreen.kt | 4 +- .../flipcash/app/scanner/internal/Scanner.kt | 3 +- .../flipcash/app/tokens/TokenInfoScreen.kt | 25 +++++---- .../flipcash/app/router/internal/AppRouter.kt | 3 +- .../app/router/internal/AppRouterTest.kt | 3 +- .../app/router/internal/NavigateToTest.kt | 5 +- .../app/router/internal/ResolveRoutesTest.kt | 9 ++-- 13 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 760ab37a94..0e23659add 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -35,6 +35,7 @@ import com.flipcash.app.core.navigation.TabBarVisibilityController import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.ui.transitions.CardExpandTransition +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.internal.ui.AppNavigationBar import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBillOverlayEntryDecorator import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBlockingOverlayEntryDecorator @@ -57,11 +58,21 @@ import dev.theolm.rinku.DeepLink private fun isTokenInfoKey(key: Any?): Boolean { val s = key?.toString() ?: return false // The bespoke fade-in-place card-expand transition is only for the wallet/balance presentation. - // A drill-in push (asPush=true, e.g. from token discovery) is an ordinary stack push, so let it fall - // through to the default horizontal slide (and slide-back on pop / predictive-pop). - return s.startsWith("Info(") && s.contains("mint=") && !s.contains("asPush=true") + // A drill-in push (TokenInfoEntry.asPush, e.g. from token discovery) is an ordinary stack push, so + // let it fall through to the default horizontal slide (and slide-back on pop / predictive-pop). + return s.startsWith("Info(") && s.contains("mint=") && pushedTokenInfoEntries.none { s.contains(it) } } +/** The `entry=…` fragments of an [AppRoute.Token.Info] key whose entry is presented as a push. */ +private val pushedTokenInfoEntries: List = + listOf( + TokenInfoEntry.Wallet, + TokenInfoEntry.Deeplink, + TokenInfoEntry.Discovery, + TokenInfoEntry.Chat, + TokenInfoEntry.ChatGate, + ).filter { it.asPush }.map { "entry=$it" } + /** * True when a scene key belongs to [AppRoute.Sheets.Give] (the give/cash screen, which is pushed * rather than presented as a sheet). Same stringified-key match as [isTokenInfoKey]. diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 41a7429817..f3e67ebf0b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -126,7 +126,7 @@ fun appEntryProvider( // Tokens annotatedEntry(testTag = "token_info_screen") { key -> - TokenInfoScreen(key.mint, key.shortfall, key.fromDeeplink, key.asPush, key.returnAfterBuy) + TokenInfoScreen(key.mint, key.entry) } annotatedEntry(testTag = "transaction_history_screen") { key -> TransactionHistoryScreen(key.mint) diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index 873fe5e601..3027814154 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.router.Router import com.flipcash.services.user.AuthState import dev.theolm.rinku.DeepLink @@ -194,7 +195,7 @@ class BuildNavGraphForLaunchTest { fun `token deeplink pushes token info onto the Wallet tab without a sheet`() { val result = buildReady( DeeplinkAction.Navigate( - listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink)) ) ) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 4cbeca5a4c..91894db4e8 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -16,6 +16,7 @@ import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.navigation.SteppedFlowRoute import com.flipcash.app.core.userprofile.UpdateProfileResult @@ -210,16 +211,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class Info( val mint: Mint, - val shortfall: Fiat? = null, - val fromDeeplink: Boolean = false, - // A normal stack PUSH (slide in, back arrow) rather than the wallet card-expand presentation - // (fade-in-place, ✕ dismiss). Set when the screen is reached by drilling in from a list — e.g. - // token discovery — where a back arrow that slides back is the expected navigation. - val asPush: Boolean = false, - // Pop back to whatever opened this screen once a buy of [mint] succeeds, rather than - // staying here. Set by a chat's gate: the reader came to buy their way into the group, - // so the purchase finishing is the moment to put them back in front of Join. - val returnAfterBuy: Boolean = false, + val entry: TokenInfoEntry = TokenInfoEntry.Wallet, ) : Token @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt new file mode 100644 index 0000000000..22e00cd58d --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt @@ -0,0 +1,53 @@ +package com.flipcash.app.core.tokens + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Where a currency-info screen was opened from. The origin decides how the screen is presented + * and what a successful buy of its currency does. + */ +@Serializable +@Parcelize +sealed interface TokenInfoEntry : Parcelable { + /** + * A normal stack push (slide in, back arrow) rather than the wallet card-expand presentation + * (fade-in-place, ✕ dismiss). True when the screen is drilled into from a list or transcript, + * where there is no card for it to grow from. + */ + val asPush: Boolean get() = false + + /** Pop back to whatever opened this screen once a buy of its currency succeeds. */ + val returnAfterBuy: Boolean get() = false + + /** The wallet, or any route that lands on a currency without a more specific origin. */ + @Serializable + data object Wallet : TokenInfoEntry + + /** A token deeplink, opened or scanned. */ + @Serializable + data object Deeplink : TokenInfoEntry + + /** Token discovery's list. */ + @Serializable + data object Discovery : TokenInfoEntry { + override val asPush: Boolean get() = true + } + + /** A currency mentioned in a chat transcript. */ + @Serializable + data object Chat : TokenInfoEntry { + override val asPush: Boolean get() = true + } + + /** + * A group chat's buy-in gate. The reader came to buy their way into the group, so the + * purchase finishing is the moment to put them back in front of Join. + */ + @Serializable + data object ChatGate : TokenInfoEntry { + override val asPush: Boolean get() = true + override val returnAfterBuy: Boolean get() = true + } +} diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt index 93128ae817..3dbd5156f0 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.discovery.internal.TokenDiscoveryScreen import com.flipcash.app.discovery.internal.TokenDiscoveryViewModel import com.flipcash.core.R @@ -54,7 +55,7 @@ private fun TokenDiscoveryEventHandler(viewModel: TokenDiscoveryViewModel, navig viewModel.eventFlow .filterIsInstance() .map { it.mint } - .onEach { navigator.navigate(AppRoute.Token.Info(it, asPush = true)) } + .onEach { navigator.navigate(AppRoute.Token.Info(it, TokenInfoEntry.Discovery)) } .launchIn(this) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 45fa0c2baf..f7dd700cc1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -16,6 +16,7 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.ChatTopBar @@ -75,8 +76,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // is no card here for it to grow from. AppRoute.Token.Info( action.mint, - asPush = true, - returnAfterBuy = action.returnAfterBuy, + if (action.returnAfterBuy) TokenInfoEntry.ChatGate else TokenInfoEntry.Chat, ) ) ) diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index c4e94487e1..d48645eb35 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -28,6 +28,7 @@ import com.flipcash.app.core.AppRoute.Token.* import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.ScannableContainer import com.flipcash.app.session.CodeScanEvent @@ -110,7 +111,7 @@ internal fun Scanner() { val routes: List = when (deeplink) { is DeeplinkType.TokenInfo -> listOf( AppRoute.Sheets.Wallet, - Info(deeplink.mint, fromDeeplink = true) + Info(deeplink.mint, TokenInfoEntry.Deeplink) ) // Scanned tip-DM code — same destination as the // /tip/chat/{id} deeplink. diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 44734d4fc2..5a18615805 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -37,6 +37,7 @@ import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.tokens.internal.TokenInfoScreen import com.flipcash.app.tokens.internal.components.info.CurrencyInfoTitlePill import com.flipcash.app.tokens.ui.TokenInfoViewModel @@ -45,7 +46,6 @@ import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.navigateForResult -import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults @@ -64,10 +64,7 @@ import kotlinx.coroutines.flow.onEach @Composable fun TokenInfoScreen( mint: Mint, - shortFall: Fiat?, - fromDeeplink: Boolean, - asPush: Boolean = false, - returnAfterBuy: Boolean = false, + entry: TokenInfoEntry, ) { val navigator = LocalCodeNavigator.current val analytics = rememberAnalytics() @@ -109,7 +106,7 @@ fun TokenInfoScreen( // Currency-info is a modal dismiss, not a true back nav — lead with a close (✕). But when // it was PUSHED onto the stack (e.g. drilled into from token discovery) it IS a back nav, so // lead with a back arrow instead. - leadingDismiss = !asPush, + leadingDismiss = !entry.asPush, hazeState = haze, endContent = { state.token.dataOrNull?.let { @@ -162,7 +159,7 @@ fun TokenInfoScreen( ) { topPadding -> TokenInfoScreen( viewModel = viewModel, - shortfall = shortFall, + shortfall = null, listState = listState, contentPadding = PaddingValues( top = topPadding, @@ -173,16 +170,18 @@ fun TokenInfoScreen( } LaunchedEffect(Unit) { - val source = when { - shortFall != null -> Analytics.TokenInfoSource.Give - fromDeeplink -> Analytics.TokenInfoSource.Deeplink - else -> Analytics.TokenInfoSource.Wallet + val source = when (entry) { + TokenInfoEntry.Deeplink -> Analytics.TokenInfoSource.Deeplink + TokenInfoEntry.Wallet, + TokenInfoEntry.Discovery, + TokenInfoEntry.Chat, + TokenInfoEntry.ChatGate -> Analytics.TokenInfoSource.Wallet } analytics.openTokenInfo(source = source, mint = mint) } LaunchedEffect(Unit) { - viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, shortFall)) + viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, null)) } LaunchedEffect(viewModel) { @@ -209,7 +208,7 @@ fun TokenInfoScreen( // still has this purchase to make. is SwapResult.Success -> { val bought = (screen.purpose as? SwapPurpose.Buy)?.mint - if (returnAfterBuy && bought == mint) navigator.pop() + if (entry.returnAfterBuy && bought == mint) navigator.pop() } else -> Unit } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 6379135b91..9411ffd1bc 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -13,6 +13,7 @@ import com.flipcash.app.core.navigation.fragments import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.router.Router import com.flipcash.app.router.internal.AppRouter.Companion.cashLink import com.flipcash.app.router.internal.AppRouter.Companion.chat @@ -119,7 +120,7 @@ internal class AppRouter( mint = type.mint, routes = listOf( AppRoute.Sheets.Wallet, - AppRoute.Token.Info(type.mint, fromDeeplink = true), + AppRoute.Token.Info(type.mint, TokenInfoEntry.Deeplink), ), ) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index 2c0887f17d..6914edec2e 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -7,6 +7,7 @@ import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.util.Linkify import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.AuthState import com.getcode.opencode.model.core.ID @@ -341,7 +342,7 @@ class AppRouterTest { val tokenInfo = action.routes[1] assertIs(tokenInfo) assertEquals(Mint(mint), tokenInfo.mint) - assertTrue(tokenInfo.fromDeeplink) + assertEquals(TokenInfoEntry.Deeplink, tokenInfo.entry) } // endregion diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt index e847c5c577..9ae91d888b 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt @@ -6,6 +6,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.extensions.resolveRoutes +import com.flipcash.app.core.tokens.TokenInfoEntry import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.EmptyCodeNavigator import com.getcode.navigation.core.NavOptions @@ -167,12 +168,12 @@ class NavigateToTest { AppRoute.Main.Scanner, AppRoute.Main.Sheet( AppRoute.Sheets.ActivityHistory, - listOf(AppRoute.Token.Info(mint, fromDeeplink = true)), + listOf(AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink)), ), ) navigator.navigateAll( - listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint, fromDeeplink = true)), + listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink)), options = quietOptions, ) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt index d236289719..93e7cfd542 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt @@ -6,6 +6,7 @@ import com.flipcash.app.core.extensions.resolveBackStack import com.flipcash.app.core.extensions.resolveRoutes import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint import org.junit.runner.RunWith @@ -53,7 +54,7 @@ class ResolveRoutesTest { fun `sheet with inner routes bundles into Main Sheet`() { val routes = listOf( AppRoute.Sheets.ActivityHistory, - AppRoute.Token.Info(mint, fromDeeplink = true), + AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink), ) val resolved = resolveRoutes(routes) @@ -113,7 +114,7 @@ class ResolveRoutesTest { fun `resolved routes are structurally equal when inputs match`() { val routes = listOf( AppRoute.Sheets.ActivityHistory, - AppRoute.Token.Info(mint, fromDeeplink = true), + AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink), ) val resolved1 = resolveRoutes(routes) @@ -141,7 +142,7 @@ class ResolveRoutesTest { fun `wallet tab stays flat and token info pushes on top`() { val routes = listOf( AppRoute.Sheets.Wallet, - AppRoute.Token.Info(mint, fromDeeplink = true), + AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink), ) val resolved = resolveRoutes(routes) @@ -215,7 +216,7 @@ class ResolveRoutesTest { @Test fun `a token deeplink lands on the wallet tab exactly once`() { val base = listOf(AppRoute.Sheets.Wallet) - val deeplink = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) + val deeplink = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink)) val stack = resolveBackStack(base, deeplink) assertEquals(2, stack.size) From d89f6cb63f651d2271462d85057450f84a551517 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 12:57:17 -0400 Subject: [PATCH 2/2] refactor(tokens): have TokenInfoEntry state what the screen does after a swap The entries exposed asPush and returnAfterBuy as booleans with defaults, so reading an origin didn't tell you how it behaves, and TokenInfoScreen still decided inline, untested, whether a buy should pop. Each origin now declares its presentation (CardExpand or Push) and afterBuy (Stay or ReturnToOpener) with no defaults. afterSwap() in core turns the entry, the swap purpose and its result into Stay, Pop or OpenDeposit, and the screen only executes that. The rule that only a buy of this currency counts, not the USDF top-up from Add Money, moves with it. TokenInfoEntryTest covers the presentation and after-buy table for every origin and checks TokenInfoEntry.all against the serializer's subclass list. AppContent now reads that list to pick the transition, so an origin left out of it would have got the card-expand transition without anyone noticing. --- .../app/internal/ui/navigation/AppContent.kt | 15 ++-- .../app/core/tokens/TokenInfoEntry.kt | 85 +++++++++++++++---- .../app/core/tokens/TokenInfoEntryTest.kt | 80 +++++++++++++++++ .../flipcash/app/tokens/TokenInfoScreen.kt | 20 ++--- 4 files changed, 162 insertions(+), 38 deletions(-) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 0e23659add..629ee3e2c6 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -36,6 +36,7 @@ import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.ui.transitions.CardExpandTransition import com.flipcash.app.core.tokens.TokenInfoEntry +import com.flipcash.app.core.tokens.TokenInfoPresentation import com.flipcash.app.internal.ui.AppNavigationBar import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBillOverlayEntryDecorator import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBlockingOverlayEntryDecorator @@ -58,20 +59,16 @@ import dev.theolm.rinku.DeepLink private fun isTokenInfoKey(key: Any?): Boolean { val s = key?.toString() ?: return false // The bespoke fade-in-place card-expand transition is only for the wallet/balance presentation. - // A drill-in push (TokenInfoEntry.asPush, e.g. from token discovery) is an ordinary stack push, so - // let it fall through to the default horizontal slide (and slide-back on pop / predictive-pop). + // A drill-in push (TokenInfoPresentation.Push, e.g. from token discovery) is an ordinary stack push, + // so let it fall through to the default horizontal slide (and slide-back on pop / predictive-pop). return s.startsWith("Info(") && s.contains("mint=") && pushedTokenInfoEntries.none { s.contains(it) } } /** The `entry=…` fragments of an [AppRoute.Token.Info] key whose entry is presented as a push. */ private val pushedTokenInfoEntries: List = - listOf( - TokenInfoEntry.Wallet, - TokenInfoEntry.Deeplink, - TokenInfoEntry.Discovery, - TokenInfoEntry.Chat, - TokenInfoEntry.ChatGate, - ).filter { it.asPush }.map { "entry=$it" } + TokenInfoEntry.all + .filter { it.presentation == TokenInfoPresentation.Push } + .map { "entry=$it" } /** * True when a scene key belongs to [AppRoute.Sheets.Give] (the give/cash screen, which is pushed diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt index 22e00cd58d..3fcc0b9cde 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt @@ -1,44 +1,47 @@ package com.flipcash.app.core.tokens import android.os.Parcelable +import com.getcode.solana.keys.Mint import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable /** - * Where a currency-info screen was opened from. The origin decides how the screen is presented - * and what a successful buy of its currency does. + * Where a currency-info screen was opened from. Each origin states how the screen is presented + * and what it does once a buy of its currency succeeds; [afterSwap] turns that into the step the + * screen takes when a swap it launched returns. */ @Serializable @Parcelize sealed interface TokenInfoEntry : Parcelable { - /** - * A normal stack push (slide in, back arrow) rather than the wallet card-expand presentation - * (fade-in-place, ✕ dismiss). True when the screen is drilled into from a list or transcript, - * where there is no card for it to grow from. - */ - val asPush: Boolean get() = false - - /** Pop back to whatever opened this screen once a buy of its currency succeeds. */ - val returnAfterBuy: Boolean get() = false + val presentation: TokenInfoPresentation + val afterBuy: AfterBuy /** The wallet, or any route that lands on a currency without a more specific origin. */ @Serializable - data object Wallet : TokenInfoEntry + data object Wallet : TokenInfoEntry { + override val presentation get() = TokenInfoPresentation.CardExpand + override val afterBuy get() = AfterBuy.Stay + } /** A token deeplink, opened or scanned. */ @Serializable - data object Deeplink : TokenInfoEntry + data object Deeplink : TokenInfoEntry { + override val presentation get() = TokenInfoPresentation.CardExpand + override val afterBuy get() = AfterBuy.Stay + } /** Token discovery's list. */ @Serializable data object Discovery : TokenInfoEntry { - override val asPush: Boolean get() = true + override val presentation get() = TokenInfoPresentation.Push + override val afterBuy get() = AfterBuy.Stay } /** A currency mentioned in a chat transcript. */ @Serializable data object Chat : TokenInfoEntry { - override val asPush: Boolean get() = true + override val presentation get() = TokenInfoPresentation.Push + override val afterBuy get() = AfterBuy.Stay } /** @@ -47,7 +50,55 @@ sealed interface TokenInfoEntry : Parcelable { */ @Serializable data object ChatGate : TokenInfoEntry { - override val asPush: Boolean get() = true - override val returnAfterBuy: Boolean get() = true + override val presentation get() = TokenInfoPresentation.Push + override val afterBuy get() = AfterBuy.ReturnToOpener } + + companion object { + /** Every origin. Covered by a test against the serializer's subclass list. */ + val all: List = listOf(Wallet, Deeplink, Discovery, Chat, ChatGate) + } +} + +enum class TokenInfoPresentation { + /** The wallet card grows into the screen in place; the screen leads with ✕. */ + CardExpand, + + /** An ordinary stack push: slides in, leads with a back arrow. */ + Push, } + +enum class AfterBuy { + /** Stay on the currency's info screen. */ + Stay, + + /** Pop back to whatever opened the screen. */ + ReturnToOpener, +} + +/** The step a currency-info screen takes when a swap it launched returns. */ +enum class TokenInfoSwapOutcome { + Stay, + Pop, + OpenDeposit, +} + +/** + * The step to take when a swap launched from the info screen for [mint] returns [result]. + * + * [afterBuy] applies only to a buy of [mint] itself. Add Money from this screen also ends in a + * successful buy, but of USDF to fund this one, and the reader still has this purchase to make. + */ +fun TokenInfoEntry.afterSwap(mint: Mint, purpose: SwapPurpose, result: SwapResult): TokenInfoSwapOutcome = + when (result) { + SwapResult.OpenDeposit -> TokenInfoSwapOutcome.OpenDeposit + SwapResult.Canceled -> TokenInfoSwapOutcome.Stay + is SwapResult.Success -> { + val boughtThisCurrency = purpose is SwapPurpose.Buy && purpose.mint == mint + when { + !boughtThisCurrency -> TokenInfoSwapOutcome.Stay + afterBuy == AfterBuy.ReturnToOpener -> TokenInfoSwapOutcome.Pop + else -> TokenInfoSwapOutcome.Stay + } + } + } diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt new file mode 100644 index 0000000000..7eb2c4ba57 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt @@ -0,0 +1,80 @@ +package com.flipcash.app.core.tokens + +import com.getcode.solana.keys.Mint +import kotlinx.serialization.descriptors.elementNames +import kotlin.test.Test +import kotlin.test.assertEquals + +class TokenInfoEntryTest { + + private val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + private val buyThis = SwapPurpose.Buy(mint) + private val success = SwapResult.Success() + + @Test + fun `all lists every origin`() { + // AppContent picks the transition from TokenInfoEntry.all, so an origin missing from it + // would silently get the card-expand transition. + val subclasses = TokenInfoEntry.serializer().descriptor.getElementDescriptor(1).elementNames.toSet() + assertEquals(subclasses, TokenInfoEntry.all.map { it::class.qualifiedName }.toSet()) + } + + @Test + fun `wallet and deeplink expand the card, list and chat origins push`() { + assertEquals( + mapOf( + TokenInfoEntry.Wallet to TokenInfoPresentation.CardExpand, + TokenInfoEntry.Deeplink to TokenInfoPresentation.CardExpand, + TokenInfoEntry.Discovery to TokenInfoPresentation.Push, + TokenInfoEntry.Chat to TokenInfoPresentation.Push, + TokenInfoEntry.ChatGate to TokenInfoPresentation.Push, + ), + TokenInfoEntry.all.associateWith { it.presentation }, + ) + } + + @Test + fun `buying this currency returns to the opener only from a chat gate`() { + assertEquals( + mapOf( + TokenInfoEntry.Wallet to TokenInfoSwapOutcome.Stay, + TokenInfoEntry.Deeplink to TokenInfoSwapOutcome.Stay, + TokenInfoEntry.Discovery to TokenInfoSwapOutcome.Stay, + TokenInfoEntry.Chat to TokenInfoSwapOutcome.Stay, + TokenInfoEntry.ChatGate to TokenInfoSwapOutcome.Pop, + ), + TokenInfoEntry.all.associateWith { it.afterSwap(mint, buyThis, success) }, + ) + } + + @Test + fun `adding money from a chat gate stays, since this currency is still unbought`() { + assertEquals( + TokenInfoSwapOutcome.Stay, + TokenInfoEntry.ChatGate.afterSwap(mint, SwapPurpose.Buy(Mint.usdf), success), + ) + } + + @Test + fun `selling or converting from a chat gate stays`() { + assertEquals(TokenInfoSwapOutcome.Stay, TokenInfoEntry.ChatGate.afterSwap(mint, SwapPurpose.Sell(mint), success)) + assertEquals( + TokenInfoSwapOutcome.Stay, + TokenInfoEntry.ChatGate.afterSwap(mint, SwapPurpose.Convert(mint, Mint.usdf), success), + ) + } + + @Test + fun `a canceled buy stays`() { + TokenInfoEntry.all.forEach { + assertEquals(TokenInfoSwapOutcome.Stay, it.afterSwap(mint, buyThis, SwapResult.Canceled)) + } + } + + @Test + fun `open deposit opens deposit from every origin`() { + TokenInfoEntry.all.forEach { + assertEquals(TokenInfoSwapOutcome.OpenDeposit, it.afterSwap(mint, buyThis, SwapResult.OpenDeposit)) + } + } +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 5a18615805..b3beef5b68 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -35,9 +35,11 @@ import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.TokenInfoEntry +import com.flipcash.app.core.tokens.TokenInfoPresentation +import com.flipcash.app.core.tokens.TokenInfoSwapOutcome +import com.flipcash.app.core.tokens.afterSwap import com.flipcash.app.tokens.internal.TokenInfoScreen import com.flipcash.app.tokens.internal.components.info.CurrencyInfoTitlePill import com.flipcash.app.tokens.ui.TokenInfoViewModel @@ -106,7 +108,7 @@ fun TokenInfoScreen( // Currency-info is a modal dismiss, not a true back nav — lead with a close (✕). But when // it was PUSHED onto the stack (e.g. drilled into from token discovery) it IS a back nav, so // lead with a back arrow instead. - leadingDismiss = !entry.asPush, + leadingDismiss = entry.presentation == TokenInfoPresentation.CardExpand, hazeState = haze, endContent = { state.token.dataOrNull?.let { @@ -200,17 +202,11 @@ fun TokenInfoScreen( is AppRoute.Token.Swap -> { navigator.navigateForResult(screen) { result -> if (result !is NavResultOrCanceled.ReturnValue) return@navigateForResult - when (result.value) { - is SwapResult.OpenDeposit -> + when (entry.afterSwap(mint, screen.purpose, result.value)) { + TokenInfoSwapOutcome.OpenDeposit -> navigator.push(AppRoute.Transfers.Deposit(showOtherOptions = false)) - // Only a buy of this currency. Add Money from here also ends in a - // successful Buy, but of USDF to fund this one, and the reader - // still has this purchase to make. - is SwapResult.Success -> { - val bought = (screen.purpose as? SwapPurpose.Buy)?.mint - if (entry.returnAfterBuy && bought == mint) navigator.pop() - } - else -> Unit + TokenInfoSwapOutcome.Pop -> navigator.pop() + TokenInfoSwapOutcome.Stay -> Unit } } }