Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,32 +82,34 @@ fun appEntryProvider(
OnboardingFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}

// Main
annotatedEntry<AppRoute.Main.Sheet> { key ->
SheetContent(key, resultStateRegistry, barManager)
}
// Main — pushed onto whatever stack the user is already on
annotatedEntry<AppRoute.Main.AppRestricted> { key -> AppRestrictedScreen(key.restrictionType) }
annotatedEntry<AppRoute.Main.Scanner> { ScannerScreen() }
annotatedEntry<AppRoute.Main.RegionSelection> { RegionSelectionScreen() }
annotatedEntry<AppRoute.Main.InviteContact> { 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<AppRoute.Sheets.Give>(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo) }
annotatedEntry<AppRoute.Sheets.Tips> { key ->
TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
annotatedEntry<AppRoute.Main.Give>(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo) }
annotatedEntry<AppRoute.Main.ActivityHistory> { ActivityHistoryScreen() }
annotatedEntry<AppRoute.Main.TransactionDetails> { key -> TransactionDetailsScreen(key.id) }
annotatedEntry<AppRoute.Main.InviteContact> { key -> InviteContactScreen(key.phoneNumber) }
annotatedEntry<AppRoute.Main.Sheet> { key ->
SheetContent(key, resultStateRegistry, barManager)
}
annotatedEntry<AppRoute.Sheets.TokenSelection> { key -> TokenSelectScreen(key.purpose) }
annotatedEntry<AppRoute.Sheets.TipAmountEntry> { TipAmountEntryScreen() }
annotatedEntry<AppRoute.Sheets.Wallet> {

// Tabs — the nav bar's four homes; entering one replaces the stack
annotatedEntry<AppRoute.Tabs.Scanner> { ScannerScreen() }
annotatedEntry<AppRoute.Tabs.Wallet> {
// 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<AppRoute.Tabs.Tips> { key ->
TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Tabs.Menu> { MenuScreen() }

// Sheets — inner content, wrapped in Main.Sheet by resolveRoutes
annotatedEntry<AppRoute.Sheets.TokenSelection> { key -> TokenSelectScreen(key.purpose) }
annotatedEntry<AppRoute.Sheets.TipAmountEntry> { TipAmountEntryScreen() }
annotatedEntry<AppRoute.Sheets.ShareApp> { ShareAppScreen() }
annotatedEntry<AppRoute.Sheets.ActivityHistory> { ActivityHistoryScreen() }
annotatedEntry<AppRoute.Sheets.TransactionDetails> { key -> TransactionDetailsScreen(key.id) }
annotatedEntry<AppRoute.Sheets.Menu> { MenuScreen() }

// Messaging
annotatedEntry<AppRoute.Messaging.Chat> { key ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,24 @@ 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)
}

@Test
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)
}
Expand All @@ -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)
}
Expand All @@ -90,18 +90,18 @@ 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)
}

@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())
}

Expand Down Expand Up @@ -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<AppRoute.Token.Info>(stack[1])
assertTrue(stack.none { it is AppRoute.Main.Sheet }, "a tab home must never be wrapped in a sheet")
}
Expand All @@ -208,18 +208,18 @@ 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)))),
)
)
)

val stack = result.resolvedBackStack()
assertEquals(2, stack.size)
assertIs<AppRoute.Sheets.Tips>(stack[0])
assertIs<AppRoute.Tabs.Tips>(stack[0])
assertIs<AppRoute.Messaging.Chat>(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 })
}

Expand All @@ -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,
Expand All @@ -242,15 +242,15 @@ 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 })
}

@Test
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -39,8 +38,7 @@ fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List<AppRoute> = 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<NavKey>): Boolean =
(routes.firstOrNull() as? AppRoute)?.asNavBarTab() != null
private fun leadsWithTab(routes: List<NavKey>): Boolean = routes.firstOrNull() is AppRoute.Tabs

/**
* Navigate to multiple routes, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet].
Expand All @@ -50,9 +48,8 @@ private fun leadsWithTab(routes: List<NavKey>): 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<NavKey>,
Expand Down Expand Up @@ -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<NavKey>): List<NavKey> {
if (routes.isEmpty()) return emptyList()
Expand All @@ -112,11 +108,6 @@ fun resolveRoutes(routes: List<NavKey>): List<NavKey> {
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<AppRoute>()
return before + AppRoute.Main.Sheet(sheetRoute, innerRoutes)
Expand Down
Loading
Loading