From 9499dddc13654bc539544c45e505d9910390d8c4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 4 Sep 2026 12:06:33 -0400 Subject: [PATCH 1/2] refactor(nav): split AppRoute by how a route arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppRoute.Sheets held three unrelated populations: the four nav-bar tab homes, screens that are only ever pushed (ActivityHistory, TransactionDetails, Give), and actual modals. resolveRoutes wrapped every member in Main.Sheet, so the tab homes needed an asNavBarTab() carve-out to escape and the pushed screens had no escape at all — a deeplink naming one landed in a modal instead of on the screen the rest of the app pushes. Three groupings now say how a route arrives: - Tabs — the four nav-bar homes. navBarButton() is exhaustive over the type, so a new tab home fails to compile until it is given a button. - Sheets — modals that host an inner backstack, which is the only thing the Main.Sheet wrapping buys. A modal needing no nested navigation implements com.getcode.navigation.Sheet directly; that marker, not this grouping, is what ModalBottomSheetSceneStrategy reads. - Main — ordinary pushes. resolveRoutes loses the carve-out and wraps on membership alone. In-app behaviour is unchanged: every entry to the three moved screens already went through navigator.push, and AppRouter only feeds tab homes into navigateAll. A deeplink to one of them now lands on the pushed screen. Route class names change, so a back stack saved by an older build will not restore across the update. Maestro tags derive from simpleName, all of which are preserved. --- .../com/flipcash/app/internal/ui/App.kt | 2 +- .../app/internal/ui/navigation/AppContent.kt | 2 +- .../ui/navigation/AppScreenContent.kt | 36 +++---- .../navigation/BuildNavGraphForLaunchTest.kt | 34 +++---- .../kotlin/com/flipcash/app/core/AppRoute.kt | 99 +++++++++++++------ .../app/core/extensions/CodeNavigator.kt | 21 ++-- .../flipcash/app/core/navigation/HomeRoute.kt | 2 +- .../app/core/navigation/NavBarRoutes.kt | 30 +++--- .../app/core/navigation/HomeRouteTest.kt | 2 +- .../app/core/navigation/NavBarRoutesTest.kt | 10 +- .../balance/internal/WalletScreenContent.kt | 6 +- .../flipcash/app/deposit/DepositFlowScreen.kt | 2 +- .../app/menu/internal/MenuScreenViewModel.kt | 8 +- .../internal/PurchaseAccountScreenContent.kt | 2 +- .../flipcash/app/scanner/internal/Scanner.kt | 6 +- .../flipcash/app/tipping/TippingFlowScreen.kt | 2 +- .../tipping/internal/screens/TipInfoScreen.kt | 4 +- .../components/info/CurrencyInfoContentV2.kt | 4 +- .../app/withdrawal/WithdrawalFlowScreen.kt | 2 +- .../app/bills/decor/PayableDecorator.kt | 2 +- .../app/bills/decor/TipCardDecorator.kt | 2 +- .../NotificationPermissionScreen.kt | 8 +- .../flipcash/app/router/internal/AppRouter.kt | 10 +- .../app/router/internal/AppRouterTest.kt | 16 +-- .../app/router/internal/NavigateToTest.kt | 90 ++++++++--------- .../app/router/internal/ResolveRoutesTest.kt | 72 ++++++++------ .../ActivityHistoryScreen.kt | 2 +- docs/architecture/03-navigation.md | 16 ++- maestro/README.md | 4 +- maestro/subflows/create_account.yaml | 2 +- maestro/subflows/login.yaml | 2 +- maestro/subflows/login_usdf_only.yaml | 2 +- maestro/subflows/login_with_deeplink.yaml | 2 +- 33 files changed, 284 insertions(+), 220 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index e30f8ee7d0..2fe637eb96 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -280,7 +280,7 @@ internal fun App( // gives. Pushing it instead reads as a modal on a stack // the user never navigated. Mirrors iOS // DeepLinkController's requestedCardMint. - codeNavigator.navigateAll(listOf(AppRoute.Sheets.Wallet)) + codeNavigator.navigateAll(listOf(AppRoute.Tabs.Wallet)) cardExpansion.beginExpanded(action.mint) } 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..a10c058c64 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 @@ -63,7 +63,7 @@ private fun isTokenInfoKey(key: Any?): Boolean { } /** - * True when a scene key belongs to [AppRoute.Sheets.Give] (the give/cash screen, which is pushed + * True when a scene key belongs to [AppRoute.Main.Give] (the give/cash screen, which is pushed * rather than presented as a sheet). Same stringified-key match as [isTokenInfoKey]. */ private fun isGiveKey(key: Any?): Boolean = 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 7294ef6f58..069b7003d6 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 @@ -82,32 +82,34 @@ fun appEntryProvider( OnboardingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } - // Main - annotatedEntry { key -> - SheetContent(key, resultStateRegistry, barManager) - } + // Main — pushed onto whatever stack the user is already on annotatedEntry { key -> AppRestrictedScreen(key.restrictionType) } - annotatedEntry { ScannerScreen() } annotatedEntry { RegionSelectionScreen() } - annotatedEntry { key -> InviteContactScreen(key.phoneNumber) } - - // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) // Route type is `Give` but the screen is the Cash/Give screen the flows call cash_screen. - annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo) } - annotatedEntry { key -> - TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo) } + annotatedEntry { ActivityHistoryScreen() } + annotatedEntry { key -> TransactionDetailsScreen(key.id) } + annotatedEntry { key -> InviteContactScreen(key.phoneNumber) } + annotatedEntry { key -> + SheetContent(key, resultStateRegistry, barManager) } - annotatedEntry { key -> TokenSelectScreen(key.purpose) } - annotatedEntry { TipAmountEntryScreen() } - annotatedEntry { + + // Tabs — the nav bar's four homes; entering one replaces the stack + annotatedEntry { ScannerScreen() } + annotatedEntry { // The wallet hosts the card-expand overlay in-entry so a pushed action (Give/Convert/Withdraw) // covers the expanded currency-info with correct z-order (iOS WalletScreen structure). CardExpandHost { WalletScreen() } } + annotatedEntry { key -> + TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } + annotatedEntry { MenuScreen() } + + // Sheets — inner content, wrapped in Main.Sheet by resolveRoutes + annotatedEntry { key -> TokenSelectScreen(key.purpose) } + annotatedEntry { TipAmountEntryScreen() } annotatedEntry { ShareAppScreen() } - annotatedEntry { ActivityHistoryScreen() } - annotatedEntry { key -> TransactionDetailsScreen(key.id) } - annotatedEntry { MenuScreen() } // Messaging annotatedEntry { key -> 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 b8c55865f8..a1b869bf0a 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 @@ -53,16 +53,16 @@ class BuildNavGraphForLaunchTest { @Test fun `logged in without deeplink opens on the Wallet tab`() { val result = build(AuthState.Ready)!! - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.resolvedBackStack()) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.resolvedBackStack()) } @Test fun `logged in with Navigate deeplink includes deeplink routes`() { - val routes = listOf(AppRoute.Main.Scanner) + val routes = listOf(AppRoute.Tabs.Scanner) val result = buildReady(DeeplinkAction.Navigate(routes)) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.baseRoutes) assertEquals(routes, result.deeplinkRoutes) } @@ -70,7 +70,7 @@ class BuildNavGraphForLaunchTest { fun `logged in with OpenCashLink fires eagerly via pendingAction`() { val action = DeeplinkAction.OpenCashLink("testEntropy") val result = buildReady(action) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) assertEquals(action, result.pendingAction) } @@ -79,7 +79,7 @@ class BuildNavGraphForLaunchTest { fun `logged in with Login action fires eagerly via pendingAction`() { val action = DeeplinkAction.Login("seed") val result = buildReady(action) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) assertEquals(action, result.pendingAction) } @@ -90,10 +90,10 @@ class BuildNavGraphForLaunchTest { // no pushed screen, so nothing lands in deeplinkRoutes. See DeeplinkAction.OpenToken. val action = DeeplinkAction.OpenToken( mint = Mint(MINT), - routes = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(Mint(MINT))), + routes = listOf(AppRoute.Tabs.Wallet, AppRoute.Token.Info(Mint(MINT))), ) val result = buildReady(action) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) assertEquals(action, result.pendingAction) } @@ -101,7 +101,7 @@ class BuildNavGraphForLaunchTest { @Test fun `logged in with None action opens the Wallet tab without deeplink routes`() { val result = buildReady(DeeplinkAction.None) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) } @@ -192,13 +192,13 @@ 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.Tabs.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) ) ) val stack = result.resolvedBackStack() assertEquals(2, stack.size) - assertEquals(AppRoute.Sheets.Wallet, stack[0]) + assertEquals(AppRoute.Tabs.Wallet, stack[0]) assertIs(stack[1]) assertTrue(stack.none { it is AppRoute.Main.Sheet }, "a tab home must never be wrapped in a sheet") } @@ -208,7 +208,7 @@ class BuildNavGraphForLaunchTest { val result = buildReady( DeeplinkAction.Navigate( listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Tips(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(1, 2, 3, 4)))), ) ) @@ -216,10 +216,10 @@ class BuildNavGraphForLaunchTest { val stack = result.resolvedBackStack() assertEquals(2, stack.size) - assertIs(stack[0]) + assertIs(stack[0]) assertIs(stack[1]) // The launch home must be replaced by the target tab, not left underneath it. - assertTrue(stack.none { it == AppRoute.Sheets.Wallet }) + assertTrue(stack.none { it == AppRoute.Tabs.Wallet }) assertTrue(stack.none { it is AppRoute.Main.Sheet }) } @@ -228,7 +228,7 @@ class BuildNavGraphForLaunchTest { val result = buildReady( DeeplinkAction.Navigate( listOf( - AppRoute.Sheets.Menu, + AppRoute.Tabs.Menu, AppRoute.Menu.MyAccount, AppRoute.Verification( origin = AppRoute.Menu.MyAccount, @@ -242,7 +242,7 @@ class BuildNavGraphForLaunchTest { val stack = result.resolvedBackStack() assertEquals(3, stack.size) - assertEquals(AppRoute.Sheets.Menu, stack[0]) + assertEquals(AppRoute.Tabs.Menu, stack[0]) assertTrue(stack.none { it is AppRoute.Main.Sheet }) } @@ -250,7 +250,7 @@ class BuildNavGraphForLaunchTest { fun `pending actions still launch on the Wallet tab`() { val action = DeeplinkAction.OpenCashLink("testEntropy") val result = buildReady(action) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Wallet), result.baseRoutes) assertEquals(action, result.pendingAction) } } 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 1fae42a78d..67530a713f 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 @@ -97,22 +97,55 @@ sealed interface AppRoute : NavKey, Parcelable { } } + /** + * Full-screen destinations pushed onto whichever stack the user is already on. + * + * The distinction that matters at navigation time is how a route arrives: a [Tabs] home + * replaces the stack, a [Sheets] route is presented over it in a modal, and everything here + * is an ordinary push. + */ @Serializable @Parcelize sealed interface Main : AppRoute { @Serializable data class AppRestricted(val restrictionType: RestrictionType) : Main - @Serializable - data object Scanner : Main // TODO: is there a better place for this to live? @Serializable data object RegionSelection : Main + /** The give/cash screen, reached from the wallet tile and from a token's info screen. */ + @Serializable + data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Main + + /** Full unified paged activity history — the "dive in" from the wallet's recent-activity preview. */ + @Serializable + data object ActivityHistory : Main + + /** + * One activity entry, opened from its row (Figma node 9708:105260). + * + * Carries the entry's id rather than the row that was tapped: the screen re-reads the entry + * and stays live on it, so a cash link cancelled from this screen's own app bar redraws the + * screen the cancel was issued from. + */ + @Serializable + data class TransactionDetails(val id: ID) : Main + + /** + * A modal needing no nested navigation, so it carries the sheet marker directly rather + * than going through [Sheets] and [Sheet]. + */ @Serializable data class InviteContact(val phoneNumber: String) : com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + /** + * A [Sheets] route hosted in a modal bottom sheet, its nested navigator seeded with + * [initialRoute] followed by [innerRoutes]. + * + * Built by `openAsSheet`/`resolveRoutes`; nothing navigates to it directly. + */ @Serializable @Parcelize data class Sheet( @@ -121,6 +154,31 @@ sealed interface AppRoute : NavKey, Parcelable { ) : Main, com.getcode.navigation.Sheet } + /** + * The four tab homes the hoisted nav bar swaps between. + * + * A tab home replaces the current stack rather than stacking on it, which is the whole reason + * it is a type and not just a convention — `navigateAll` reads it off + * [com.flipcash.app.core.navigation.asNavBarTab], which is total over this hierarchy. + */ + @Serializable + @Parcelize + sealed interface Tabs : AppRoute { + @Serializable + data object Scanner : Tabs + + @Serializable + data object Wallet : Tabs + + /** Home of the chats tab, seeded at the chats list. */ + @Serializable + data class Tips(val resumed: Boolean = false) : Tabs + + /** The "You" tab: the menu (settings) surface, augmented with the tip card and share. */ + @Serializable + data object Menu : Tabs + } + @Serializable @Parcelize data class Verification( @@ -161,17 +219,21 @@ sealed interface AppRoute : NavKey, Parcelable { get() = steps } + /** + * Modal bottom sheets that host their own inner backstack. + * + * Membership is what makes `resolveRoutes` wrap the route in [Main.Sheet], seeding that + * sheet's nested navigator with whatever routes followed it — that packing is the only thing + * this grouping buys, so a modal with no nested navigation does not belong here. Such a + * modal implements [com.getcode.navigation.Sheet] directly instead ([Main.InviteContact], + * [com.flipcash.app.core.chat.ChatStep.AmountEntry]); that marker, not this interface, is what + * the scene strategy reads to present a route as a sheet at all. + */ @Serializable @Parcelize sealed interface Sheets : AppRoute { @Serializable data class TokenSelection(val purpose: TokenPurpose) : Sheets - @Serializable - data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets - - @Serializable - data class Tips(val resumed: Boolean = false): Sheets { - } /** * Custom tip-amount entry, opened over the still-visible tip card + modal. The entered @@ -180,29 +242,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object TipAmountEntry : Sheets - @Serializable - data object Wallet : Sheets - - /** Full unified paged activity history — the "dive in" from the wallet's recent-activity preview. */ - @Serializable - data object ActivityHistory : Sheets - - /** - * One activity entry, opened from its row (Figma node 9708:105260). - * - * Carries the entry's id rather than the row that was tapped: the screen re-reads the entry - * and stays live on it, so a cash link cancelled from this screen's own app bar redraws the - * screen the cancel was issued from. - */ - @Serializable - data class TransactionDetails(val id: ID) : Sheets - - @Serializable - data object Menu : Sheets - @Serializable data object ShareApp : Sheets - } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt index dd38e8b767..a521294855 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt @@ -3,7 +3,6 @@ package com.flipcash.app.core.extensions import androidx.compose.runtime.snapshots.Snapshot import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.navigation.asNavBarTab import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.NavOptions @@ -39,8 +38,7 @@ fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List = emp * Such a list is applied as a *tab switch* (the leading route replaces the stack) rather than * stacked on top of whatever tab the user was on. */ -private fun leadsWithTab(routes: List): Boolean = - (routes.firstOrNull() as? AppRoute)?.asNavBarTab() != null +private fun leadsWithTab(routes: List): Boolean = routes.firstOrNull() is AppRoute.Tabs /** * Navigate to multiple routes, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet]. @@ -50,9 +48,8 @@ private fun leadsWithTab(routes: List): Boolean = * If a sheet is already open and the new routes include a sheet, the current sheet * is animated closed before the new one opens. * - * The tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are *not* sheets, so a route - * list leading with one switches to that tab (replacing the stack) and pushes the rest on top - * of it. See [resolveRoutes]. + * A list leading with an [AppRoute.Tabs] home switches to that tab — replacing the stack — + * and pushes the rest on top of it. See [resolveRoutes]. */ fun CodeNavigator.navigateAll( routes: List, @@ -100,10 +97,9 @@ fun CodeNavigator.navigateAll( * [AppRoute.Main.Sheet] with inner routes, mirroring what [navigateAll] pushes * onto the backstack. Useful for predicting the resulting stack without navigating. * - * The tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are top-level tab destinations - * rather than modals, so they stay flat on the root backstack (which keeps the hoisted nav bar - * visible and lets back/pop behave like a tab stack). Anything after the tab route is resolved - * independently, so a genuine sheet later in the list still wraps. + * Everything else — [AppRoute.Tabs] homes and ordinary [AppRoute.Main] pushes alike — stays flat + * on the root backstack, which keeps the hoisted nav bar visible and lets back/pop behave like a + * tab stack. */ fun resolveRoutes(routes: List): List { if (routes.isEmpty()) return emptyList() @@ -112,11 +108,6 @@ fun resolveRoutes(routes: List): List { if (sheetIndex < 0) return routes val sheetRoute = routes[sheetIndex] as AppRoute.Sheets - - if (sheetRoute.asNavBarTab() != null) { - return routes.take(sheetIndex + 1) + resolveRoutes(routes.drop(sheetIndex + 1)) - } - val before = routes.take(sheetIndex) val innerRoutes = routes.drop(sheetIndex + 1).filterIsInstance() return before + AppRoute.Main.Sheet(sheetRoute, innerRoutes) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt index c92a853776..29c7fbf46d 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt @@ -10,4 +10,4 @@ import com.flipcash.app.core.AppRoute * [com.getcode.navigation.core.NavOptions.PopUpTo.ClearAll], exactly like a tab switch from the * nav bar. */ -val homeRoute: AppRoute = AppRoute.Sheets.Wallet +val homeRoute: AppRoute = AppRoute.Tabs.Wallet diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt index ebccaa40ec..c5077d6a62 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt @@ -8,21 +8,25 @@ import com.flipcash.app.core.AppRoute */ /** The top-level route a tab switches to. */ -fun NavBarButton.destinationRoute(): AppRoute = when (this) { - NavBarButton.Scanner -> AppRoute.Main.Scanner - NavBarButton.Wallet -> AppRoute.Sheets.Wallet +fun NavBarButton.destinationRoute(): AppRoute.Tabs = when (this) { + NavBarButton.Scanner -> AppRoute.Tabs.Scanner + NavBarButton.Wallet -> AppRoute.Tabs.Wallet // The chats tab routes through the tipping flow seeded at the list. - NavBarButton.Chats -> AppRoute.Sheets.Tips(resumed = false) + NavBarButton.Chats -> AppRoute.Tabs.Tips(resumed = false) // The "You" tab is the menu (settings) surface, augmented with the tip card + share. - NavBarButton.TipCard -> AppRoute.Sheets.Menu + NavBarButton.TipCard -> AppRoute.Tabs.Menu } -/** The tab a top-level route belongs to, or null if the route isn't a tab home. */ -fun AppRoute.asNavBarTab(): NavBarButton? = when (this) { - AppRoute.Main.Scanner -> NavBarButton.Scanner - AppRoute.Sheets.Wallet -> NavBarButton.Wallet - // The tipping flow is home to the chats tab (the tip card moved to the You/menu tab). - is AppRoute.Sheets.Tips -> NavBarButton.Chats - AppRoute.Sheets.Menu -> NavBarButton.TipCard - else -> null +/** + * The tab a tab home belongs to. Exhaustive over [AppRoute.Tabs], so a new tab home fails to + * compile until it is given a button rather than silently behaving as an ordinary push. + */ +fun AppRoute.Tabs.navBarButton(): NavBarButton = when (this) { + AppRoute.Tabs.Scanner -> NavBarButton.Scanner + AppRoute.Tabs.Wallet -> NavBarButton.Wallet + is AppRoute.Tabs.Tips -> NavBarButton.Chats + AppRoute.Tabs.Menu -> NavBarButton.TipCard } + +/** The tab a top-level route belongs to, or null if the route isn't a tab home. */ +fun AppRoute.asNavBarTab(): NavBarButton? = (this as? AppRoute.Tabs)?.navBarButton() diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt index 66853ed931..17f202985c 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt @@ -12,7 +12,7 @@ class HomeRouteTest { @Test fun `home is the wallet tab`() { - assertEquals(AppRoute.Sheets.Wallet, homeRoute) + assertEquals(AppRoute.Tabs.Wallet, homeRoute) } @Test diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt index 47e8fa433b..2b53f2efc9 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt @@ -13,23 +13,23 @@ class NavBarRoutesTest { @Test fun `the TipCard (You) tab routes to the menu`() { - assertEquals(AppRoute.Sheets.Menu, NavBarButton.TipCard.destinationRoute()) + assertEquals(AppRoute.Tabs.Menu, NavBarButton.TipCard.destinationRoute()) } @Test fun `the menu route belongs to the TipCard (You) tab`() { - assertEquals(NavBarButton.TipCard, AppRoute.Sheets.Menu.asNavBarTab()) + assertEquals(NavBarButton.TipCard, AppRoute.Tabs.Menu.asNavBarTab()) } @Test fun `the Chats tab still routes through the tipping flow`() { - assertEquals(AppRoute.Sheets.Tips(resumed = false), NavBarButton.Chats.destinationRoute()) + assertEquals(AppRoute.Tabs.Tips(resumed = false), NavBarButton.Chats.destinationRoute()) } @Test fun `the tipping flow maps back to the Chats tab, never TipCard`() { // Even the post-setup resumed form is the Chats tab now — the tip card moved to the You tab. - assertEquals(NavBarButton.Chats, AppRoute.Sheets.Tips(resumed = true).asNavBarTab()) - assertEquals(NavBarButton.Chats, AppRoute.Sheets.Tips(resumed = false).asNavBarTab()) + assertEquals(NavBarButton.Chats, AppRoute.Tabs.Tips(resumed = true).asNavBarTab()) + assertEquals(NavBarButton.Chats, AppRoute.Tabs.Tips(resumed = false).asNavBarTab()) } } diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 7cc0bc9a73..7f04449572 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -201,7 +201,7 @@ internal fun WalletScreenContent( dispatchEvent(WalletViewModel.Event.PresentDepositOptions) } is TutorialItem.ScanTipCard -> { - dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Main.Scanner)) + dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Tabs.Scanner)) } } } @@ -269,12 +269,12 @@ internal fun WalletScreenContent( modifier = Modifier .padding(top = grid.x2) .clickable { - dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Sheets.ActivityHistory)) + dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Main.ActivityHistory)) } .padding(top = grid.x4, bottom = grid.x1), onItemClick = { item -> dispatchEvent( - WalletViewModel.Event.OpenScreen(AppRoute.Sheets.TransactionDetails(item.messageId)) + WalletViewModel.Event.OpenScreen(AppRoute.Main.TransactionDetails(item.messageId)) ) }, ) diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt index 543d162f48..dd12134da0 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt @@ -70,7 +70,7 @@ fun DepositFlowScreen( ) when (result) { DepositResult.Success -> { - outerNavigator.popUntil { it == AppRoute.Sheets.Menu } + outerNavigator.popUntil { it == AppRoute.Tabs.Menu } } DepositResult.Canceled -> { outerNavigator.pop() diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 8062d41b18..d8624dbd55 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -326,7 +326,7 @@ internal class MenuScreenViewModel @Inject constructor( UsernameProgress.Unlocked -> dispatchEvent( Event.OpenScreen( AppRoute.UpdateUserProfile( - origin = AppRoute.Sheets.Menu, + origin = AppRoute.Tabs.Menu, steps = listOf(UpdateProfileStep.Username), ) ) @@ -373,7 +373,7 @@ internal class MenuScreenViewModel @Inject constructor( dispatchEvent( Event.OpenScreen( AppRoute.UpdateUserProfile( - origin = AppRoute.Sheets.Menu, + origin = AppRoute.Tabs.Menu, // A name is all a tip card needs. steps = listOf(UpdateProfileStep.Name(DisplayNameSource.TipCardSetup)), ) @@ -388,7 +388,7 @@ internal class MenuScreenViewModel @Inject constructor( dispatchEvent( Event.OpenScreen( AppRoute.UpdateUserProfile( - origin = AppRoute.Sheets.Menu, + origin = AppRoute.Tabs.Menu, // The account already has a name and a card by the time this // checklist is drawn, so the flow reduces to the one step. steps = listOf(UpdateProfileStep.Photo), @@ -404,7 +404,7 @@ internal class MenuScreenViewModel @Inject constructor( dispatchEvent( Event.OpenScreen( AppRoute.UpdateUserProfile( - origin = AppRoute.Sheets.Menu, + origin = AppRoute.Tabs.Menu, steps = listOf(UpdateProfileStep.MinimumTip), ) ) diff --git a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt index 092c55c052..029a81f4ba 100644 --- a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt +++ b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt @@ -51,7 +51,7 @@ fun PurchaseAccountScreen(viewModel: PurchaseAccountViewModel) { viewModel.eventFlow .filterIsInstance() .onEach { - navigator.replaceAll(AppRoute.Main.Scanner) + navigator.replaceAll(AppRoute.Tabs.Scanner) }.launchIn(this) } 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 75c3ee410b..7a9bc05341 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 @@ -62,7 +62,7 @@ internal fun Scanner() { session.tipCardEvents.collect { event -> when (event) { TipCardEvent.OwnCardScanned -> - navigator.navigateAll(listOf(AppRoute.Sheets.Menu)) + navigator.navigateAll(listOf(AppRoute.Tabs.Menu)) } } } @@ -108,13 +108,13 @@ internal fun Scanner() { is DeeplinkType.Navigatable -> { val routes: List = when (deeplink) { is DeeplinkType.TokenInfo -> listOf( - AppRoute.Sheets.Wallet, + AppRoute.Tabs.Wallet, Info(deeplink.mint, fromDeeplink = true) ) // Scanned tip-DM code — same destination as the // /tip/chat/{id} deeplink. is DeeplinkType.TipChat -> listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Tips(), AppRoute.Messaging.Chat(deeplink.identifier), ) else -> emptyList() diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt index 083999085e..bf38b3d027 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt @@ -23,7 +23,7 @@ import com.getcode.ui.utils.rememberKeyboardController @Composable fun TippingFlowScreen( - route: AppRoute.Sheets.Tips, + route: AppRoute.Tabs.Tips, resultStateRegistry: NavResultStateRegistry, ) { val sheetDismiss = LocalBottomSheetDismissDispatcher.current diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt index 2d6ee799e8..83b128e90a 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt @@ -56,14 +56,14 @@ internal fun TipInfoScreen() { onClick = { flowNavigator.navigate( AppRoute.UpdateUserProfile( - origin = AppRoute.Sheets.Tips(), + origin = AppRoute.Tabs.Tips(), // A card only needs a name, and only if the account hasn't got one. steps = if (userManager?.profile?.displayName.isNullOrEmpty()) { listOf(UpdateProfileStep.Name(DisplayNameSource.TipCardSetup)) } else { emptyList() }, - target = AppRoute.Sheets.Tips(resumed = true), + target = AppRoute.Tabs.Tips(resumed = true), ) ) }, 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 f8ec1889f4..99450c9a1f 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 @@ -242,7 +242,7 @@ internal fun CurrencyInfoContentV2( onItemClick = { item -> dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.TransactionDetails(item.messageId) + AppRoute.Main.TransactionDetails(item.messageId) ) ) }, @@ -352,7 +352,7 @@ private fun CurrencyActionTiles( onClick = { dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give(mint = tokenMint, fromTokenInfo = true) + AppRoute.Main.Give(mint = tokenMint, fromTokenInfo = true) ) ) }, diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt index 3db5373635..9cbfdf396e 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt @@ -71,7 +71,7 @@ fun WithdrawalFlowScreen( ) when (result) { WithdrawalResult.Success -> { - outerNavigator.popUntil { it == AppRoute.Sheets.Menu } + outerNavigator.popUntil { it == AppRoute.Tabs.Menu } } WithdrawalResult.Canceled -> { outerNavigator.pop() diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt index 61c7dbcef2..e59d896441 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/PayableDecorator.kt @@ -99,7 +99,7 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan onClaim = { if (session == null) context.onDismiss() else if (session.claimReceivedFunds()) { - navigator.navigateAll(listOf(AppRoute.Sheets.Wallet)) + navigator.navigateAll(listOf(AppRoute.Tabs.Wallet)) } } ) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index 177c634733..641d5ded71 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -81,7 +81,7 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S is TipEvent.LaunchChat -> { navigator.navigateAll( listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Tips(), AppRoute.Messaging.Chat(event.identifier, openKeyboard = true), ), ) diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt index 55516fd63f..ef36f92c59 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt @@ -25,7 +25,7 @@ fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { analytics.action(Button.AllowPush) if (fromOnboarding) analytics.action(Action.CompletedOnboarding) navigator.navigate( - route = AppRoute.Main.Scanner, + route = AppRoute.Tabs.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) ) } @@ -42,7 +42,7 @@ fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { LaunchedEffect(permissionState.status) { when (permissionState.status) { PermissionResult.Granted -> navigator.navigate( - route = AppRoute.Main.Scanner, + route = AppRoute.Tabs.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) ) PermissionResult.PermanentlyDenied -> navigator.push( @@ -61,7 +61,7 @@ fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { onSkip = { analytics.action(Button.SkipPush) navigator.navigate( - route = AppRoute.Main.Scanner, + route = AppRoute.Tabs.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) ) } @@ -77,7 +77,7 @@ fun NotificationPermissionRationaleScreen(permanentlyDenied: Boolean = false) { permanentlyDenied = permanentlyDenied, onComplete = { navigator.navigate( - route = AppRoute.Main.Scanner, + route = AppRoute.Tabs.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) ) }, 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 3ee56ad027..e949843cf4 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 @@ -118,7 +118,7 @@ internal class AppRouter( is DeeplinkType.TokenInfo -> DeeplinkAction.OpenToken( mint = type.mint, routes = listOf( - AppRoute.Sheets.Wallet, + AppRoute.Tabs.Wallet, AppRoute.Token.Info(type.mint, fromDeeplink = true), ), ) @@ -126,7 +126,7 @@ internal class AppRouter( is DeeplinkType.EmailVerification -> resolveEmailVerification(type) is DeeplinkType.TipChat -> DeeplinkAction.Navigate( - listOf(AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(type.identifier)) + listOf(AppRoute.Tabs.Tips(), AppRoute.Messaging.Chat(type.identifier)) ) is DeeplinkType.Tipcard -> tipCard(TipCardOwner.ById(type.userId)) @@ -138,7 +138,7 @@ internal class AppRouter( /** * Where a tip card link goes. Your own leads nowhere payable, so instead of presenting a card * that can't be acted on it lands on the You tab — the surface that owns your tip card (see - * NavBarRoutes: NavBarButton.TipCard -> Sheets.Menu). + * NavBarRoutes: NavBarButton.TipCard -> Tabs.Menu). * * The self-check belongs here and not after resolution: the session announces a self-tip * through a replay-less event that only the scanner collects, so a link opened onto any other @@ -147,7 +147,7 @@ internal class AppRouter( private fun tipCard(owner: TipCardOwner): DeeplinkAction { val self = currentUserProvider() return if (owner.isSelf(self.id, self.username)) { - DeeplinkAction.Navigate(listOf(AppRoute.Sheets.Menu)) + DeeplinkAction.Navigate(listOf(AppRoute.Tabs.Menu)) } else { DeeplinkAction.PresentTipCard(owner) } @@ -250,7 +250,7 @@ internal class AppRouter( EmailDeeplinkOrigin.MyAccount -> listOf( - AppRoute.Sheets.Menu, + AppRoute.Tabs.Menu, AppRoute.Menu.MyAccount ) + AppRoute.Verification( origin = AppRoute.Menu.MyAccount, 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 a46f6a11c3..ca5e43d867 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 @@ -236,7 +236,7 @@ class AppRouterTest { loggedIn() val action = router.dispatch(DeepLink(verifyUrl("myaccount"))) assertIs(action) - assertEquals(AppRoute.Sheets.Menu, action.routes[0]) + assertEquals(AppRoute.Tabs.Menu, action.routes[0]) assertIs(action.routes[1]) assertIs(action.routes[2]) } @@ -337,7 +337,7 @@ class AppRouterTest { val action = router.dispatch(DeepLink("https://app.flipcash.com/token/$mint")) assertIs(action) assertEquals(2, action.routes.size) - assertIs(action.routes[0]) + assertIs(action.routes[0]) val tokenInfo = action.routes[1] assertIs(tokenInfo) assertEquals(Mint(mint), tokenInfo.mint) @@ -378,7 +378,7 @@ class AppRouterTest { val action = router.dispatch(DeepLink(Linkify.tipChatById(sampleChatId))) assertIs(action) assertEquals(2, action.routes.size) - assertIs(action.routes[0]) + assertIs(action.routes[0]) val chat = action.routes[1] assertIs(chat) val identifier = chat.identifier @@ -408,7 +408,7 @@ class AppRouterTest { // Tipping yourself is a payment no-op, so the link lands on the You tab instead. assertIs(action) - assertEquals(listOf(AppRoute.Sheets.Menu), action.routes) + assertEquals(listOf(AppRoute.Tabs.Menu), action.routes) } @Test @@ -531,7 +531,7 @@ class AppRouterTest { val action = router.dispatch(DeepLink("https://flipcash.com/$userId")) assertIs(action) - assertEquals(AppRoute.Sheets.Menu, action.routes.single()) + assertEquals(AppRoute.Tabs.Menu, action.routes.single()) } // UUID.fromString would take these; the regex doesn't. A shape the app doesn't claim goes back @@ -578,7 +578,7 @@ class AppRouterTest { currentUsername = "sally_streamer" val action = router.dispatch(DeepLink(Linkify.tipcard(TipCardOwner.ByUsername("sally_streamer")))) assertIs(action) - assertEquals(AppRoute.Sheets.Menu, action.routes.single()) + assertEquals(AppRoute.Tabs.Menu, action.routes.single()) } // A link can be typed or pasted in any case; handles are lowercase on the wire. @@ -588,7 +588,7 @@ class AppRouterTest { currentUsername = "sally_streamer" val action = router.dispatch(DeepLink("https://flipcash.com/Sally_Streamer")) assertIs(action) - assertEquals(AppRoute.Sheets.Menu, action.routes.single()) + assertEquals(AppRoute.Tabs.Menu, action.routes.single()) } @Test @@ -639,7 +639,7 @@ class AppRouterTest { val action = router.dispatch(DeepLink(url)) assertIs(action) assertEquals(3, action.routes.size) - assertIs(action.routes[0]) + assertIs(action.routes[0]) assertIs(action.routes[1]) val verification = action.routes[2] assertIs(verification) 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..1f2b49b98e 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 @@ -39,11 +39,11 @@ class NavigateToTest { @Test fun `navigateTo without existing sheet navigates directly`() { - val navigator = createNavigator(AppRoute.Main.Scanner) + val navigator = createNavigator(AppRoute.Tabs.Scanner) val mint = Mint("So11111111111111111111111111111111111111112") navigator.navigateAll( - listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint)), + listOf(AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -55,8 +55,8 @@ class NavigateToTest { @Test fun `navigateTo non-sheet routes navigates directly even with existing sheet`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) navigator.navigateAll(listOf(AppRoute.Menu.MyAccount), options = quietOptions) @@ -71,13 +71,13 @@ class NavigateToTest { @Test fun `navigateTo with existing sheet sets pendingSheetDismiss`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") navigator.navigateAll( - listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint)), + listOf(AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -89,12 +89,12 @@ class NavigateToTest { @Test fun `pendingSheetDismiss callback increments sheetGeneration`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) val initialGeneration = navigator.sheetGeneration - navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.TipAmountEntry), options = quietOptions) // Simulate what ModalBottomSheetScene does: remove old sheet, then invoke callback navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -106,13 +106,13 @@ class NavigateToTest { @Test fun `pendingSheetDismiss callback navigates to new routes`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") navigator.navigateAll( - listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint)), + listOf(AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -122,25 +122,25 @@ class NavigateToTest { val last = navigator.backStack.last() assertIs(last) - assertEquals(AppRoute.Sheets.ActivityHistory, last.initialRoute) + assertEquals(AppRoute.Sheets.TipAmountEntry, last.initialRoute) assertIs(last.innerRoutes.single()) } @Test fun `repeated dismiss-then-replace increments generation each time`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) // First replace - navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.TipAmountEntry), options = quietOptions) navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() assertEquals(1, navigator.sheetGeneration) // Second replace - navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.TipAmountEntry), options = quietOptions) navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() assertEquals(2, navigator.sheetGeneration) @@ -152,7 +152,7 @@ class NavigateToTest { @Test fun `empty routes is a no-op`() { - val navigator = createNavigator(AppRoute.Main.Scanner) + val navigator = createNavigator(AppRoute.Tabs.Scanner) navigator.navigateAll(emptyList(), options = quietOptions) @@ -164,15 +164,15 @@ class NavigateToTest { fun `same token dismiss-then-replace works`() { val mint = Mint("So11111111111111111111111111111111111111112") val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet( - AppRoute.Sheets.ActivityHistory, + AppRoute.Sheets.TipAmountEntry, listOf(AppRoute.Token.Info(mint, fromDeeplink = true)), ), ) navigator.navigateAll( - listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint, fromDeeplink = true)), + listOf(AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mint, fromDeeplink = true)), options = quietOptions, ) @@ -187,11 +187,11 @@ class NavigateToTest { // backstack. The callback's navigate() must not produce a duplicate Sheet key // (which would crash SaveableStateProvider). val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) - navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.TipAmountEntry), options = quietOptions) // Simulate: a route is pushed during the dismiss animation navigator.backStack.add(AppRoute.Menu.MyAccount) @@ -210,18 +210,18 @@ class NavigateToTest { @Test fun `dismiss-replace increments sheetGeneration for composition scoping`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) - navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.TipAmountEntry), options = quietOptions) // Simulate ModalBottomSheetScene dismiss: remove old sheet, then fire callback navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() val newSheet = navigator.backStack.filterIsInstance().single() - assertEquals(AppRoute.Sheets.ActivityHistory, newSheet.initialRoute) + assertEquals(AppRoute.Sheets.TipAmountEntry, newSheet.initialRoute) assert(navigator.sheetGeneration > 0) { "Expected sheetGeneration > 0 after dismiss-replace, got ${navigator.sheetGeneration}" } @@ -229,19 +229,19 @@ class NavigateToTest { @Test fun `navigate deduplicates identical sheet that was not removed by onBack`() { - // Reproduces the production crash: a stale Sheet(ActivityHistory,[]) remains on the + // Reproduces the production crash: a stale Sheet(TipAmountEntry,[]) remains on the // backstack after onBack removed the wrong entry during a dismiss animation. // A subsequent navigate for the same Sheet must not produce a duplicate. val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) // Simulate: something pushed on top during dismiss, onBack removed that // instead of the sheet, so the old sheet is still here. // Now navigate to the same sheet again. navigator.navigate( - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), NavOptions(debugRouting = false), ) @@ -252,16 +252,16 @@ class NavigateToTest { @Test fun `double navigateTo with pending dismiss does not produce duplicate sheets`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) // First navigate sets pendingSheetDismiss - navigator.navigateAll(listOf(AppRoute.Sheets.Menu), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Tabs.Menu), options = quietOptions) assertNotNull(navigator.pendingSheetDismiss) // Second navigate overwrites pendingSheetDismiss - navigator.navigateAll(listOf(AppRoute.Sheets.Menu), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Tabs.Menu), options = quietOptions) // Simulate: onBack removes old sheet, then callback fires navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -269,7 +269,7 @@ class NavigateToTest { // Simulate a stale callback also firing navigate for the same sheet navigator.navigate( - AppRoute.Main.Sheet(AppRoute.Sheets.Menu), + AppRoute.Main.Sheet(AppRoute.Tabs.Menu), NavOptions(debugRouting = false), ) @@ -284,8 +284,8 @@ class NavigateToTest { @Test fun `openAsSheet with existing sheet sets pendingSheetDismiss`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) navigator.openAsSheet(AppRoute.Sheets.ShareApp) @@ -298,8 +298,8 @@ class NavigateToTest { @Test fun `openAsSheet callback navigates to new sheet after dismiss`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) navigator.openAsSheet(AppRoute.Sheets.ShareApp) @@ -316,8 +316,8 @@ class NavigateToTest { @Test fun `openAsSheet increments sheetGeneration on dismiss-replace`() { val navigator = createNavigator( - AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), + AppRoute.Tabs.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.TipAmountEntry), ) val initialGeneration = navigator.sheetGeneration @@ -332,7 +332,7 @@ class NavigateToTest { @Test fun `openAsSheet without existing sheet navigates directly`() { - val navigator = createNavigator(AppRoute.Main.Scanner) + val navigator = createNavigator(AppRoute.Tabs.Scanner) navigator.openAsSheet(AppRoute.Sheets.ShareApp) 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..3b2ebe72d5 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 @@ -31,28 +31,42 @@ class ResolveRoutesTest { @Test fun `non-sheet routes pass through unchanged`() { - val routes = listOf(AppRoute.Main.Scanner, AppRoute.Menu.MyAccount) + val routes = listOf(AppRoute.Tabs.Scanner, AppRoute.Menu.MyAccount) assertEquals(routes, resolveRoutes(routes)) } + @Test + fun `a pushed full screen is not wrapped into a sheet`() { + // These carry their own app bar and back arrow and are reached by push everywhere in the + // app. A deeplink naming one has to land on that same screen, not a modal wearing it. + val routes = listOf( + AppRoute.Main.ActivityHistory, + AppRoute.Main.TransactionDetails(listOf(1, 2, 3)), + ) + + val resolved = resolveRoutes(routes) + assertEquals(routes, resolved) + assertTrue(resolved.none { it is AppRoute.Main.Sheet }) + } + // endregion - // region Sheet wrapping — genuine modals (no tab home) bundle into Main.Sheet + // region Sheet wrapping — AppRoute.Sheets members bundle into Main.Sheet @Test fun `single sheet route is wrapped in Main Sheet`() { - val resolved = resolveRoutes(listOf(AppRoute.Sheets.ActivityHistory)) + val resolved = resolveRoutes(listOf(AppRoute.Sheets.TipAmountEntry)) assertEquals(1, resolved.size) val sheet = resolved.single() assertIs(sheet) - assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) + assertEquals(AppRoute.Sheets.TipAmountEntry, sheet.initialRoute) assertEquals(emptyList(), sheet.innerRoutes) } @Test fun `sheet with inner routes bundles into Main Sheet`() { val routes = listOf( - AppRoute.Sheets.ActivityHistory, + AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mint, fromDeeplink = true), ) @@ -60,7 +74,7 @@ class ResolveRoutesTest { assertEquals(1, resolved.size) val sheet = resolved.single() assertIs(sheet) - assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) + assertEquals(AppRoute.Sheets.TipAmountEntry, sheet.initialRoute) assertEquals(1, sheet.innerRoutes.size) assertIs(sheet.innerRoutes[0]) } @@ -68,7 +82,7 @@ class ResolveRoutesTest { @Test fun `sheet with multiple inner routes bundles all`() { val routes = listOf( - AppRoute.Sheets.ActivityHistory, + AppRoute.Sheets.TipAmountEntry, AppRoute.Menu.MyAccount, AppRoute.Verification( origin = AppRoute.Menu.MyAccount, @@ -82,7 +96,7 @@ class ResolveRoutesTest { assertEquals(1, resolved.size) val sheet = resolved.single() assertIs(sheet) - assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) + assertEquals(AppRoute.Sheets.TipAmountEntry, sheet.initialRoute) assertEquals(2, sheet.innerRoutes.size) assertIs(sheet.innerRoutes[0]) assertIs(sheet.innerRoutes[1]) @@ -91,17 +105,17 @@ class ResolveRoutesTest { @Test fun `routes before sheet stay on root backstack`() { val routes = listOf( - AppRoute.Main.Scanner, - AppRoute.Sheets.ActivityHistory, + AppRoute.Tabs.Scanner, + AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mint), ) val resolved = resolveRoutes(routes) assertEquals(2, resolved.size) - assertIs(resolved[0]) + assertIs(resolved[0]) val sheet = resolved[1] assertIs(sheet) - assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) + assertEquals(AppRoute.Sheets.TipAmountEntry, sheet.initialRoute) assertEquals(1, sheet.innerRoutes.size) } @@ -112,7 +126,7 @@ class ResolveRoutesTest { @Test fun `resolved routes are structurally equal when inputs match`() { val routes = listOf( - AppRoute.Sheets.ActivityHistory, + AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mint, fromDeeplink = true), ) @@ -127,9 +141,9 @@ class ResolveRoutesTest { val mintB = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") val resolvedA = - resolveRoutes(listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mintA))) + resolveRoutes(listOf(AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mintA))) val resolvedB = - resolveRoutes(listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mintB))) + resolveRoutes(listOf(AppRoute.Sheets.TipAmountEntry, AppRoute.Token.Info(mintB))) assert(resolvedA != resolvedB) } @@ -140,7 +154,7 @@ class ResolveRoutesTest { @Test fun `wallet tab stays flat and token info pushes on top`() { val routes = listOf( - AppRoute.Sheets.Wallet, + AppRoute.Tabs.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true), ) @@ -152,7 +166,7 @@ class ResolveRoutesTest { @Test fun `chats tab stays flat and the chat pushes on top`() { val routes = listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Tips(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(1, 2, 3, 4)))), ) @@ -164,7 +178,7 @@ class ResolveRoutesTest { @Test fun `menu tab stays flat with my account and verification pushed on top`() { val routes = listOf( - AppRoute.Sheets.Menu, + AppRoute.Tabs.Menu, AppRoute.Menu.MyAccount, AppRoute.Verification( origin = AppRoute.Menu.MyAccount, @@ -180,18 +194,18 @@ class ResolveRoutesTest { @Test fun `a genuine sheet that follows a tab home still wraps`() { val routes = listOf( - AppRoute.Sheets.Wallet, + AppRoute.Tabs.Wallet, AppRoute.Token.Info(mint), - AppRoute.Sheets.ActivityHistory, + AppRoute.Sheets.TipAmountEntry, ) val resolved = resolveRoutes(routes) assertEquals(3, resolved.size) - assertEquals(AppRoute.Sheets.Wallet, resolved[0]) + assertEquals(AppRoute.Tabs.Wallet, resolved[0]) assertIs(resolved[1]) val sheet = resolved[2] assertIs(sheet) - assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) + assertEquals(AppRoute.Sheets.TipAmountEntry, sheet.initialRoute) } // endregion @@ -200,32 +214,32 @@ class ResolveRoutesTest { @Test fun `a deeplink to a tab replaces the launch home rather than stacking on it`() { - val base = listOf(AppRoute.Sheets.Wallet) + val base = listOf(AppRoute.Tabs.Wallet) val deeplink = listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Tips(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(9)))), ) val stack = resolveBackStack(base, deeplink) assertEquals(deeplink, stack) // The Wallet home the app launched on must not linger beneath the Chats tab. - assertTrue(stack.none { it == AppRoute.Sheets.Wallet }) + assertTrue(stack.none { it == AppRoute.Tabs.Wallet }) } @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 base = listOf(AppRoute.Tabs.Wallet) + val deeplink = listOf(AppRoute.Tabs.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) val stack = resolveBackStack(base, deeplink) assertEquals(2, stack.size) - assertEquals(1, stack.count { it == AppRoute.Sheets.Wallet }) + assertEquals(1, stack.count { it == AppRoute.Tabs.Wallet }) assertIs(stack[1]) } @Test fun `a deeplink without a tab home stacks on the launch home`() { - val base = listOf(AppRoute.Sheets.Wallet) + val base = listOf(AppRoute.Tabs.Wallet) val deeplink = listOf(AppRoute.Token.Info(mint), AppRoute.Token.Swap(SwapPurpose.Buy(mint))) assertEquals(base + deeplink, resolveBackStack(base, deeplink)) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt index 4e01a65e09..99f7e8fb3c 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt @@ -66,7 +66,7 @@ fun ActivityHistoryScreen() { ActivityFeedRow( item = item, modifier = Modifier.fillMaxWidth(), - onClick = { navigator.push(AppRoute.Sheets.TransactionDetails(item.messageId)) }, + onClick = { navigator.push(AppRoute.Main.TransactionDetails(item.messageId)) }, ) } diff --git a/docs/architecture/03-navigation.md b/docs/architecture/03-navigation.md index 38f5f69fc7..2cfa226816 100644 --- a/docs/architecture/03-navigation.md +++ b/docs/architecture/03-navigation.md @@ -54,10 +54,22 @@ sealed interface AppRoute : NavKey, Parcelable { sealed interface Token : AppRoute { /* Info, Swap, ... */ } sealed interface Transfers : AppRoute { /* Deposit, Withdrawal */ } sealed interface Messaging : AppRoute { /* Chat */ } - // Onboarding, Main, Sheets, Menu, ... + // Onboarding, Main, Tabs, Sheets, Menu, ... } ``` +Three of those groupings are about *how a route arrives*, and `resolveRoutes` reads +them directly: + +- **`Tabs`** — the four nav-bar homes (`Scanner`, `Wallet`, `Tips`, `Menu`). A list + leading with one replaces the stack rather than pushing onto it. +- **`Sheets`** — modals that host their own inner backstack. `resolveRoutes` wraps + one (plus whatever routes follow it) into `Main.Sheet`, seeding the sheet's nested + navigator. A modal needing no nested navigation skips this and implements + `com.getcode.navigation.Sheet` directly — that marker, not the grouping, is what + `ModalBottomSheetSceneStrategy` reads. +- **`Main`** — ordinary pushes onto whatever stack the user is already on. + Two marker interfaces from `com.getcode.navigation.flow` model multi-screen flows: - **`FlowRoute`** — a route that expands into an `initialStack` of inner steps @@ -109,7 +121,7 @@ via an `entryProvider { … }` builder: fun appEntryProvider(/* ... */): (NavKey) -> NavEntry = entryProvider { annotatedEntry { MainRoot(/* ... */) } annotatedEntry { key -> OnboardingFlowScreen(route = key, /* ... */) } - annotatedEntry { key -> CashScreen(key.mint, key.fromTokenInfo) } + annotatedEntry { key -> CashScreen(key.mint, key.fromTokenInfo) } annotatedEntry { key -> TokenInfoScreen(mint = key.mint) } annotatedEntry { key -> SwapFlowScreen(route = key, /* ... */) } annotatedEntry { key -> WithdrawalFlowScreen(route = key, /* ... */) } diff --git a/maestro/README.md b/maestro/README.md index 36313d1da1..2bae92b6ec 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -73,9 +73,9 @@ Every routed screen is addressable by a stable `_screen` resource-id. Thes - The tag defaults to one **derived from the route type name** (`screenRootTag` in `NavMetadata.kt`): `AppRoute.Menu.MyAccount` → `my_account_screen`, - `AppRoute.Main.Scanner` → `scanner_screen`. + `AppRoute.Tabs.Scanner` → `scanner_screen`. - Pass an explicit `testTag` only when a route needs a different id than its type name, e.g. - `annotatedEntry(testTag = "cash_screen") { ... }`. + `annotatedEntry(testTag = "cash_screen") { ... }`. Because the tag lives with the route registration, adding a screen tags it automatically and the anchors can't drift out of sync with the UI. Screens that are **not** nav entries (the diff --git a/maestro/subflows/create_account.yaml b/maestro/subflows/create_account.yaml index a2aff5774b..ae1dd8c4e4 100644 --- a/maestro/subflows/create_account.yaml +++ b/maestro/subflows/create_account.yaml @@ -29,5 +29,5 @@ appId: com.flipcash.app.android text: "Allow" optional: true -# Onboarding releases to the Wallet tab, not the camera (homeRoute = AppRoute.Sheets.Wallet). +# Onboarding releases to the Wallet tab, not the camera (homeRoute = AppRoute.Tabs.Wallet). - extendedWaitUntil: { visible: { id: wallet_screen }, timeout: 20000 } diff --git a/maestro/subflows/login.yaml b/maestro/subflows/login.yaml index a5a5e05529..b2223fa314 100644 --- a/maestro/subflows/login.yaml +++ b/maestro/subflows/login.yaml @@ -9,7 +9,7 @@ appId: com.flipcash.app.android id: "seed_input_field" - inputText: ${SEED_PHRASE} - tapOn: Log in -# Launch lands on the Wallet tab, not the camera (homeRoute = AppRoute.Sheets.Wallet). +# Launch lands on the Wallet tab, not the camera (homeRoute = AppRoute.Tabs.Wallet). - extendedWaitUntil: visible: id: "wallet_screen" diff --git a/maestro/subflows/login_usdf_only.yaml b/maestro/subflows/login_usdf_only.yaml index 9ea1dfd316..aa42328f31 100644 --- a/maestro/subflows/login_usdf_only.yaml +++ b/maestro/subflows/login_usdf_only.yaml @@ -9,7 +9,7 @@ appId: com.flipcash.app.android clearAppState: "true" deeplink: ${USDF_ONLY_DEEPLINK} -# Launch lands on the Wallet tab, not the camera (homeRoute = AppRoute.Sheets.Wallet). +# Launch lands on the Wallet tab, not the camera (homeRoute = AppRoute.Tabs.Wallet). - extendedWaitUntil: visible: id: "wallet_screen" diff --git a/maestro/subflows/login_with_deeplink.yaml b/maestro/subflows/login_with_deeplink.yaml index 4d0cf5e9b7..9b5e8d112c 100644 --- a/maestro/subflows/login_with_deeplink.yaml +++ b/maestro/subflows/login_with_deeplink.yaml @@ -7,7 +7,7 @@ appId: com.flipcash.app.android clearAppState: "true" deeplink: ${LOGIN_DEEPLINK} -# Launch lands on the Wallet tab, not the camera (homeRoute = AppRoute.Sheets.Wallet). +# Launch lands on the Wallet tab, not the camera (homeRoute = AppRoute.Tabs.Wallet). - extendedWaitUntil: visible: id: "wallet_screen" From 9867a08431cb22e3c8ccdcefc79d90e91c7b3f62 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 4 Sep 2026 12:23:31 -0400 Subject: [PATCH 2/2] fix(nav): stop sending tab-home traffic to the scanner by push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scanner destinations the new AppRoute.Tabs type makes visible as errors: The wallet's new-user tutorial dispatched "scan a tip card" as OpenScreen, which WalletScreen handles with navigator.push — so the scanner arrived stacked on the wallet, and Back returned to the wallet instead of behaving like the nav bar. It now dispatches SwitchTab, handled with replaceAll, the same call AppNavigationBar makes. PurchaseAccountScreen released to the scanner on account creation rather than to homeRoute, which every other onboarding exit uses. NotificationPermissionScreen and NotificationPermissionRationaleScreen were a second copy of the permission screens left behind by the FlowHost migration: nothing calls them, and AppRoute.Onboarding.NotificationPermission has no annotatedEntry to reach them by. The live path is OnboardingFlowScreen's NotificationPermissionStepContent, which releases to homeRoute. Deleted rather than repointed — the file's only effect was to make the wrong destination look supported. --- .../com/flipcash/app/balance/WalletScreen.kt | 9 ++ .../balance/internal/WalletScreenContent.kt | 2 +- .../app/balance/internal/WalletViewModel.kt | 8 ++ .../internal/PurchaseAccountScreenContent.kt | 5 +- .../NotificationPermissionScreen.kt | 85 ------------------- 5 files changed, 21 insertions(+), 88 deletions(-) delete mode 100644 apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt index 5bfd043f03..784ddef045 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt @@ -53,5 +53,14 @@ fun WalletScreen() { .onEach { navigator.push(it) } .launchIn(this) } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.tab } + // Same call the nav bar makes, so the tab arrives with the stack replaced. + .onEach { navigator.replaceAll(it) } + .launchIn(this) + } } } \ No newline at end of file diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 7f04449572..744468bcb6 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -201,7 +201,7 @@ internal fun WalletScreenContent( dispatchEvent(WalletViewModel.Event.PresentDepositOptions) } is TutorialItem.ScanTipCard -> { - dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Tabs.Scanner)) + dispatchEvent(WalletViewModel.Event.SwitchTab(AppRoute.Tabs.Scanner)) } } } diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index a5a77cd4f9..2ef2381efa 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -136,6 +136,13 @@ internal class WalletViewModel @Inject constructor( data object OpenCurrencySelection : Event data class OpenScreen(val screen: AppRoute) : Event + + /** + * Switch to a tab home, which replaces the stack rather than stacking on it. Distinct from + * [OpenScreen]: pushing a tab home leaves the wallet underneath it, so Back returns here + * instead of behaving like the nav bar. + */ + data class SwitchTab(val tab: AppRoute.Tabs) : Event data object PresentDepositOptions: Event } @@ -237,6 +244,7 @@ internal class WalletViewModel @Inject constructor( } Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } + is Event.SwitchTab -> { state -> state } } } } diff --git a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt index 029a81f4ba..fd1dd78d63 100644 --- a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt +++ b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.flipcash.app.billing.IapProduct import com.flipcash.app.billing.ProductPrice -import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.homeRoute import com.flipcash.app.core.ui.BrandedGradientIcon import com.flipcash.app.theme.FlipcashPreview import com.flipcash.features.purchase.R @@ -51,7 +51,8 @@ fun PurchaseAccountScreen(viewModel: PurchaseAccountViewModel) { viewModel.eventFlow .filterIsInstance() .onEach { - navigator.replaceAll(AppRoute.Tabs.Scanner) + // Releases to the same home the app launches on, like the rest of onboarding. + navigator.replaceAll(homeRoute) }.launchIn(this) } diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt deleted file mode 100644 index ef36f92c59..0000000000 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.flipcash.app.permissions - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import com.flipcash.app.analytics.Action -import com.flipcash.app.analytics.Button -import com.flipcash.app.core.AppRoute -import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent -import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent -import com.getcode.libs.analytics.LocalAnalytics -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.core.NavOptions -import com.getcode.util.permissions.PermissionResult -import com.getcode.util.permissions.rememberNotificationPermission - -@Composable -fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { - val navigator = LocalCodeNavigator.current - val analytics = LocalAnalytics.current - - val permissionState = rememberNotificationPermission { result -> - when (result) { - PermissionResult.Granted -> { - analytics.action(Button.AllowPush) - if (fromOnboarding) analytics.action(Action.CompletedOnboarding) - navigator.navigate( - route = AppRoute.Tabs.Scanner, - options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) - ) - } - PermissionResult.Denied -> navigator.push( - AppRoute.Onboarding.NotificationPermissionRationale(false) - ) - PermissionResult.PermanentlyDenied -> navigator.push( - AppRoute.Onboarding.NotificationPermissionRationale(true) - ) - PermissionResult.NotRequested -> Unit - } - } - - LaunchedEffect(permissionState.status) { - when (permissionState.status) { - PermissionResult.Granted -> navigator.navigate( - route = AppRoute.Tabs.Scanner, - options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) - ) - PermissionResult.PermanentlyDenied -> navigator.push( - AppRoute.Onboarding.NotificationPermissionRationale(true) - ) - // NotRequested + Denied both render screen 1 - // Denied = show rationale (screen 1) then re-trigger dialog on OK - PermissionResult.NotRequested, - PermissionResult.Denied -> Unit - } - } - - // Only reached when status is NotRequested - NotificationScreenContent( - permissionState = permissionState, - onSkip = { - analytics.action(Button.SkipPush) - navigator.navigate( - route = AppRoute.Tabs.Scanner, - options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) - ) - } - ) - - BackHandler(fromOnboarding) { } -} - -@Composable -fun NotificationPermissionRationaleScreen(permanentlyDenied: Boolean = false) { - val navigator = LocalCodeNavigator.current - NotificationRationalePermissionContent( - permanentlyDenied = permanentlyDenied, - onComplete = { - navigator.navigate( - route = AppRoute.Tabs.Scanner, - options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) - ) - }, - ) -}