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 629ee3e2c6..87b47cfde2 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 @@ -32,7 +32,6 @@ import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.LocalTabBarVisibility import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.navigation.TabBarVisibilityController -import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.ui.transitions.CardExpandTransition import com.flipcash.app.core.tokens.TokenInfoEntry @@ -102,8 +101,7 @@ internal fun AppContent( // A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every // tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the // chat list scrolled back to the top. Hold each tab home's ViewModels and scroll state outside the - // back stack so a switch shows what the tab last had. Only the four routes a tab press produces - // are held, so variants of a tab route (a resumed Tips, say) still get a fresh screen. + // back stack so a switch shows what the tab last had. val tabHomeKeys = remember { NavBarButton.entries.map { it.destinationRoute().toString() }.toSet() } @@ -113,7 +111,7 @@ internal fun AppContent( // push keeps its tab home on the stack, so this fires on a replaceAll away from the tabs. LaunchedEffect(entryState, codeNavigator.backStack) { snapshotFlow { - codeNavigator.backStack.any { (it as? AppRoute)?.asNavBarTab() != null } + codeNavigator.backStack.any { it is AppRoute.Tabs } }.collect { onTabs -> if (!onTabs) entryState.releaseAll() } } @@ -166,7 +164,7 @@ internal fun AppContent( // into a detail screen and slides in. Pops always slide back out (a pop is always // leaving a detail). Sheets/overlays keep their own (no) transition. transitionSpec = { - val landsOnTab = (codeNavigator.currentRouteKey as? AppRoute)?.asNavBarTab() != null + val landsOnTab = codeNavigator.currentRouteKey is AppRoute.Tabs when { targetState is OverlayScene<*> || initialState is OverlayScene<*> -> EnterTransition.None togetherWith ExitTransition.None 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 f3e67ebf0b..a83807714f 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 @@ -28,7 +28,7 @@ import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.FindByUsernameScreen import com.flipcash.app.tipping.NewChatScreen import com.flipcash.app.tipping.NewGroupFlowScreen -import com.flipcash.app.tipping.TippingFlowScreen +import com.flipcash.app.tipping.ChatsScreen import com.flipcash.shared.transactionhistory.ActivityHistoryScreen import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction @@ -90,26 +90,26 @@ fun appEntryProvider( SheetContent(key, resultStateRegistry, barManager) } 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 { key -> TokenSelectScreen(key.purpose) } - annotatedEntry { + // Tabs (nav bar homes — flat on the root backstack, never wrapped in Main.Sheet) + 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 { ChatsScreen() } + annotatedEntry { MenuScreen() } + + // 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 -> TokenSelectScreen(key.purpose) } annotatedEntry { ShareAppScreen() } annotatedEntry { ActivityHistoryScreen() } annotatedEntry { key -> TransactionDetailsScreen(key.id) } - annotatedEntry { MenuScreen() } // Messaging annotatedEntry { key -> diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavTabBarInsetEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavTabBarInsetEntryDecorator.kt index f245b05e67..2a0e922bac 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavTabBarInsetEntryDecorator.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavTabBarInsetEntryDecorator.kt @@ -10,7 +10,6 @@ import androidx.navigation3.runtime.NavEntryDecorator import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.LocalTabBarPadding -import com.flipcash.app.core.navigation.asNavBarTab import com.getcode.navigation.core.CodeNavigator /** @@ -41,7 +40,7 @@ fun NavTabBarInsetEntryDecorator( val route = remember(entry.contentKey) { navigator.backStack.firstOrNull { it.toString() == entry.contentKey } as? AppRoute } - val isTabHome = route?.asNavBarTab() != null + val isTabHome = route is AppRoute.Tabs val padding = if (isTabHome) PaddingValues(bottom = tabBarHeight.value) else PaddingValues() CompositionLocalProvider(LocalTabBarPadding provides padding) { 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 3027814154..0f98384de1 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 @@ -54,16 +54,16 @@ class BuildNavGraphForLaunchTest { @Test fun `logged in without deeplink opens on the Chats tab`() { val result = build(AuthState.Ready)!! - assertEquals(listOf(AppRoute.Sheets.Tips()), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Chats), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) - assertEquals(listOf(AppRoute.Sheets.Tips()), result.resolvedBackStack()) + assertEquals(listOf(AppRoute.Tabs.Chats), 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) } @@ -71,7 +71,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) } @@ -80,7 +80,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) } @@ -91,10 +91,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) } @@ -102,7 +102,7 @@ class BuildNavGraphForLaunchTest { @Test fun `logged in with None action opens the Chats tab without deeplink routes`() { val result = buildReady(DeeplinkAction.None) - assertEquals(listOf(AppRoute.Sheets.Tips()), result.baseRoutes) + assertEquals(listOf(AppRoute.Tabs.Chats), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) } @@ -195,13 +195,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, TokenInfoEntry.Deeplink)) + listOf(AppRoute.Tabs.Wallet, AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink)) ) ) 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") } @@ -211,7 +211,7 @@ class BuildNavGraphForLaunchTest { val result = buildReady( DeeplinkAction.Navigate( listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Chats, AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(1, 2, 3, 4)))), ) ) @@ -219,10 +219,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 }) } @@ -231,7 +231,7 @@ class BuildNavGraphForLaunchTest { val result = buildReady( DeeplinkAction.Navigate( listOf( - AppRoute.Sheets.Menu, + AppRoute.Tabs.Menu, AppRoute.Menu.MyAccount, AppRoute.Verification( origin = AppRoute.Menu.MyAccount, @@ -245,7 +245,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 }) } @@ -253,7 +253,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/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt index 6daa5412b6..b0a203fe2d 100644 --- a/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt +++ b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt @@ -114,7 +114,7 @@ class BaselineProfileGenerator { // The chats tab lists conversations; open the first one and send a text message. A // message is fund-free (money-safety: never tap Send Cash / confirm a spend). The // conversation is not hardcoded — send_contact_row resolves to the first row. - openTab("nav_chats", "tips_screen") + openTab("nav_chats", "chats_screen") device.wait(Until.findObject(By.res("send_contact_row")), TIMEOUT)?.click() device.wait(Until.findObject(By.res("chat_screen")), LOGIN_TIMEOUT) @@ -132,7 +132,7 @@ class BaselineProfileGenerator { // Back to the conversation list, fling it for coverage, then back to the scanner. device.pressBack() - device.wait(Until.findObject(By.res("tips_screen")), TIMEOUT) + device.wait(Until.findObject(By.res("chats_screen")), TIMEOUT) device.waitForIdle() flingScroll("chat_list", Direction.UP, 2) flingScroll("chat_list", Direction.DOWN, 1) 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 91894db4e8..d3abefd2b2 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 @@ -110,8 +110,6 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class AppRestricted(val restrictionType: RestrictionType) : Main - @Serializable - data object Scanner : Main // TODO: is there a better place for this to live? @Serializable @@ -168,20 +166,36 @@ sealed interface AppRoute : NavKey, Parcelable { get() = steps } + /** + * The homes of the hoisted nav bar's tabs. They sit flat on the root backstack, so the bar stays + * visible over them, and a route list leading with one is applied as a tab switch (see + * `navigateAll`). `NavBarRoutes.kt` maps each to its `NavBarButton`. + */ @Serializable @Parcelize - sealed interface Sheets : AppRoute { + sealed interface Tabs : AppRoute { @Serializable - data class TokenSelection(val purpose: TokenPurpose) : Sheets + data object Scanner : Tabs + @Serializable - data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets + data object Wallet : Tabs + /** Tip DMs and groups. */ @Serializable - data class Tips(val resumed: Boolean = false): Sheets { - } + data object Chats : Tabs + /** Settings, plus the user's tip card. The nav bar labels it "You". */ @Serializable - data object Wallet : Sheets + data object Menu : Tabs + } + + @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 /** Full unified paged activity history — the "dive in" from the wallet's recent-activity preview. */ @Serializable @@ -197,9 +211,6 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class TransactionDetails(val id: ID) : Sheets - @Serializable - data object Menu : Sheets - @Serializable data object ShareApp : Sheets @@ -335,9 +346,8 @@ sealed interface AppRoute : NavKey, Parcelable { /** * Node 10127:117987 — the two ways to start a chat, reached from the "+" on the Chats list. * - * A top-level route rather than a step of the tipping flow, even though the Chats list it - * is reached from is one: the flow is a tab home, so a step pushed inside it keeps the tab - * bar. This covers it, the way [Chat] does. [FindByUsername] and [NewGroup] are pushed on + * A top-level route rather than something drawn inside [Tabs.Chats]: that is a tab home, so + * anything inside it keeps the tab bar. This covers it, the way [Chat] does. [FindByUsername] and [NewGroup] are pushed on * top of it for the same reason. */ @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..5088133f48 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 @@ -35,12 +34,12 @@ fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List = emp } /** - * True when [routes] leads with a route that is a tab home. + * True when [routes] leads with an [AppRoute.Tabs] home. * 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 + routes.firstOrNull() is AppRoute.Tabs /** * Navigate to multiple routes, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet]. @@ -50,9 +49,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 route 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 +98,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. + * [AppRoute.Tabs] homes are not sheets, so they stay flat on the root backstack (which keeps the + * hoisted nav bar visible and lets back/pop behave like a tab stack). A sheet later in the list + * still wraps, along with everything after it. */ fun resolveRoutes(routes: List): List { if (routes.isEmpty()) return emptyList() @@ -112,11 +109,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 d11582d4fb..394b164aab 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,7 +10,7 @@ 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 /** * The tab a cold launch opens on when no deeplink directs it elsewhere: Chats. Built from the tab's 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..ba5bcc7676 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 @@ -9,20 +9,18 @@ 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 - // The chats tab routes through the tipping flow seeded at the list. - NavBarButton.Chats -> AppRoute.Sheets.Tips(resumed = false) + NavBarButton.Scanner -> AppRoute.Tabs.Scanner + NavBarButton.Wallet -> AppRoute.Tabs.Wallet + NavBarButton.Chats -> AppRoute.Tabs.Chats // 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 + AppRoute.Tabs.Scanner -> NavBarButton.Scanner + AppRoute.Tabs.Wallet -> NavBarButton.Wallet + AppRoute.Tabs.Chats -> NavBarButton.Chats + AppRoute.Tabs.Menu -> NavBarButton.TipCard else -> null } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipResult.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipResult.kt deleted file mode 100644 index e6a9ff3a5e..0000000000 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipResult.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.flipcash.app.core.tipping - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable - -@Serializable -sealed interface TipResult : Parcelable { - @Parcelize - @Serializable - data object Success : TipResult - - @Parcelize - @Serializable - data object Canceled : TipResult -} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipStep.kt deleted file mode 100644 index d3b30dcfd7..0000000000 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipStep.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.flipcash.app.core.tipping - -import android.os.Parcelable -import com.getcode.navigation.NonDismissableRoute -import com.getcode.navigation.NonDraggableRoute -import com.getcode.navigation.flow.FlowStep -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable - -@Serializable -sealed interface TipStep : FlowStep, Parcelable { - - @Parcelize - @Serializable - data object Intro : TipStep - - @Parcelize - @Serializable - data object TipCard: TipStep - - @Parcelize - @Serializable - data object Tips : TipStep -} diff --git a/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand_large.xml b/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand_large.xml deleted file mode 100644 index 66f3de265f..0000000000 --- a/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand_large.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - 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 a1c1e42283..3ac2379b17 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..aee8c35743 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 @@ -6,30 +6,27 @@ import kotlin.test.assertEquals /** * The "You" tab is the menu (settings) surface. Mirrors iOS `YouTabRoutingTests`: the TipCard tab - * routes to the menu, and the menu route belongs to the TipCard tab — the tipping flow is now home - * only to the Chats tab. + * routes to the menu, and the menu route belongs to the TipCard tab. */ 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()) + fun `the Chats tab routes to the chat list`() { + assertEquals(AppRoute.Tabs.Chats, 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()) + fun `the chat list maps back to the Chats tab, never TipCard`() { + assertEquals(NavBarButton.Chats, AppRoute.Tabs.Chats.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 e3578ecf20..6151e29d58 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 @@ -221,7 +221,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)) } } } 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 d48645eb35..4573b0e28a 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 @@ -110,13 +110,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, TokenInfoEntry.Deeplink) ) // Scanned tip-DM code — same destination as the // /tip/chat/{id} deeplink. is DeeplinkType.TipChat -> listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Chats, AppRoute.Messaging.Chat(deeplink.identifier), ) else -> emptyList() @@ -209,7 +209,7 @@ internal fun Scanner() { session.tipCardEvents.collect { event -> when (event) { TipCardEvent.OwnCardScanned -> - navigator.navigateAll(listOf(AppRoute.Sheets.Menu)) + navigator.navigateAll(listOf(AppRoute.Tabs.Menu)) } } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt similarity index 91% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt rename to apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 1285641bbe..526951387b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -29,18 +29,18 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.data.isLoaded import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.app.tipping.internal.TipFlowViewModel +import com.flipcash.app.tipping.internal.ChatsViewModel import com.flipcash.app.tipping.internal.components.TipChatRow import com.flipcash.features.tipping.R import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -49,18 +49,16 @@ import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.theme.ScaffoldBarPlacement /** - * The tip DM conversation list — always a step in the tipping [TippingFlowScreen] flow, so it shares - * the flow's [TipFlowViewModel]. It is the "Chats" root tab: the standard centred screen title over - * the list, no dismiss affordance (the root nav bar is the chrome); the tip card lives on its own - * tab. + * The "Chats" root tab: tip DMs and groups under the standard centred screen title, with no dismiss + * affordance (the root nav bar is the chrome). The tip card lives on the "You" tab. */ @Composable -fun TipsScreen() { - val viewModel = flowSharedViewModel() +fun ChatsScreen() { + val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current - val chats = state.tipChats + val chats = state.chats val listState = rememberLazyListState() CodeScaffold( @@ -90,8 +88,8 @@ fun TipsScreen() { // Start title sits at the inset and reads as off-centre against the Add button. titleAlignment = Alignment.CenterHorizontally, // Node 9442:5779 — the only way to start a chat with someone who has never paid - // you. A pushed route, not a step of this flow: this list is a tab home, and a step - // pushed inside it would leave the tab bar over the entry screen. + // you. A pushed route rather than an in-place screen: this list is a tab home, and + // anything drawn inside it would leave the tab bar over the entry screen. endContent = { AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) } }, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt deleted file mode 100644 index d971114d75..0000000000 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.flipcash.app.tipping - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.bills.ScannableRenderer -import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha -import com.flipcash.app.bills.components.cards.LocalTipCardColor -import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.tipping.internal.TipFlowViewModel -import com.getcode.navigation.flow.flowSharedViewModel - -/** - * The user's own tip card — a step in the tipping [TippingFlowScreen] flow, so it shares the flow's - * [TipFlowViewModel]. It is the post-profile-setup landing (the flow seeded at TipCard): the card - * full-bleed, no chrome. The primary home for the card is the "You" tab (the menu), which also owns - * settings. - */ -@Composable -fun TipCardScreen() { - val viewModel = flowSharedViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - Box(modifier = Modifier.fillMaxSize()) { - TipCardArt(card = state.tipCard, modifier = Modifier.fillMaxSize()) - } -} - -@Composable -private fun TipCardArt(card: Scannable.TipCard?, modifier: Modifier = Modifier) { - if (card == null) return - CompositionLocalProvider( - // Static display (no camera behind the card), so render it opaque at the design's flattened - // colour rather than the translucent frosted fill — the 36% alpha otherwise multiplies the - // app's Brand background and reads incorrectly. Figma flattens the card to rgb(16,16,17). - LocalTipCardColor provides Color(0xFF101011), - LocalTipCardBaseAlpha provides 1f, - ) { - Box(modifier = modifier, contentAlignment = Alignment.Center) { - ScannableRenderer(scannable = card) - } - } -} 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 deleted file mode 100644 index 083999085e..0000000000 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.flipcash.app.tipping - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tipping.TipResult -import com.flipcash.app.core.tipping.TipStep -import com.flipcash.app.tipping.internal.TipFlowViewModel -import com.flipcash.app.tipping.internal.screens.TipInfoScreen -import com.getcode.navigation.annotatedEntry -import com.getcode.navigation.flow.FlowExitReason -import com.getcode.navigation.flow.FlowHost -import com.getcode.navigation.flow.flowSharedViewModel -import com.getcode.navigation.results.NavResultStateRegistry -import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher -import com.getcode.ui.utils.rememberKeyboardController - -@Composable -fun TippingFlowScreen( - route: AppRoute.Sheets.Tips, - resultStateRegistry: NavResultStateRegistry, -) { - val sheetDismiss = LocalBottomSheetDismissDispatcher.current - val viewModel = hiltViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val keyboard = rememberKeyboardController() - - // How the flow was entered gates the step list (post-setup handoff → TipCard alone). - LaunchedEffect(route.resumed) { - viewModel.dispatchEvent(TipFlowViewModel.Event.OnResumed(route.resumed)) - } - - // `resumed` is deterministically the tip card, so seed it synchronously rather than waiting for - // the VM's async steps to resolve. Otherwise the tip-card tab renders the default first step - // (the chats list) for a frame — briefly visible when a tab switch crossfades into it. - val steps = if (route.resumed) listOf(TipStep.TipCard) else state.steps - - FlowHost( - steps = steps, - resumeAt = 0, - resultStateRegistry = resultStateRegistry, - // Proceeding off the end (TipCard's Done, or backing out at root) completes the flow. - completedResult = TipResult.Success, - onExit = { reason, _ -> - when (reason) { - is FlowExitReason.Completed, - FlowExitReason.BackedOutOfRoot, - FlowExitReason.Canceled -> keyboard.hideIfVisible { sheetDismiss() } - } - }, - entryProvider = tippingEntryProvider(), - ) -} - -private fun tippingEntryProvider(): (NavKey) -> NavEntry = entryProvider { - annotatedEntry { SyncStep(it); TipInfoScreen() } - annotatedEntry { SyncStep(it); TipCardScreen() } - annotatedEntry { SyncStep(it); TipsScreen() } -} - -@Composable -private fun SyncStep(step: TipStep) { - val viewModel = flowSharedViewModel() - LaunchedEffect(step) { - viewModel.dispatchEvent(TipFlowViewModel.Event.OnStepChanged(step)) - } -} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt new file mode 100644 index 0000000000..1daad4dcdf --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -0,0 +1,79 @@ +package com.flipcash.app.tipping.internal + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import com.flipcash.app.core.data.Loadable +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.toConversationReference +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +/** Backs the "Chats" tab: the user's tip DMs and groups. */ +@HiltViewModel +internal class ChatsViewModel @Inject constructor( + chatCoordinator: ChatCoordinator, + userManager: UserManager, + tokenCoordinator: TokenCoordinator, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State( + // Loading until the chat feed emits — distinguishes "still loading" from "loaded, none", so + // the empty state doesn't flash on top of a list that's about to arrive. + val chats: Loadable> = Loadable.Loading(), + ) + + sealed interface Event { + data class ChatsUpdated(val chats: Loadable>) : Event + } + + init { + fun conversations(summaries: List, tokens: List): List { + val selfId = userManager.accountId + val tokensByMint = tokens.associateBy { it.address } + return summaries.map { it.toConversationReference(selfId, tokensByMint, resources) } + } + + // On a cold launch the feed is usually built before this screen is, so draw it on the first + // frame rather than waiting for the collector below to get a turn on the main thread. + chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP)?.let { summaries -> + dispatchEvent( + Event.ChatsUpdated(Loadable.Loaded(conversations(summaries, tokenCoordinator.cachedTokens()))) + ) + } + + combine( + chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP), + tokenCoordinator.tokens, + ::conversations, + ) + // Off the main thread: on a cold launch the first mapping lands while the main thread + // is drawing the app's first screens, and waiting for it holds the Chats tab blank. + .flowOn(dispatchers.Default) + .onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }.launchIn(viewModelScope) + } + + internal companion object { + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.ChatsUpdated -> { state -> state.copy(chats = event.chats) } + } + } + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt deleted file mode 100644 index ea5c968248..0000000000 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.flipcash.app.tipping.internal - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.data.Loadable -import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.app.core.extensions.onResult -import com.flipcash.app.core.tipping.TipStep -import com.flipcash.app.shareable.ShareSheetController -import com.flipcash.app.shareable.Shareable -import com.flipcash.app.bills.share.TipCodePreviewCache -import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.features.tipping.R -import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.ChatCoordinator -import com.flipcash.shared.chat.ChatSummary -import com.flipcash.shared.chat.ui.ConversationReference -import com.flipcash.shared.chat.ui.toConversationReference -import com.flipcash.shared.tipping.TippingCoordinator -import com.getcode.opencode.model.financial.Token -import com.getcode.util.resources.ResourceHelper -import com.getcode.view.BaseViewModel -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -@HiltViewModel -internal class TipFlowViewModel @Inject constructor( - chatCoordinator: ChatCoordinator, - userManager: UserManager, - tippingCoordinator: TippingCoordinator, - tokenCoordinator: TokenCoordinator, - shareable: ShareSheetController, - tipCodePreviewCache: TipCodePreviewCache, - private val resources: ResourceHelper, - dispatchers: DispatcherProvider, -) : BaseViewModel( - initialState = State(), - updateStateForEvent = updateStateForEvent, -) { - - data class State( - /** - * The steps the flow opens on. - * - * This flow *is* the "Chats" root tab: it always shows the list (and its empty state) - * whatever the profile looks like, so a nameless account gets the same chrome as any other, - * and the claim-your-tip-card prompt lives on the You tab instead. Swapping a root tab out - * for a setup screen would also strand that screen's Close, which has no sheet to dismiss. - * The post-setup handoff is the one exception, and it is seeded from the route - * (see TippingFlowScreen). - */ - val steps: List = listOf(TipStep.Tips), - // true when re-entering right after the user-profile setup handoff (Tips(resumed = true)). - val resumed: Boolean = false, - val currentStep: TipStep? = null, - // Loading until the chat feed emits — distinguishes "still loading" from "loaded, none", so - // the empty state doesn't flash on top of a list that's about to arrive. - val tipChats: Loadable> = Loadable.Loading(), - val tipCard: Scannable.TipCard? = null, - ) - - sealed interface Event { - data class OnStepChanged(val step: TipStep) : Event - data class ChatsUpdated(val tips: Loadable>) : Event - - /** How the flow was entered — [resumed] is true for the post-setup handoff re-entry. */ - data class OnResumed(val resumed: Boolean) : Event - data class OnTipCardPopulated(val card: Scannable.TipCard) : Event - data object ShareTipCard: Event - } - - init { - // Rebuild the current user's tip card whenever their profile becomes available/changes. - userManager.state - .mapNotNull { it.userProfile } - .distinctUntilChanged() - .map { tippingCoordinator.resolveTipCard() } - .onResult(onSuccess = { card -> - dispatchEvent(Event.OnTipCardPopulated(card)) - // Render the Sharesheet preview eagerly so it's ready by the time the user taps share. - tippingCoordinator.currentUserId?.let { tipCodePreviewCache.prepare(it, card) } - }) - .launchIn(viewModelScope) - - fun conversations(summaries: List, tokens: List): List { - val selfId = userManager.accountId - val tokensByMint = tokens.associateBy { it.address } - return summaries.map { it.toConversationReference(selfId, tokensByMint, resources) } - } - - // On a cold launch the feed is usually built before this screen is, so draw it on the first - // frame rather than waiting for the collector below to get a turn on the main thread. - chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP)?.let { summaries -> - dispatchEvent( - Event.ChatsUpdated(Loadable.Loaded(conversations(summaries, tokenCoordinator.cachedTokens()))) - ) - } - - combine( - chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP), - tokenCoordinator.tokens, - ::conversations, - ) - // Off the main thread: on a cold launch the first mapping lands while the main thread - // is drawing the app's first screens, and waiting for it holds the Chats tab blank. - .flowOn(dispatchers.Default) - .onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { tippingCoordinator.currentUserId } - .map { userId -> - // Title shown above the link, e.g. "Tip X" (same label as the card). - val title = stateFlow.value.tipCard?.user?.displayName - ?.let { resources.getString(R.string.label_tipUser, it) } - // Attach the eagerly-rendered preview if it's ready; null shares the URL alone. - shareable.present( - Shareable.TipCard( - userId = userId, - preview = tipCodePreviewCache.get(userId), - title = title, - username = stateFlow.value.tipCard?.user?.username, - ) - ) - } - .launchIn(viewModelScope) - } - - internal companion object { - private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> - when (event) { - is Event.OnStepChanged -> { state -> state.copy(currentStep = event.step) } - is Event.ChatsUpdated -> { state -> state.copy(tipChats = event.tips) } - is Event.OnResumed -> { state -> state.copy(resumed = event.resumed) } - is Event.OnTipCardPopulated -> { state -> state.copy(tipCard = event.card) } - is Event.ShareTipCard -> { state -> state } - } - } - } -} 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 deleted file mode 100644 index 2d6ee799e8..0000000000 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.flipcash.app.tipping.internal.screens - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.userprofile.UpdateProfileStep -import com.flipcash.app.core.LocalUserManager -import com.flipcash.app.core.tipping.TipResult -import com.flipcash.app.core.tipping.TipStep -import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.core.R -import com.getcode.navigation.flow.LocalFlowNavigator -import com.getcode.navigation.flow.PreviewFlowNavigator -import com.getcode.navigation.flow.rememberFlowNavigator -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton -import com.getcode.ui.theme.CodeScaffold - -@Composable -internal fun TipInfoScreen() { - val flowNavigator = rememberFlowNavigator() - val userManager = LocalUserManager.current - CodeScaffold( - topBar = { - AppBarWithTitle( - endContent = { AppBarDefaults.Close { flowNavigator.exitCanceled() }}) - }, - bottomBar = { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .navigationBarsPadding() - .padding(vertical = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.action_startReceivingTips), - buttonState = ButtonState.Filled, - onClick = { - flowNavigator.navigate( - AppRoute.UpdateUserProfile( - origin = AppRoute.Sheets.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), - ) - ) - }, - ) - } - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(R.drawable.ic_tipping_hand_large), - contentDescription = null, - ) - - Text( - text = stringResource(R.string.title_tipIntro), - style = CodeTheme.typography.displaySmall, - color = CodeTheme.colors.textMain, - ) - Text( - modifier = Modifier - .padding(top = CodeTheme.dimens.grid.x2) - .padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.subtitle_tipIntro), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } - } - } -} - -@Preview -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun Preview_TipIntro() { - CompositionLocalProvider( - LocalFlowNavigator provides PreviewFlowNavigator(), - ) { - TipInfoScreen() - } -} 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 90a5f168b3..85f0971772 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 @@ -100,7 +100,7 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan onClaim = { if (session == null) context.onDismiss(PutInWallet) else if (session.claimReceivedFunds()) { - navigator.navigateAll(listOf(AppRoute.Sheets.Wallet)) + navigator.navigateAll(listOf(AppRoute.Tabs.Wallet)) } } ) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt index 5605aae7e2..5c7c879b64 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt @@ -26,7 +26,7 @@ sealed interface ChatTapTarget { /** * Whether a chat push can open its conversation, and by which link. * - * The Chats tab lists tip DMs and groups (`TipFlowViewModel`: `feed(TIP_DM, GROUP)`), and both open + * The Chats tab lists tip DMs and groups (`ChatsViewModel`: `feed(TIP_DM, GROUP)`), and both open * by chat id from there. A `CONTACT_DM` is not: the Send tab and direct-send flow that used to open * one were removed, and `AppRouter` deliberately routes nothing for them. * 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 9411ffd1bc..4621f02c30 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 @@ -119,7 +119,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, TokenInfoEntry.Deeplink), ), ) @@ -127,7 +127,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.Chats, AppRoute.Messaging.Chat(type.identifier)) ) // The same destination a group push tap resolves to (see ChatTapTargetPlanner): the @@ -135,7 +135,7 @@ internal class AppRouter( // a push land on one screen rather than on two that would have to agree. is DeeplinkType.GroupChatInvite -> DeeplinkAction.Navigate( listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Chats, AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(type.chatId)), ) ) @@ -149,7 +149,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 @@ -158,7 +158,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) } @@ -259,7 +259,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 6914edec2e..ad75cb0dcc 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 @@ -237,7 +237,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]) } @@ -338,7 +338,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) @@ -379,7 +379,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 @@ -409,7 +409,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 @@ -477,7 +477,7 @@ class AppRouterTest { 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 @@ -602,7 +602,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 @@ -649,7 +649,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. @@ -659,7 +659,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 @@ -710,7 +710,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 9ae91d888b..d79877ae90 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 @@ -40,7 +40,7 @@ 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( @@ -56,7 +56,7 @@ class NavigateToTest { @Test fun `navigateTo non-sheet routes navigates directly even with existing sheet`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) @@ -72,7 +72,7 @@ class NavigateToTest { @Test fun `navigateTo with existing sheet sets pendingSheetDismiss`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") @@ -90,7 +90,7 @@ class NavigateToTest { @Test fun `pendingSheetDismiss callback increments sheetGeneration`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val initialGeneration = navigator.sheetGeneration @@ -107,7 +107,7 @@ class NavigateToTest { @Test fun `pendingSheetDismiss callback navigates to new routes`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") @@ -130,7 +130,7 @@ class NavigateToTest { @Test fun `repeated dismiss-then-replace increments generation each time`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) @@ -153,7 +153,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) @@ -165,7 +165,7 @@ 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, listOf(AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink)), @@ -188,7 +188,7 @@ 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.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) @@ -211,7 +211,7 @@ class NavigateToTest { @Test fun `dismiss-replace increments sheetGeneration for composition scoping`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) @@ -234,7 +234,7 @@ class NavigateToTest { // 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.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) @@ -253,16 +253,16 @@ class NavigateToTest { @Test fun `double navigateTo with pending dismiss does not produce duplicate sheets`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) // 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) @@ -270,7 +270,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), ) @@ -285,7 +285,7 @@ class NavigateToTest { @Test fun `openAsSheet with existing sheet sets pendingSheetDismiss`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) @@ -299,7 +299,7 @@ class NavigateToTest { @Test fun `openAsSheet callback navigates to new sheet after dismiss`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) @@ -317,7 +317,7 @@ class NavigateToTest { @Test fun `openAsSheet increments sheetGeneration on dismiss-replace`() { val navigator = createNavigator( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val initialGeneration = navigator.sheetGeneration @@ -333,7 +333,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 93e7cfd542..340f1422e0 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 @@ -32,7 +32,7 @@ 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)) } @@ -92,14 +92,14 @@ class ResolveRoutesTest { @Test fun `routes before sheet stay on root backstack`() { val routes = listOf( - AppRoute.Main.Scanner, + AppRoute.Tabs.Scanner, AppRoute.Sheets.ActivityHistory, 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) @@ -141,7 +141,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, TokenInfoEntry.Deeplink), ) @@ -153,7 +153,7 @@ class ResolveRoutesTest { @Test fun `chats tab stays flat and the chat pushes on top`() { val routes = listOf( - AppRoute.Sheets.Tips(), + AppRoute.Tabs.Chats, AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(1, 2, 3, 4)))), ) @@ -165,7 +165,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, @@ -181,14 +181,14 @@ 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, ) 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) @@ -201,32 +201,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.Chats, 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, TokenInfoEntry.Deeplink)) + val base = listOf(AppRoute.Tabs.Wallet) + val deeplink = listOf(AppRoute.Tabs.Wallet, AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink)) 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/docs/architecture/features/README.md b/docs/architecture/features/README.md index b310b78041..35532f0936 100644 --- a/docs/architecture/features/README.md +++ b/docs/architecture/features/README.md @@ -36,7 +36,7 @@ Two recurring shapes show up in the **Pattern** column: | **scanner** | Camera capture of a peer's Kik Code (e.g. on a cash bill) | `scanner/ScannerScreen.kt` | Screen-local; uses `:ui:scanner`, routes scans through `Router.classify` | | **withdrawal** | Withdraw funds on-chain | `withdrawal/WithdrawalFlowScreen.kt` · `withdrawal/WithdrawalViewModel` | **Flow** (`AppRoute.Transfers.Withdrawal`); entry → destination → confirmation | | **deposit** | Add funds via on-ramp / deposit address | `deposit/DepositFlowScreen.kt` · `…/internal/DepositViewModel` | **Flow** (`FlowRouteWithResult`); USDC deposit info, Coinbase on-ramp | -| **tipping** | Tip card, tip chats, and tip amount entry | `tipping/TippingFlowScreen.kt`, `tipping/TipCardScreen.kt` · `…/internal/TipFlowViewModel` | **Flow** (`AppRoute.Sheets.Tips`); intro → tip card → tips steps | +| **tipping** | The Chats tab (tip DMs and groups), new chat, and new group | `tipping/ChatsScreen.kt` · `…/internal/ChatsViewModel` | **VM-driven** tab home (`AppRoute.Tabs.Chats`); combines the chat feed with tokens | | **transactions** | Activity / transaction history | `transactions/TransactionHistoryScreen.kt` · `…/internal/TransactionHistoryViewModel` | **VM-driven**; Paging-backed list from persistence ([05](../05-persistence.md)) | ## C. Currencies & tokens diff --git a/maestro/README.md b/maestro/README.md index 8ebe32d1cc..cccaa909b7 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -73,7 +73,7 @@ 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") { ... }`. diff --git a/maestro/blocking.yaml b/maestro/blocking.yaml index 1d9ebdc008..ee62e345dd 100644 --- a/maestro/blocking.yaml +++ b/maestro/blocking.yaml @@ -11,7 +11,7 @@ tags: # Open the tip conversation (the chats tab). - tapOn: { id: nav_chats } -- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } +- extendedWaitUntil: { visible: { id: chats_screen }, timeout: 8000 } # Pick the chat by participant rather than by position: the list is ordered by recency and # carries the account's own self-tip chat, so index 0 is not stably the contact. - scrollUntilVisible: @@ -26,7 +26,7 @@ tags: - tapOn: "Block" - assertVisible: "Block ${CONTACT_NAME}?" - tapOn: { text: "^Block$" } -- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } +- extendedWaitUntil: { visible: { id: chats_screen }, timeout: 8000 } # Verify they appear in My Account -> Blocked. Chats is a root tab, so the You tab is one tap # away rather than behind a sheet dismiss. 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..f75bfedd2a 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). +# A seed login with no deeplink lands on the Chats tab (AppRoute.Tabs.Chats). - extendedWaitUntil: visible: - id: "wallet_screen" + id: "chats_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" diff --git a/maestro/subflows/open_tip_chat.yaml b/maestro/subflows/open_tip_chat.yaml index f29a8bfa40..fc7554438b 100644 --- a/maestro/subflows/open_tip_chat.yaml +++ b/maestro/subflows/open_tip_chat.yaml @@ -4,7 +4,7 @@ appId: com.flipcash.app.android # The list is ordered by recency and carries the account's own self-tip chat, so index 0 is not # stably the contact — open it by name. - tapOn: { id: nav_chats } -- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } +- extendedWaitUntil: { visible: { id: chats_screen }, timeout: 8000 } - assertVisible: { id: send_contact_row } - scrollUntilVisible: element: "${CONTACT_NAME}" diff --git a/maestro/tipping_setup.yaml b/maestro/tipping_setup.yaml index ac5df7fb4f..c56cabbabc 100644 --- a/maestro/tipping_setup.yaml +++ b/maestro/tipping_setup.yaml @@ -13,7 +13,7 @@ tags: # Tipping is on by default -> the chats tab is present. - assertVisible: { id: nav_chats } - tapOn: { id: nav_chats } -- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } +- extendedWaitUntil: { visible: { id: chats_screen }, timeout: 8000 } # The display name is captured during onboarding, so tip setup lands directly on the # Tips list — no name step here. Open the tip card from the list.