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/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 7cc0bc9a73..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.Main.Scanner)) + dispatchEvent(WalletViewModel.Event.SwitchTab(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/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/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..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.Main.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/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 deleted file mode 100644 index 55516fd63f..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.Main.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.Main.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.Main.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.Main.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"