From b9638a5de8432f357800fd2a31aa29647d278e09 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 27 Apr 2026 18:51:22 -0400 Subject: [PATCH 1/8] feat: introduce AppRouter for centralised navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single @Observable @MainActor router that owns per-stack navigation paths and the top-level sheet presentation. Replaces ad-hoc per-screen @State flags and bindings with a generic API: push, pop, popToRoot, popLast, pushAny, setPath, present, dismissSheet, navigate. Per-stack paths are NavigationPath (type-erased) so a single stack can carry mixed Hashable types — Destination cases at the top level and sub-flow Hashable cases (e.g. WithdrawNavigationPath) below — without nested NavigationStacks. Cross-stack navigate(to:) presents the destination's owning sheet and replaces the path; CurrencyInfoScreen uses .id(mint) so leaf swaps rebuild with fresh @State, fixing the deeplink-replace bug. Every mutator logs at INFO via flipcash.router. - BalanceScreen, SettingsScreen, ScanScreen drive their stacks/sheets from the router; remove local navigation @State. - DeepLinkController routes currency links through router.navigate; Session.pendingCurrencyInfoMint is gone. - WithdrawScreen drops its inner NavigationStack and registers WithdrawNavigationPath destinations on the parent (Settings) stack; WithdrawViewModel pushes substeps through router callbacks. - CurrencyDiscoveryScreen drops its inner NavigationStack and pushes through the router. CurrencyInfoScreen drops the metadata: init. - Settings children all push: My Account, App Settings, Advanced Features, Beta, Account Selection, Application Logs, Access Key, Deposit, Withdraw — no more sheet/push mix. --- .../Deep Links/DeepLinkController.swift | 2 +- .../Navigation/AppRouter+Destination.swift | 70 ++++ .../AppRouter+DestinationView.swift | 122 ++++++ .../AppRouter+SheetPresentation.swift | 29 ++ .../Core/Navigation/AppRouter+Stack.swift | 38 ++ Flipcash/Core/Navigation/AppRouter.swift | 180 ++++++++ .../Core/Screens/Main/BalanceScreen.swift | 60 +-- .../Core/Screens/Main/CashReservesRow.swift | 4 +- .../CurrencyCreationSummaryScreen.swift | 9 +- .../CurrencyDiscoveryList.swift | 4 +- .../CurrencyDiscoveryScreen.swift | 68 +-- .../Currency Info/CurrencyInfoScreen.swift | 18 - .../Currency Info/CurrencyInfoViewModel.swift | 14 - Flipcash/Core/Screens/Main/ScanScreen.swift | 176 ++++---- .../SettingsAdvancedFeaturesScreen.swift | 41 ++ .../Settings/SettingsAppSettingsScreen.swift | 51 +++ .../Settings/SettingsMyAccountScreen.swift | 79 ++++ .../Core/Screens/Settings/SettingsRow.swift | 50 +++ .../Screens/Settings/SettingsScreen.swift | 386 +++--------------- .../Screens/Settings/SettingsToggle.swift | 35 ++ .../Screens/Settings/WithdrawScreen.swift | 74 ++-- .../Screens/Settings/WithdrawViewModel.swift | 43 +- Flipcash/Core/Session/Session.swift | 7 - .../Core/Session/SessionAuthenticator.swift | 3 + .../Navigation/AppRouterCrossStackTests.swift | 113 +++++ FlipcashTests/Navigation/AppRouterTests.swift | 143 +++++++ .../Regression_native_amount_mismatch.swift | 2 - .../TestSupport/AppRouter+TestSupport.swift | 21 + .../WithdrawViewModel+TestSupport.swift | 1 - FlipcashTests/WithdrawViewModelTests.swift | 2 - 30 files changed, 1234 insertions(+), 611 deletions(-) create mode 100644 Flipcash/Core/Navigation/AppRouter+Destination.swift create mode 100644 Flipcash/Core/Navigation/AppRouter+DestinationView.swift create mode 100644 Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift create mode 100644 Flipcash/Core/Navigation/AppRouter+Stack.swift create mode 100644 Flipcash/Core/Navigation/AppRouter.swift create mode 100644 Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift create mode 100644 Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift create mode 100644 Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift create mode 100644 Flipcash/Core/Screens/Settings/SettingsRow.swift create mode 100644 Flipcash/Core/Screens/Settings/SettingsToggle.swift create mode 100644 FlipcashTests/Navigation/AppRouterCrossStackTests.swift create mode 100644 FlipcashTests/Navigation/AppRouterTests.swift create mode 100644 FlipcashTests/TestSupport/AppRouter+TestSupport.swift diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 8bf0025f5..6afe22636 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -212,7 +212,7 @@ struct DeepLinkAction { case .currencyInfo(let mint): if case .loggedIn(let container) = sessionAuthenticator.state { Analytics.deeplinkRouted(kind: kind) - container.session.pendingCurrencyInfoMint = mint + container.appRouter.navigate(to: .currencyInfo(mint)) } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift new file mode 100644 index 000000000..ddaf0da12 --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -0,0 +1,70 @@ +// +// AppRouter+Destination.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import Foundation +import FlipcashCore + +extension AppRouter { + + /// A type-erased push target. Every screen reachable via a NavigationStack + /// push (anywhere in the app) is a case here. + enum Destination: Hashable, Sendable, CustomStringConvertible { + + // Wallet flow + case currencyInfo(PublicKey) + case discoverCurrencies + case currencyCreationSummary + case currencyCreationWizard + case transactionHistory(PublicKey) + + // Settings flow + case settingsMyAccount + case settingsAdvancedFeatures + case settingsAppSettings + case settingsBetaFlags + case settingsAccountSelection + case settingsApplicationLogs + case accessKey + case depositCurrencyList + case withdraw + + /// The stack this destination naturally belongs in. Cross-stack + /// navigation uses this to know which sheet to present. + var owningStack: Stack { + switch self { + case .currencyInfo, .discoverCurrencies, .currencyCreationSummary, + .currencyCreationWizard, .transactionHistory: + return .balance + case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, + .settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs, + .accessKey, .depositCurrencyList, .withdraw: + return .settings + } + } + + /// Stable string for log filtering. Deliberately omits associated values + /// so PublicKey base58 strings never end up in interpolated log messages. + var description: String { + switch self { + case .currencyInfo: "currencyInfo" + case .discoverCurrencies: "discoverCurrencies" + case .currencyCreationSummary: "currencyCreationSummary" + case .currencyCreationWizard: "currencyCreationWizard" + case .transactionHistory: "transactionHistory" + case .settingsMyAccount: "settingsMyAccount" + case .settingsAdvancedFeatures: "settingsAdvancedFeatures" + case .settingsAppSettings: "settingsAppSettings" + case .settingsBetaFlags: "settingsBetaFlags" + case .settingsAccountSelection: "settingsAccountSelection" + case .settingsApplicationLogs: "settingsApplicationLogs" + case .accessKey: "accessKey" + case .depositCurrencyList: "depositCurrencyList" + case .withdraw: "withdraw" + } + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift new file mode 100644 index 000000000..b7ce73e39 --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -0,0 +1,122 @@ +// +// AppRouter+DestinationView.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import FlipcashCore + +/// Renders an `AppRouter.Destination` as the corresponding screen. Single +/// destination → view map for the whole app. Adding a new destination case +/// requires a new arm here; the exhaustive switch enforces this at compile time. +struct DestinationView: View { + + let destination: AppRouter.Destination + let container: Container + let sessionContainer: SessionContainer + + var body: some View { + switch destination { + + // MARK: - Wallet flow + + case .currencyInfo(let mint): + // `.id(mint)` forces a fresh view identity (and thus fresh `@State`, + // including a fresh `CurrencyInfoViewModel`) whenever the mint + // changes. Without it, SwiftUI reuses the existing view at the same + // navigation depth — same struct type, same position — and the + // viewModel keeps the old token's data, so deeplinks that replace + // `[.currencyInfo(A)]` with `[.currencyInfo(B)]` show stale UI. + CurrencyInfoScreen( + mint: mint, + container: container, + sessionContainer: sessionContainer + ) + .id(mint) + + case .discoverCurrencies: + CurrencyDiscoveryScreen( + container: container, + sessionContainer: sessionContainer + ) + + case .currencyCreationSummary: + CurrencyCreationSummaryScreen() + + case .currencyCreationWizard: + CurrencyCreationWizardScreen( + state: CurrencyCreationState(), + sessionContainer: sessionContainer + ) + + case .transactionHistory(let mint): + TransactionHistoryScreen(mint: mint) + + // MARK: - Settings flow + + case .settingsMyAccount: + SettingsMyAccountScreen( + container: container, + sessionContainer: sessionContainer + ) + + case .settingsAdvancedFeatures: + SettingsAdvancedFeaturesScreen() + + case .settingsAppSettings: + SettingsAppSettingsScreen() + + case .settingsBetaFlags: + BetaFlagsScreen(container: container) + + case .settingsAccountSelection: + // The action closure dismisses the settings sheet and switches accounts. + // Captured at the modifier site so the AppRouter stays pure-navigation. + AccountSelectionScreen( + sessionAuthenticator: container.sessionAuthenticator, + action: { [appRouter = sessionContainer.appRouter, sessionAuthenticator = container.sessionAuthenticator] account in + Task { @MainActor in + appRouter.dismissSheet() + try? await Task.delay(milliseconds: 250) + sessionAuthenticator.switchAccount(to: account.account.mnemonic) + } + } + ) + + case .settingsApplicationLogs: + ApplicationLogsScreen() + + case .accessKey: + AccessKeyBackupScreen(mnemonic: sessionContainer.session.keyAccount.mnemonic) + .navigationTitle("Access Key") + .navigationBarTitleDisplayMode(.inline) + + case .depositCurrencyList: + DepositCurrencyListScreen() + + case .withdraw: + WithdrawScreen( + container: container, + sessionContainer: sessionContainer + ) + } + } +} + +extension View { + + /// Attaches the app-wide destination → view map to a NavigationStack. + /// Apply on the root content view of every NavigationStack(path:) bound to + /// `$router[.]`. + func appRouterDestinations(container: Container, sessionContainer: SessionContainer) -> some View { + navigationDestination(for: AppRouter.Destination.self) { destination in + DestinationView( + destination: destination, + container: container, + sessionContainer: sessionContainer + ) + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift new file mode 100644 index 000000000..e767fdc8d --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -0,0 +1,29 @@ +// +// AppRouter+SheetPresentation.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import Foundation + +extension AppRouter { + + /// Identifies the top-level modal sheet currently overlaying `ScanScreen`. + /// One sheet at a time; switching sheets dismisses the previous. + enum SheetPresentation: Identifiable, Hashable, Sendable, CustomStringConvertible { + case balance + case settings + case give + + var id: Self { self } + + var description: String { + switch self { + case .balance: "balance" + case .settings: "settings" + case .give: "give" + } + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift new file mode 100644 index 000000000..2ca125c06 --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -0,0 +1,38 @@ +// +// AppRouter+Stack.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import Foundation + +extension AppRouter { + + /// Identifies one of the app's top-level NavigationStacks. Used as the + /// per-stack key for path storage and to look up which sheet a destination + /// surfaces in. + enum Stack: Hashable, CaseIterable, Sendable, CustomStringConvertible { + case balance + case settings + case give + + /// The sheet a stack is presented in. Cross-stack navigation uses + /// this to know which top-level modal to surface. + var sheet: SheetPresentation { + switch self { + case .balance: .balance + case .settings: .settings + case .give: .give + } + } + + var description: String { + switch self { + case .balance: "balance" + case .settings: "settings" + case .give: "give" + } + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift new file mode 100644 index 000000000..30972edf7 --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -0,0 +1,180 @@ +// +// AppRouter.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import Foundation +import SwiftUI +import FlipcashCore + +private let logger = Logger(label: "flipcash.router") + +/// Centralised navigation state for the app. Each top-level modal sheet +/// (`SheetPresentation`) owns a NavigationStack whose path is stored here +/// per `Stack`. Sheet swaps preserve other stacks' paths so reopening a +/// previously-visible sheet restores its state. +/// +/// Paths are stored as `NavigationPath` (type-erased) so a single stack +/// can carry destinations of more than one Hashable type — for example, +/// the Settings stack carries `Destination` cases at the top level and +/// `WithdrawNavigationPath` cases for the multi-step withdraw flow. +/// This avoids nested `NavigationStack`s, which crash with +/// `comparisonTypeMismatch` on push/pop/push cycles. +/// +/// All mutators log at INFO via `flipcash.router`. The bindable subscript +/// funnels SwiftUI's automatic writes (e.g., swipe-back) through `setPath`, +/// so every observable state change produces exactly one log line. +@MainActor +@Observable +final class AppRouter { + + private(set) var presentedSheet: SheetPresentation? + + private var paths: [Stack: NavigationPath] = [:] + + init() {} + + /// Bindable per-stack `NavigationPath`. Drives `NavigationStack(path: $router[.balance])`. + /// Writes funnel through the binding-setter `setPath` so SwiftUI's automatic + /// mutations (e.g., swipe-back) also log. + subscript(stack: Stack) -> NavigationPath { + get { paths[stack, default: NavigationPath()] } + set { setPath(newValue, on: stack) } + } + + // MARK: - Stack mutators + + func push(_ destination: Destination, on stack: Stack) { + paths[stack, default: NavigationPath()].append(destination) + logger.info("Pushed destination", metadata: [ + "stack": "\(stack)", + "destination": "\(destination)", + "depth": "\(paths[stack, default: NavigationPath()].count)", + ]) + } + + /// Pushes any Hashable value onto the stack. Used by sub-flows whose + /// destination types live outside `AppRouter.Destination` (e.g., + /// `WithdrawNavigationPath`), so a single stack can carry mixed types + /// without nesting `NavigationStack`s. + func pushAny(_ value: H, on stack: Stack) { + paths[stack, default: NavigationPath()].append(value) + logger.info("Pushed sub-flow destination", metadata: [ + "stack": "\(stack)", + "type": "\(type(of: value))", + "depth": "\(paths[stack, default: NavigationPath()].count)", + ]) + } + + func pop(on stack: Stack) { + guard !(paths[stack]?.isEmpty ?? true) else { return } + paths[stack]?.removeLast() + logger.info("Popped destination", metadata: [ + "stack": "\(stack)", + "newDepth": "\(paths[stack, default: NavigationPath()].count)", + ]) + } + + func popToRoot(on stack: Stack) { + guard !(paths[stack]?.isEmpty ?? true) else { return } + paths[stack] = NavigationPath() + logger.info("Popped to root", metadata: ["stack": "\(stack)"]) + } + + /// Pops up to `count` items from the top of `stack`. Used by sub-flows + /// (e.g., `WithdrawViewModel.popToEnterAmount`) that need to unwind a + /// known number of substeps. + func popLast(_ count: Int, on stack: Stack) { + let available = paths[stack, default: NavigationPath()].count + let actual = min(count, available) + guard actual > 0 else { return } + for _ in 0..")", + ]) + } + + /// SwiftUI binding setter — receives an updated `NavigationPath` whose + /// length may differ from our last known state (e.g., the user popped + /// via swipe-back). + private func setPath(_ newPath: NavigationPath, on stack: Stack) { + let oldPath = paths[stack, default: NavigationPath()] + guard oldPath != newPath else { return } + paths[stack] = newPath + logger.info("Replaced path", metadata: [ + "stack": "\(stack)", + "previousDepth": "\(oldPath.count)", + "newDepth": "\(newPath.count)", + ]) + } + + // MARK: - Sheet mutators + + /// Presents `sheet`. Idempotent: no-op if already presenting `sheet`. + func present(_ sheet: SheetPresentation) { + guard sheet != presentedSheet else { return } + let previous = presentedSheet + presentedSheet = sheet + logger.info("Presented sheet", metadata: [ + "sheet": "\(sheet)", + "previousSheet": "\(previous.map(String.init(describing:)) ?? "")", + ]) + } + + func dismissSheet() { + guard let dismissing = presentedSheet else { return } + presentedSheet = nil + logger.info("Dismissed sheet", metadata: ["sheet": "\(dismissing)"]) + } + + // MARK: - Cross-stack navigation + + /// Cross-stack navigation. Presents the destination's `owningStack` (swapping + /// the current sheet if different) and sets `[destination]` as the only + /// path entry on that stack. Other stacks' paths are preserved underneath. + /// + /// Call this from deeplinks, push notifications, and any programmatic + /// redirect that should land the user *on* the destination regardless of + /// where they currently are. + /// + /// > Note: `DestinationView` applies `.id(mint)` to `CurrencyInfoScreen` + /// > so leaf swaps from `[currencyInfo(A)]` to `[currencyInfo(B)]` rebuild + /// > the destination with fresh `@State` rather than reusing the previous + /// > view's view model. + func navigate(to destination: Destination) { + let targetStack = destination.owningStack + let targetSheet = targetStack.sheet + + var expected = NavigationPath() + expected.append(destination) + let alreadyThere = presentedSheet == targetSheet + && paths[targetStack, default: NavigationPath()] == expected + guard !alreadyThere else { return } + + present(targetSheet) + setPath([destination], on: targetStack) + } +} diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift index db5e72b2e..0864602d0 100644 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ b/Flipcash/Core/Screens/Main/BalanceScreen.swift @@ -10,9 +10,8 @@ import FlipcashUI import FlipcashCore struct BalanceScreen: View { - - @Binding var isPresented: Bool - + + @Environment(AppRouter.self) private var router @Environment(RatesController.self) private var ratesController @Environment(HistoryController.self) private var historyController @Environment(NotificationController.self) private var notificationController @@ -20,9 +19,6 @@ struct BalanceScreen: View { let session: Session - @State private var isShowingCurrencyDiscovery: Bool = false - @State private var selectedMint: PublicKey? - /// Owned, mutable source for the LazyVStack. Reorder animations only fire /// when the data source is mutated inside an active animation transaction — /// a body-time computed property doesn't satisfy that. @@ -73,26 +69,26 @@ struct BalanceScreen: View { let amount = FiatAmount(value: abs(totalAppreciation), currency: balanceRate.currency) return (amount, isPositive) } - + // MARK: - Init - - - init(isPresented: Binding, container: Container, sessionContainer: SessionContainer) { - self._isPresented = isPresented + + init(container: Container, sessionContainer: SessionContainer) { self.container = container self.sessionContainer = sessionContainer self.session = sessionContainer.session } - + // MARK: - Lifecycle - private func onAppear() { historyController.sync() } - + // MARK: - Body - - + var body: some View { - NavigationStack { + @Bindable var router = router + NavigationStack(path: $router[.balance]) { Background(color: .backgroundMain) { VStack(spacing: 0) { list() @@ -101,24 +97,14 @@ struct BalanceScreen: View { .onAppear(perform: onAppear) .onChange(of: session.balances, initial: true) { _, _ in refreshSortedBalances() } .onChange(of: balanceRate) { _, _ in refreshSortedBalances() } - .onChange(of: session.pendingCurrencyInfoMint, initial: true) { _, mint in - guard let mint else { return } - Analytics.tokenInfoOpened(from: .openedFromDeeplink, mint: mint) - selectedMint = mint - session.pendingCurrencyInfoMint = nil - } .navigationTitle("Wallet") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(item: $selectedMint) { mint in - CurrencyInfoScreen( - mint: mint, - container: container, - sessionContainer: sessionContainer - ) - } + .appRouterDestinations(container: container, sessionContainer: sessionContainer) .toolbar { ToolbarItem(placement: .topBarTrailing) { - ToolbarCloseButton(binding: $isPresented) + ToolbarCloseButton { + router.dismissSheet() + } } } .onChange(of: notificationController.pushWillPresent) { _, _ in @@ -126,14 +112,8 @@ struct BalanceScreen: View { historyController.sync() } } - .sheet(isPresented: $isShowingCurrencyDiscovery) { - CurrencyDiscoveryScreen( - container: container, - sessionContainer: sessionContainer - ) - } } - + @ViewBuilder private func emptyState() -> some View { VStack(spacing: 10) { Text("No Balance Yet") @@ -146,7 +126,7 @@ struct BalanceScreen: View { .frame(maxWidth: .infinity, alignment: .center) BubbleButton(text: "Discover Currencies") { - isShowingCurrencyDiscovery = true + router.push(.discoverCurrencies, on: .balance) } .padding(.top, 8) } @@ -178,7 +158,7 @@ struct BalanceScreen: View { ForEach(currencyBalances) { balance in CurrencyBalanceRow(exchangedBalance: balance) { Analytics.tokenInfoOpened(from: .openedFromWallet, mint: balance.stored.mint) - selectedMint = balance.stored.mint + router.push(.currencyInfo(balance.stored.mint), on: .balance) } .vSeparator(color: .rowSeparator) .matchedGeometryEffect(id: balance.id, in: balanceRowNamespace) @@ -188,7 +168,9 @@ struct BalanceScreen: View { CashReservesRow( reservesBalance: reservesBalance, showTopDivider: currencyBalances.isEmpty, - selectedMint: $selectedMint + onTap: { + router.push(.currencyInfo(reservesBalance.stored.mint), on: .balance) + } ) } } else { @@ -197,7 +179,7 @@ struct BalanceScreen: View { } footer: { if hasBalances { Button("Discover Currencies") { - isShowingCurrencyDiscovery = true + router.push(.discoverCurrencies, on: .balance) } .buttonStyle(.filled) .padding(.horizontal, 20) diff --git a/Flipcash/Core/Screens/Main/CashReservesRow.swift b/Flipcash/Core/Screens/Main/CashReservesRow.swift index 08b3ba7b1..ebf61b37e 100644 --- a/Flipcash/Core/Screens/Main/CashReservesRow.swift +++ b/Flipcash/Core/Screens/Main/CashReservesRow.swift @@ -10,12 +10,12 @@ import FlipcashUI struct CashReservesRow: View { let reservesBalance: ExchangedBalance let showTopDivider: Bool - @Binding var selectedMint: PublicKey? + let onTap: () -> Void var body: some View { Button { Analytics.tokenInfoOpened(from: .openedFromWallet, mint: reservesBalance.stored.mint) - selectedMint = reservesBalance.stored.mint + onTap() } label: { HStack(spacing: 8) { Text("USDF") diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift index 748201542..ed86c2c0d 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift @@ -8,6 +8,7 @@ import FlipcashCore import FlipcashUI struct CurrencyCreationSummaryScreen: View { + @Environment(AppRouter.self) private var router @Environment(Session.self) private var session @Environment(RatesController.self) private var ratesController @@ -49,9 +50,11 @@ struct CurrencyCreationSummaryScreen: View { Spacer() - NavigationLink("Get Started", value: CurrencyCreationStep.wizard) - .buttonStyle(.filled) - .padding(.bottom, 20) + Button("Get Started") { + router.push(.currencyCreationWizard, on: .balance) + } + .buttonStyle(.filled) + .padding(.bottom, 20) } .padding(.horizontal, 20) } diff --git a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift index efc9b3939..e1aeff5bb 100644 --- a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift +++ b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift @@ -11,7 +11,7 @@ struct CurrencyDiscoveryList: View { @Binding var mintsByCategory: [DiscoverCategory: [MintMetadata]] @Binding var selectedCategory: DiscoverCategory - @Binding var selectedMint: PublicKey? + let onSelectMint: (PublicKey) -> Void @State private var failedCategories: Set = [] @@ -43,7 +43,7 @@ struct CurrencyDiscoveryList: View { } else { ForEach(mints.indexed(), id: \.element.address) { item in Button { - selectedMint = item.element.address + onSelectMint(item.element.address) } label: { CurrencyDiscoveryRow(rank: item.index + 1, mint: item.element) } diff --git a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift index 4f001aea4..d43397d95 100644 --- a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift @@ -8,66 +8,36 @@ import FlipcashCore import FlipcashUI struct CurrencyDiscoveryScreen: View { + + @Environment(AppRouter.self) private var router + let container: Container let sessionContainer: SessionContainer - @Environment(\.dismiss) private var dismiss - @State private var mintsByCategory: [DiscoverCategory: [MintMetadata]] = [:] @State private var selectedCategory: DiscoverCategory = .popular - @State private var selectedMint: PublicKey? - @State private var creationState = CurrencyCreationState() var body: some View { - NavigationStack { - ZStack { - CurrencyDiscoveryList( - container: container, - mintsByCategory: $mintsByCategory, - selectedCategory: $selectedCategory, - selectedMint: $selectedMint - ) + ZStack { + CurrencyDiscoveryList( + container: container, + mintsByCategory: $mintsByCategory, + selectedCategory: $selectedCategory, + onSelectMint: { mint in + router.push(.currencyInfo(mint), on: .balance) + } + ) - if mintsByCategory[selectedCategory] != nil { - CurrencyInfoFooter { - NavigationLink("Create Your Own Currency", value: CurrencyCreationStep.summary) - .buttonStyle(.filled) + if mintsByCategory[selectedCategory] != nil { + CurrencyInfoFooter { + Button("Create Your Own Currency") { + router.push(.currencyCreationSummary, on: .balance) } - } - } - .navigationTitle("Currencies") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - ToolbarCloseButton(action: dismiss.callAsFunction) - } - } - .navigationDestination(for: CurrencyCreationStep.self) { step in - switch step { - case .summary: - CurrencyCreationSummaryScreen() - case .wizard: - CurrencyCreationWizardScreen( - state: creationState, - sessionContainer: sessionContainer - ) - } - } - .navigationDestination(item: $selectedMint) { mintAddress in - if let metadata = mintsByCategory[selectedCategory]?.first(where: { $0.address == mintAddress }) { - CurrencyInfoScreen( - metadata: metadata, - container: container, - sessionContainer: sessionContainer - ) - } else { - CurrencyInfoScreen( - mint: mintAddress, - container: container, - sessionContainer: sessionContainer - ) + .buttonStyle(.filled) } } } + .navigationTitle("Currencies") + .navigationBarTitleDisplayMode(.inline) } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 54651a79c..4cf60cc0f 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -103,24 +103,6 @@ struct CurrencyInfoScreen: View { ) } - /// Creates the screen with pre-fetched metadata for instant display. - /// The title and icon render immediately; a background refresh still runs - /// via ``CurrencyInfoViewModel/loadMintMetadata()`` to pick up any updates. - init(metadata: MintMetadata, container: Container, sessionContainer: SessionContainer) { - self.init( - mint: metadata.address, - viewModel: CurrencyInfoViewModel( - metadata: metadata, - session: sessionContainer.session, - database: sessionContainer.database, - ratesController: sessionContainer.ratesController - ), - container: container, - sessionContainer: sessionContainer, - showFundingOnAppear: false - ) - } - // MARK: - Body - var body: some View { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index e72610b49..605b6fc88 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -148,20 +148,6 @@ class CurrencyInfoViewModel { } } - /// Initializes with pre-fetched metadata for instant display. Converts - /// the ``MintMetadata`` to ``StoredMintMetadata`` and starts in the - /// `.loaded` state — no loading spinner is shown. - init(metadata: MintMetadata, session: Session, database: Database, ratesController: RatesController) { - self.mint = metadata.address - self.session = session - self.database = database - self.ratesController = ratesController - - let stored = StoredMintMetadata(metadata) - setupUpdateable(with: stored) - loadingState = .loaded(stored, metadata) - } - func loadMintMetadata() async { // If already loaded from cache, no need to show loading state let wasAlreadyLoaded = isLoaded diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 05c272ed2..25df13518 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -14,18 +14,16 @@ struct ScanScreen: View { @Environment(SessionAuthenticator.self) private var sessionAuthenticator @Environment(Preferences.self) private var preferences @Environment(BetaFlags.self) private var betaFlags - + @Environment(AppRouter.self) private var router + @Bindable private var session: Session - + @State private var viewModel: ScanViewModel @State private var giveViewModel: GiveViewModel - + @State private var cameraAuthorizer = CameraAuthorizer() - - @State private var isShowingBalance: Bool = false - @State private var isShowingSettings: Bool = false - + @State private var sendButtonState: ButtonState = .normal @State private var sendButtonTask: Task? @@ -137,6 +135,24 @@ struct ScanScreen: View { } .interactiveDismissDisabled() } + // Swipe-to-dismiss writes nil through this binding; route through + // `dismissSheet()` so the dismissal is logged. Programmatic presentations + // go through `router.present(_:)` directly and never write through here. + .sheet(item: Binding( + get: { router.presentedSheet }, + set: { newValue in + if newValue == nil { + router.dismissSheet() + } + } + )) { sheet in + RoutedSheet( + sheet: sheet, + container: container, + sessionContainer: sessionContainer, + giveViewModel: giveViewModel + ) + } .dialog(item: $giveViewModel.dialogItem) // Dismiss all presented sheets when a bill is about to appear. // Bills render in ScanScreen's ZStack, so any sheet on top @@ -145,10 +161,19 @@ struct ScanScreen: View { // are always visible regardless of the current navigation state. .onChange(of: session.presentationState.isPresenting) { _, isPresenting in guard isPresenting else { return } - isShowingSettings = false - isShowingBalance = false + router.dismissSheet() giveViewModel.isPresented = false } + // Mirror GiveViewModel's business-logic flag to the router. The model + // owns "should the give flow be open" (it falls back to false on + // no-balance); the router owns presentation. Sync them here. + .onChange(of: giveViewModel.isPresented) { _, isPresented in + if isPresented { + router.present(.give) + } else if router.presentedSheet == .give { + router.dismissSheet() + } + } // Reset button state on bill dismissal — `sendButtonState` outlives individual bills. .onChange(of: session.billState.bill) { _, newBill in guard newBill == nil else { return } @@ -260,23 +285,14 @@ struct ScanScreen: View { Spacer() - GlassButton( - asset: .hamburger, - size: .regular, - binding: $isShowingSettings - ) - .accessibilityLabel("Settings") - .sheet(isPresented: $isShowingSettings) { - SettingsScreen( - isPresented: $isShowingSettings, - container: container, - sessionContainer: sessionContainer - ) + GlassButton(asset: .hamburger, size: .regular) { + router.present(.settings) } + .accessibilityLabel("Settings") } .padding(.horizontal, 20) } - + @ViewBuilder private func bottomBar() -> some View { HStack(alignment: .bottom) { LargeButton( @@ -286,57 +302,13 @@ struct ScanScreen: View { maxWidth: 80, maxHeight: 80, fullWidth: true, - aligment: .bottom, - binding: $giveViewModel.isPresented - ) - .sheet(isPresented: $giveViewModel.isPresented) { - NavigationStack { - GiveScreen(viewModel: giveViewModel) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - ToolbarCloseButton( - binding: $giveViewModel.isPresented - ) - } - } - } + aligment: .bottom + ) { + // Setting the model flag triggers the in-model balance check; + // the .onChange in `body` mirrors success to the router. + giveViewModel.isPresented = true } - -// LargeButton( -// title: "Send", -// image: .asset(.airplane), -// spacing: 12, -// maxWidth: 80, -// maxHeight: 80, -// fullWidth: true, -// badgeInsets: .init(top: 0, leading: 0, bottom: 0, trailing: 5), -// aligment: .bottom, -// binding: $isShowingSend -// ) -// .sheet(isPresented: $isShowingSend) { -// GiveScreen( -// isPresented: $isShowingSend, -// kind: .cashLink -// ) -// } - -// LargeButton( -// title: "Pool", -// image: .asset(.pools), -// spacing: 12, -// maxWidth: 80, -// maxHeight: 80, -// fullWidth: true, -// aligment: .bottom, -// binding: $poolViewModel.isShowingPoolList -// ) -// .sheet(isPresented: $poolViewModel.isShowingPoolList) { -// PoolsScreen( -// container: container, -// sessionContainer: sessionContainer -// ) -// } - + ToastContainer(toast: toast) { LargeButton( title: "Wallet", @@ -345,23 +317,9 @@ struct ScanScreen: View { maxWidth: 80, maxHeight: 80, fullWidth: true, - aligment: .bottom, - binding: $isShowingBalance - ) - .sheet(isPresented: $isShowingBalance) { - BalanceScreen( - isPresented: $isShowingBalance, - container: container, - sessionContainer: sessionContainer - ) - .environment(\.dismissParentContainer, { - isShowingBalance = false - }) - } - .onChange(of: session.pendingCurrencyInfoMint, initial: true) { _, mint in - if mint != nil { - isShowingBalance = true - } + aligment: .bottom + ) { + router.present(.balance) } } } @@ -417,3 +375,43 @@ extension String: @retroactive Identifiable { } } +// MARK: - RoutedSheet - + +/// Renders the modal sheet currently selected by `AppRouter.presentedSheet`. +/// Each case is a top-level modal; switching between them is a sheet swap. +private struct RoutedSheet: View { + + let sheet: AppRouter.SheetPresentation + let container: Container + let sessionContainer: SessionContainer + let giveViewModel: GiveViewModel + + var body: some View { + switch sheet { + case .balance: + BalanceScreen( + container: container, + sessionContainer: sessionContainer + ) + case .settings: + SettingsScreen( + container: container, + sessionContainer: sessionContainer + ) + case .give: + NavigationStack { + GiveScreen(viewModel: giveViewModel) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + ToolbarCloseButton { + // Mirrored back to the router via the + // `.onChange(of: giveViewModel.isPresented)` in ScanScreen. + giveViewModel.isPresented = false + } + } + } + } + } + } +} + diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift new file mode 100644 index 000000000..4e7f87ede --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift @@ -0,0 +1,41 @@ +// +// SettingsAdvancedFeaturesScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import FlipcashUI + +struct SettingsAdvancedFeaturesScreen: View { + + @Environment(AppRouter.self) private var router + + private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + list() + } + .padding(.horizontal, 20) + } + .navigationTitle("Advanced Features") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func list() -> some View { + VStack(alignment: .leading, spacing: 0) { + SettingsRow(asset: .deposit, title: "Deposit Funds", insets: insets) { + router.push(.depositCurrencyList, on: .settings) + } + SettingsRow(systemImage: "doc.text", title: "Application Logs", insets: insets) { + router.push(.settingsApplicationLogs, on: .settings) + } + } + .font(.appDisplayXS) + .foregroundColor(.textMain) + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift new file mode 100644 index 000000000..02985d222 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift @@ -0,0 +1,51 @@ +// +// SettingsAppSettingsScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +struct SettingsAppSettingsScreen: View { + + @Environment(Preferences.self) private var preferences + + private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + list() + } + .padding(.horizontal, 20) + } + .navigationTitle("App Settings") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func list() -> some View { + VStack(alignment: .leading, spacing: 0) { + SettingsToggle( + image: .asset(.camera), + title: "Auto Start Camera", + isEnabled: cameraAutoStartDisabledBinding(), + insets: insets + ) + } + .font(.appDisplayXS) + .foregroundColor(.textMain) + } + + private func cameraAutoStartDisabledBinding() -> Binding { + Binding( + get: { !preferences.cameraAutoStartDisabled }, + set: { enabled in + preferences.cameraAutoStartDisabled = !enabled + } + ) + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift b/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift new file mode 100644 index 000000000..6a7a47514 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift @@ -0,0 +1,79 @@ +// +// SettingsMyAccountScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +struct SettingsMyAccountScreen: View { + + @Environment(AppRouter.self) private var router + @State private var dialogItem: DialogItem? + + let container: Container + let sessionContainer: SessionContainer + + private var sessionAuthenticator: SessionAuthenticator { container.sessionAuthenticator } + + private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + list() + } + .padding(.horizontal, 20) + } + .navigationTitle("My Account") + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $dialogItem) + } + + @ViewBuilder + private func list() -> some View { + VStack(alignment: .leading, spacing: 0) { + + SettingsRow(asset: .key, title: "Access Key", insets: insets) { + dialogItem = .init( + style: .destructive, + title: "View Your Access Key?", + subtitle: "Your Access Key will grant access to your Flipcash account. Keep it private and safe", + dismissable: true + ) { + DialogAction.destructive("View Access Key") { + router.push(.accessKey, on: .settings) + } + DialogAction.cancel {} + } + } + + SettingsRow(asset: .delete, title: "Delete Account", insets: insets) { + dialogItem = .init( + style: .destructive, + title: "Permanently Delete Account?", + subtitle: "This will permanently delete your Flipcash account", + dismissable: true + ) { + DialogAction.destructive("Permanently Delete Account") { + deleteAccount() + } + DialogAction.cancel {} + } + } + } + .font(.appDisplayXS) + .foregroundColor(.textMain) + } + + private func deleteAccount() { + Task { + router.dismissSheet() + try await Task.delay(milliseconds: 250) + sessionAuthenticator.logout() + } + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsRow.swift b/Flipcash/Core/Screens/Settings/SettingsRow.swift new file mode 100644 index 000000000..7abd61035 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsRow.swift @@ -0,0 +1,50 @@ +// +// SettingsRow.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import FlipcashUI + +/// A standard tappable row for settings screens — icon + title + optional badge. +struct SettingsRow: View { + + let image: Image + let title: String + let badge: Badge? + let insets: EdgeInsets + let action: VoidAction + + init(image: Image, title: String, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { + self.image = image + self.title = title + self.badge = badge + self.insets = insets + self.action = action + } + + init(asset: Asset, title: String, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { + self.init(image: Image.asset(asset), title: title, badge: badge, insets: insets, action: action) + } + + init(systemImage: String, title: String, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { + self.init(image: Image(systemName: systemImage), title: title, badge: badge, insets: insets, action: action) + } + + var body: some View { + Row(insets: insets) { + image.frame(minWidth: 45) + Text(title) + .multilineTextAlignment(.leading) + .truncationMode(.tail) + Spacer() + if let badge { + badge + } + } action: { + action() + } + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index f281bc662..d0aaa7097 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -10,55 +10,39 @@ import FlipcashUI import FlipcashCore struct SettingsScreen: View { + + @Environment(AppRouter.self) private var router @Environment(BetaFlags.self) private var betaFlags - @Environment(Preferences.self) private var preferences - - @Binding public var isPresented: Bool - @State private var path: [SettingsPath] = [] - - @State private var isShowingWithdrawFlow = false - @State private var isShowingLogoutConfirmation = false - @State private var isShowingAccessKey = false - @State private var isShowingDepositFlow = false - @State private var dialogItem: DialogItem? @State private var debugTapCount: Int = 0 - - private let insets: EdgeInsets = EdgeInsets( - top: 25, - leading: 0, - bottom: 25, - trailing: 0 - ) - + + private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) + private let container: Container private let sessionAuthenticator: SessionAuthenticator private let sessionContainer: SessionContainer private let session: Session - + // MARK: - Init - - - public init(isPresented: Binding, container: Container, sessionContainer: SessionContainer) { - self._isPresented = isPresented + + init(container: Container, sessionContainer: SessionContainer) { self.container = container self.sessionAuthenticator = container.sessionAuthenticator self.sessionContainer = sessionContainer self.session = sessionContainer.session } - + // MARK: - Body - - + var body: some View { - NavigationStack(path: $path) { + @Bindable var router = router + NavigationStack(path: $router[.settings]) { Background(color: .backgroundMain) { - VStack(alignment: .center, spacing: 0) { - // Content + VStack(alignment: .center, spacing: 0) { ScrollView(showsIndicators: false) { list() } - - // Footer footer() } .padding(.vertical, 10) @@ -68,49 +52,16 @@ struct SettingsScreen: View { .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - ToolbarCloseButton(binding: $isPresented) + ToolbarCloseButton { + router.dismissSheet() + } } ToolbarItem(placement: .principal) { logoHeader() } } .navigationTitle("Settings") - .navigationDestination(for: SettingsPath.self) { path in - switch path { - case .myAccount: - myAccountScreen() - case .advancedFeatures: - advancedFeaturesScreen() - case .appSettings: - appSettingsScreen() - case .betaFlagss: - BetaFlagsScreen(container: container) - case .accountSelection: - AccountSelectionScreen( - sessionAuthenticator: sessionAuthenticator, - action: switchAccount - ) - case .applicationLogs: - ApplicationLogsScreen() - } - } - .sheet(isPresented: $isShowingDepositFlow) { - NavigationStack { - DepositCurrencyListScreen() - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - ToolbarCloseButton(binding: $isShowingDepositFlow) - } - } - } - } - .sheet(isPresented: $isShowingWithdrawFlow) { - WithdrawScreen( - isPresented: $isShowingWithdrawFlow, - container: container, - sessionContainer: sessionContainer - ) - } + .appRouterDestinations(container: container, sessionContainer: sessionContainer) } } @@ -132,275 +83,54 @@ struct SettingsScreen: View { @ViewBuilder private func list() -> some View { VStack(alignment: .leading, spacing: 0) { - - navigationRow( - path: $path, - asset: .myAccount, - title: "My Account", - pathItem: .myAccount - ) - - navigationRow( - path: $path, - asset: .settings, - title: "App Settings", - pathItem: .appSettings - ) - - row( - asset: .withdraw, - title: "Withdraw Funds", - ) { - isShowingWithdrawFlow.toggle() - } - - navigationRow( - path: $path, - asset: .sliders, - title: "Advanced Features", - pathItem: .advancedFeatures - ) - - if betaFlags.accessGranted { - navigationRow( - path: $path, - asset: .debug, - title: "Beta Features", - badge: betaBadge(), - pathItem: .betaFlagss - ) - navigationRow( - path: $path, - asset: .switchAccounts, - title: "Switch Accounts", - badge: betaBadge(), - pathItem: .accountSelection - ) - } - - row(asset: .logout, title: "Log Out") { - dialogItem = .init( - style: .destructive, - title: "Are You Sure You Want To Log Out?", - subtitle: "You can get into this account using your Access Key", - dismissable: true - ) { - DialogAction.destructive("Log Out") { - logout() - } - DialogAction.cancel {} - } + SettingsRow(asset: .myAccount, title: "My Account", insets: insets) { + router.push(.settingsMyAccount, on: .settings) } - - Spacer() - } - .font(.appDisplayXS) - .foregroundColor(.textMain) - .dialog(item: $dialogItem) - } - // MARK: - Advanced Features - - - @ViewBuilder private func advancedFeaturesScreen() -> some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - advancedFeaturesList() - } - .padding(.horizontal, 20) - } - .navigationTitle("Advanced Features") - .navigationBarTitleDisplayMode(.inline) - } - - @ViewBuilder private func advancedFeaturesList() -> some View { - VStack(alignment: .leading, spacing: 0) { - row( - asset: .deposit, - title: "Deposit Funds", - ) { - isShowingDepositFlow = true + SettingsRow(asset: .settings, title: "App Settings", insets: insets) { + router.push(.settingsAppSettings, on: .settings) } - row(systemImage: "doc.text", title: "Application Logs") { - path.append(.applicationLogs) + + SettingsRow(asset: .withdraw, title: "Withdraw Funds", insets: insets) { + router.push(.withdraw, on: .settings) } - } - .font(.appDisplayXS) - .foregroundColor(.textMain) - } - - // MARK: - My Account - - - @ViewBuilder private func myAccountScreen() -> some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - myAccountList() + + SettingsRow(asset: .sliders, title: "Advanced Features", insets: insets) { + router.push(.settingsAdvancedFeatures, on: .settings) } - .padding(.horizontal, 20) - } - .navigationTitle("My Account") - .navigationBarTitleDisplayMode(.inline) - } - - @ViewBuilder private func myAccountList() -> some View { - VStack(alignment: .leading, spacing: 0) { - - row(asset: .key, title: "Access Key") { - dialogItem = .init( - style: .destructive, - title: "View Your Access Key?", - subtitle: "Your Access Key will grant access to your Flipcash account. Keep it private and safe", - dismissable: true - ) { - DialogAction.destructive("View Access Key") { - isShowingAccessKey.toggle() - } - DialogAction.cancel {} + + if betaFlags.accessGranted { + SettingsRow(asset: .debug, title: "Beta Features", badge: betaBadge(), insets: insets) { + router.push(.settingsBetaFlags, on: .settings) } - } - .sheet(isPresented: $isShowingAccessKey) { - NavigationStack { - AccessKeyBackupScreen(mnemonic: session.keyAccount.mnemonic) - .navigationTitle("Access Key") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - ToolbarCloseButton(binding: $isShowingAccessKey) - } - } + + SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: betaBadge(), insets: insets) { + router.push(.settingsAccountSelection, on: .settings) } } - - row(asset: .delete, title: "Delete Account") { + + SettingsRow(asset: .logout, title: "Log Out", insets: insets) { dialogItem = .init( style: .destructive, - title: "Permanently Delete Account?", - subtitle: "This will permanently delete your Flipcash account", + title: "Are You Sure You Want To Log Out?", + subtitle: "You can get into this account using your Access Key", dismissable: true ) { - DialogAction.destructive("Permanently Delete Account") { + DialogAction.destructive("Log Out") { logout() } DialogAction.cancel {} } } + + Spacer() } .font(.appDisplayXS) .foregroundColor(.textMain) - } - - // MARK: - App Settings - - - @ViewBuilder private func appSettingsScreen() -> some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - appSettingsList() - } - .padding(.horizontal, 20) - } - .navigationTitle("App Settings") - .navigationBarTitleDisplayMode(.inline) - } - - @ViewBuilder private func appSettingsList() -> some View { - VStack(alignment: .leading, spacing: 0) { -// switch biometrics.kind { -// case .none: -// EmptyView() -// -// case .passcode: -// toggle( -// image: .system(.faceID), -// title: Localized.Title.requirePasscode, -// isEnabled: biometricsEnabledBinding() -// ) -// -// case .faceID: -// toggle( -// image: .system(.faceID), -// title: Localized.Title.requireFaceID, -// isEnabled: biometricsEnabledBinding() -// ) -// -// case .touchID: -// toggle( -// image: .system(.touchID), -// title: Localized.Title.requireTouchID, -// isEnabled: biometricsEnabledBinding() -// ) -// } - - toggle( - image: .asset(.camera), - title: "Auto Start Camera", - isEnabled: cameraAutoStartDisabledBinding() - ) - } - .font(.appDisplayXS) - .foregroundColor(.textMain) - } - - private func cameraAutoStartDisabledBinding() -> Binding { - Binding( - get: { !preferences.cameraAutoStartDisabled }, - set: { enabled in - preferences.cameraAutoStartDisabled = !enabled - } - ) - } - - // MARK: - Utilities - - - @ViewBuilder private func navigationRow(path: Binding<[SettingsPath]>, asset: Asset, title: String, badge: Badge? = nil, pathItem: SettingsPath) -> some View { - NavigationRow(path: path, insets: insets, pathItem: pathItem) { - Image.asset(asset) - .frame(minWidth: 45) - Text(title) - .multilineTextAlignment(.leading) - .truncationMode(.tail) - Spacer() - if let badge = badge { - badge - } - } - } - - @ViewBuilder private func row(asset: Asset, title: String, badge: Badge? = nil, action: @escaping VoidAction) -> some View { - row(image: Image.asset(asset), title: title, badge: badge, action: action) - } - - @ViewBuilder private func row(systemImage: String, title: String, badge: Badge? = nil, action: @escaping VoidAction) -> some View { - row(image: Image(systemName: systemImage), title: title, badge: badge, action: action) + .dialog(item: $dialogItem) } - @ViewBuilder private func row(image: Image, title: String, badge: Badge? = nil, action: @escaping VoidAction) -> some View { - Row(insets: insets) { - image - .frame(minWidth: 45) - Text(title) - .multilineTextAlignment(.leading) - .truncationMode(.tail) - Spacer() - if let badge = badge { - badge - } - } action: { - action() - } - } - - @ViewBuilder private func toggle(image: Image, title: String, isEnabled: Binding) -> some View { - Row(insets: insets) { - image - .frame(minWidth: 45) - Toggle(title, isOn: isEnabled) - .multilineTextAlignment(.leading) - .truncationMode(.tail) - .padding(.trailing, 2) - .tint(.textSuccess) - } - } - @ViewBuilder private func footer() -> some View { VStack { Text("Version \(AppMeta.version) • Build \(AppMeta.build)") @@ -408,18 +138,17 @@ struct SettingsScreen: View { .font(.appTextHeading) .foregroundColor(.textSecondary) } - .frame(maxWidth:. infinity) + .frame(maxWidth: .infinity) } - + private func betaBadge() -> Badge { Badge(decoration: .circle(.textWarning), text: "Beta") } - + // MARK: - Actions - private func handleLogoTap() { if debugTapCount >= 9 { - // Toggle beta flags access betaFlags.setAccessGranted(!betaFlags.accessGranted) debugTapCount = 0 } else { @@ -427,34 +156,11 @@ struct SettingsScreen: View { } } - private func switchAccount(to account: AccountDescription) { - Task { - isPresented = false - try await Task.delay(milliseconds: 250) - - sessionAuthenticator.switchAccount(to: account.account.mnemonic) - } - } - private func logout() { Task { - isPresented = false + router.dismissSheet() try await Task.delay(milliseconds: 250) - sessionAuthenticator.logout() } } } - -// MARK: - Navigation - - -extension SettingsScreen { - enum SettingsPath { - case myAccount - case advancedFeatures - case appSettings - case betaFlagss - case accountSelection - case applicationLogs - } -} diff --git a/Flipcash/Core/Screens/Settings/SettingsToggle.swift b/Flipcash/Core/Screens/Settings/SettingsToggle.swift new file mode 100644 index 000000000..dfaf086a2 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsToggle.swift @@ -0,0 +1,35 @@ +// +// SettingsToggle.swift +// Flipcash +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import FlipcashUI + +struct SettingsToggle: View { + + let image: Image + let title: String + let isEnabled: Binding + let insets: EdgeInsets + + init(image: Image, title: String, isEnabled: Binding, insets: EdgeInsets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0)) { + self.image = image + self.title = title + self.isEnabled = isEnabled + self.insets = insets + } + + var body: some View { + Row(insets: insets) { + image.frame(minWidth: 45) + Toggle(title, isOn: isEnabled) + .multilineTextAlignment(.leading) + .truncationMode(.tail) + .padding(.trailing, 2) + .tint(.textSuccess) + } + } +} diff --git a/Flipcash/Core/Screens/Settings/WithdrawScreen.swift b/Flipcash/Core/Screens/Settings/WithdrawScreen.swift index 4319e45a9..32a698f58 100644 --- a/Flipcash/Core/Screens/Settings/WithdrawScreen.swift +++ b/Flipcash/Core/Screens/Settings/WithdrawScreen.swift @@ -11,8 +11,8 @@ import FlipcashCore struct WithdrawScreen: View { - @Binding var isPresented: Bool - + @Environment(\.dismiss) private var dismiss + @Environment(AppRouter.self) private var router @Environment(Session.self) private var session @Environment(RatesController.self) private var ratesController @@ -27,12 +27,10 @@ struct WithdrawScreen: View { // MARK: - Init - - init(isPresented: Binding, container: Container, sessionContainer: SessionContainer) { - self._isPresented = isPresented + init(container: Container, sessionContainer: SessionContainer) { self.container = container self.sessionContainer = sessionContainer self.viewModel = WithdrawViewModel( - isPresented: isPresented, container: container, sessionContainer: sessionContainer ) @@ -41,39 +39,49 @@ struct WithdrawScreen: View { // MARK: - Body - var body: some View { - NavigationStack(path: $viewModel.path) { - Background(color: .backgroundMain) { - List { - Section { - ForEach(balances) { balance in - CurrencyBalanceRow( - exchangedBalance: balance - ) { - viewModel.selectCurrency(balance) - } + Background(color: .backgroundMain) { + List { + Section { + ForEach(balances) { balance in + CurrencyBalanceRow( + exchangedBalance: balance + ) { + viewModel.selectCurrency(balance) } } - .listRowInsets(EdgeInsets()) } - .listStyle(.plain) - .scrollContentBackground(.hidden) + .listRowInsets(EdgeInsets()) } - .navigationTitle("Select Currency") - .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: WithdrawNavigationPath.self) { path in - switch path { - case .enterAmount: - WithdrawAmountScreen(viewModel: viewModel) - case .enterAddress: - WithdrawAddressScreen(viewModel: viewModel) - case .confirmation: - WithdrawSummaryScreen(viewModel: viewModel) - } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + .navigationTitle("Select Currency") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: WithdrawNavigationPath.self) { path in + switch path { + case .enterAmount: + WithdrawAmountScreen(viewModel: viewModel) + case .enterAddress: + WithdrawAddressScreen(viewModel: viewModel) + case .confirmation: + WithdrawSummaryScreen(viewModel: viewModel) } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - ToolbarCloseButton(binding: $isPresented) - } + } + .onAppear { + // Wire the view model's navigation callbacks. Push substeps onto + // the parent (Settings) NavigationStack via the router; pops + // remove that many items from the top. + viewModel.pushSubstep = { step in + router.pushAny(step, on: .settings) + } + viewModel.popSubsteps = { count in + router.popLast(count, on: .settings) + } + viewModel.onComplete = { + // Successful withdrawal: unwind the entire flow back to + // Settings root by popping `.withdraw` and any substeps + // pushed on top. + dismiss() } } } diff --git a/Flipcash/Core/Screens/Settings/WithdrawViewModel.swift b/Flipcash/Core/Screens/Settings/WithdrawViewModel.swift index 14ca82407..961fc9434 100644 --- a/Flipcash/Core/Screens/Settings/WithdrawViewModel.swift +++ b/Flipcash/Core/Screens/Settings/WithdrawViewModel.swift @@ -11,7 +11,20 @@ import FlipcashUI @MainActor @Observable class WithdrawViewModel { - var path: [WithdrawNavigationPath] = [] + /// Tracks which sub-step screens have been pushed. Mirrors the + /// `WithdrawNavigationPath` items the model has appended to the parent + /// navigation stack via the `pushSubstep` callback. Used by + /// `popToEnterAmount` to compute how many items to pop. + @ObservationIgnored private var substepStack: [WithdrawNavigationPath] = [] + + /// Pushes a sub-step onto the parent NavigationStack. Wired by + /// `WithdrawScreen` to call `router.pushAny(_:on: .settings)`. + @ObservationIgnored var pushSubstep: (WithdrawNavigationPath) -> Void = { _ in } + + /// Pops the given number of items from the parent NavigationStack. + /// Wired by `WithdrawScreen` to call `router.popLast(_:on: .settings)`. + @ObservationIgnored var popSubsteps: (Int) -> Void = { _ in } + var withdrawButtonState: ButtonState = .normal var selectedBalance: ExchangedBalance? var enteredAmount: String = "" @@ -169,7 +182,9 @@ class WithdrawViewModel { ) } - @ObservationIgnored private let isPresented: Binding + /// Set by `WithdrawScreen` from `@Environment(\.dismiss)` once the view + /// is on screen. Invoked by the success dialog to unwind the entire flow. + @ObservationIgnored var onComplete: () -> Void = {} @ObservationIgnored private let container: Container @ObservationIgnored private let client: Client @ObservationIgnored private let session: Session @@ -177,8 +192,7 @@ class WithdrawViewModel { // MARK: - Init - - init(isPresented: Binding, container: Container, sessionContainer: SessionContainer) { - self.isPresented = isPresented + init(container: Container, sessionContainer: SessionContainer) { self.container = container self.client = container.client self.session = sessionContainer.session @@ -405,19 +419,30 @@ class WithdrawViewModel { // MARK: - Navigation - private func popToEnterAmount() { - path = [.enterAmount] + // Pop everything above `.enterAmount`, leaving it as the top substep. + // If we're already there or the stack is empty, this is a no-op. + guard let firstAmountIndex = substepStack.firstIndex(of: .enterAmount) else { + return + } + let popsNeeded = substepStack.count - (firstAmountIndex + 1) + guard popsNeeded > 0 else { return } + popSubsteps(popsNeeded) + substepStack.removeLast(popsNeeded) } func pushEnterAmountScreen() { - path.append(.enterAmount) + pushSubstep(.enterAmount) + substepStack.append(.enterAmount) } private func pushEnterAddressScreen() { - path.append(.enterAddress) + pushSubstep(.enterAddress) + substepStack.append(.enterAddress) } private func pushConfirmationScreen() { - path.append(.confirmation) + pushSubstep(.confirmation) + substepStack.append(.confirmation) } // MARK: - Dialogs - @@ -430,7 +455,7 @@ class WithdrawViewModel { dismissable: false ) { .okay(kind: .standard) { [weak self] in - self?.isPresented.wrappedValue = false + self?.onComplete() } } } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 8bf49527b..e32cd334a 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -65,9 +65,6 @@ class Session { /// Active Coinbase onramp order, if any. var coinbaseOrder: OnrampOrderResponse? - /// Navigation trigger for deep-linking to a currency info screen. - var pendingCurrencyInfoMint: PublicKey? = nil - @ObservationIgnored private var grabStarts: [PublicKey: Date] = [:] @ObservationIgnored let keyAccount: KeyAccount @@ -924,10 +921,6 @@ class Session { } func showCashBill(_ billDescription: BillDescription) { - // Clear pending navigation so deep link triggers don't - // re-present sheets after the bill dismisses them. - pendingCurrencyInfoMint = nil - let operation = SendCashOperation( client: client, database: database, diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index c31a98502..0982c1897 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -418,6 +418,7 @@ struct SessionContainer { let flipClient: FlipClient let onrampDeeplinkInbox: OnrampDeeplinkInbox let onrampCoordinator: OnrampCoordinator + let appRouter: AppRouter @MainActor init( @@ -438,10 +439,12 @@ struct SessionContainer { self.flipClient = flipClient self.onrampDeeplinkInbox = OnrampDeeplinkInbox() self.onrampCoordinator = OnrampCoordinator(session: session, flipClient: flipClient) + self.appRouter = AppRouter() } fileprivate func injectingEnvironment(into view: SomeView) -> some View where SomeView: View { view + .environment(appRouter) .environment(session) .environment(ratesController) .environment(historyController) diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift new file mode 100644 index 000000000..6edf0ed7a --- /dev/null +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -0,0 +1,113 @@ +// +// AppRouterCrossStackTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("AppRouter Cross-Stack Navigation") +struct AppRouterCrossStackTests { + + @Test("From cold state, navigate opens owning sheet with destination on top") + func navigate_fromColdState_opensOwningStack() { + let router = AppRouter() + router.navigate(to: .currencyInfo(.usdc)) + #expect(router.presentedSheet == .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("Navigating to a destination on a different stack swaps the sheet") + func navigate_acrossStacks_swapsSheet() { + let router = AppRouter() + router.present(.settings) + router.setPath([.settingsMyAccount, .settingsAdvancedFeatures], on: .settings) + + router.navigate(to: .currencyInfo(.usdc)) + + #expect(router.presentedSheet == .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("Sheet swap preserves the other stack's path for return trips") + func navigate_acrossStacks_preservesOtherStackPath() { + let router = AppRouter() + router.present(.settings) + let settingsPath: [AppRouter.Destination] = [.settingsMyAccount, .settingsAdvancedFeatures] + router.setPath(settingsPath, on: .settings) + + router.navigate(to: .currencyInfo(.usdc)) + + #expect(router[.settings] == AppRouter.navigationPath(.settingsMyAccount, .settingsAdvancedFeatures), + "settings path must survive the sheet swap") + } + + @Test("Same-stack navigate replaces the path on that stack") + func navigate_sameStack_replacesPath() { + let router = AppRouter() + router.present(.balance) + router.setPath([.currencyInfo(.usdc), .transactionHistory(.usdc)], on: .balance) + + router.navigate(to: .currencyInfo(.usdf)) + + #expect(router.presentedSheet == .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdf))) + } + + @Test("Push notification routing to a settings destination from balance swaps to settings") + func navigate_fromBalanceToSettingsDestination_swapsToSettings() { + let router = AppRouter() + router.present(.balance) + router.setPath([.currencyInfo(.usdc)], on: .balance) + + router.navigate(to: .settingsApplicationLogs) + + #expect(router.presentedSheet == .settings) + #expect(router[.settings] == AppRouter.navigationPath(.settingsApplicationLogs)) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc)), + "balance preserved underneath") + } + + @Test("Navigate is idempotent when target state already matches current state") + func navigate_isIdempotent() { + let router = AppRouter() + router.navigate(to: .currencyInfo(.usdc)) + router.navigate(to: .currencyInfo(.usdc)) + #expect(router.presentedSheet == .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("owningStack maps wallet destinations to .balance") + func owningStack_walletDestinations_mapToBalance() { + #expect(AppRouter.Destination.currencyInfo(.usdc).owningStack == .balance) + #expect(AppRouter.Destination.discoverCurrencies.owningStack == .balance) + #expect(AppRouter.Destination.currencyCreationSummary.owningStack == .balance) + #expect(AppRouter.Destination.currencyCreationWizard.owningStack == .balance) + #expect(AppRouter.Destination.transactionHistory(.usdc).owningStack == .balance) + } + + @Test("owningStack maps settings destinations to .settings") + func owningStack_settingsDestinations_mapToSettings() { + #expect(AppRouter.Destination.settingsMyAccount.owningStack == .settings) + #expect(AppRouter.Destination.settingsAdvancedFeatures.owningStack == .settings) + #expect(AppRouter.Destination.settingsAppSettings.owningStack == .settings) + #expect(AppRouter.Destination.settingsBetaFlags.owningStack == .settings) + #expect(AppRouter.Destination.settingsAccountSelection.owningStack == .settings) + #expect(AppRouter.Destination.settingsApplicationLogs.owningStack == .settings) + #expect(AppRouter.Destination.accessKey.owningStack == .settings) + #expect(AppRouter.Destination.depositCurrencyList.owningStack == .settings) + #expect(AppRouter.Destination.withdraw.owningStack == .settings) + } + + @Test("Stack.sheet maps each stack to its corresponding sheet") + func stackSheet_isOneToOne() { + #expect(AppRouter.Stack.balance.sheet == .balance) + #expect(AppRouter.Stack.settings.sheet == .settings) + #expect(AppRouter.Stack.give.sheet == .give) + } +} diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift new file mode 100644 index 000000000..13f3fec33 --- /dev/null +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -0,0 +1,143 @@ +// +// AppRouterTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("AppRouter") +struct AppRouterTests { + + // MARK: - push / pop / popToRoot / setPath + + @Test("push appends destination to the stack") + func push_appendsDestination() { + let router = AppRouter() + router.push(.discoverCurrencies, on: .balance) + #expect(router[.balance] == AppRouter.navigationPath(.discoverCurrencies)) + } + + @Test("push appends in order across multiple calls") + func push_appendsInOrder() { + let router = AppRouter() + router.push(.currencyInfo(.usdc), on: .balance) + router.push(.transactionHistory(.usdc), on: .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc), .transactionHistory(.usdc))) + } + + @Test("pop removes the top destination") + func pop_removesTop() { + let router = AppRouter() + router.push(.currencyInfo(.usdc), on: .balance) + router.push(.transactionHistory(.usdc), on: .balance) + router.pop(on: .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("pop on empty stack is a no-op") + func pop_onEmpty_isNoop() { + let router = AppRouter() + router.pop(on: .balance) + #expect(router[.balance].isEmpty) + } + + @Test("popToRoot clears the stack") + func popToRoot_clearsStack() { + let router = AppRouter() + router.push(.currencyInfo(.usdc), on: .balance) + router.push(.transactionHistory(.usdc), on: .balance) + router.popToRoot(on: .balance) + #expect(router[.balance].isEmpty) + } + + @Test("popLast removes the requested number of items") + func popLast_removesCount() { + let router = AppRouter() + router.push(.currencyInfo(.usdc), on: .balance) + router.push(.transactionHistory(.usdc), on: .balance) + router.push(.discoverCurrencies, on: .balance) + router.popLast(2, on: .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("popLast clamps to available depth") + func popLast_clampsToDepth() { + let router = AppRouter() + router.push(.currencyInfo(.usdc), on: .balance) + router.popLast(10, on: .balance) + #expect(router[.balance].isEmpty) + } + + @Test("setPath replaces the entire path") + func setPath_replacesPath() { + let router = AppRouter() + router.push(.currencyInfo(.usdc), on: .balance) + router.setPath([.discoverCurrencies, .currencyCreationSummary], on: .balance) + #expect(router[.balance] == AppRouter.navigationPath(.discoverCurrencies, .currencyCreationSummary)) + } + + @Test("setPath with identical path is a no-op") + func setPath_identical_isNoop() { + let router = AppRouter() + router.setPath([.currencyInfo(.usdc)], on: .balance) + router.setPath([.currencyInfo(.usdc)], on: .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("pushAny accepts non-Destination Hashable types") + func pushAny_acceptsHashable() { + let router = AppRouter() + router.push(.withdraw, on: .settings) + router.pushAny(WithdrawNavigationPath.enterAmount, on: .settings) + #expect(router[.settings].count == 2) + } + + @Test("paths on different stacks are independent") + func stacks_areIndependent() { + let router = AppRouter() + router.push(.currencyInfo(.usdc), on: .balance) + router.push(.settingsMyAccount, on: .settings) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + #expect(router[.settings] == AppRouter.navigationPath(.settingsMyAccount)) + } + + // MARK: - present / dismissSheet + + @Test("present sets the sheet") + func present_setsSheet() { + let router = AppRouter() + router.present(.balance) + #expect(router.presentedSheet == .balance) + } + + @Test("dismissSheet clears the sheet") + func dismissSheet_clearsSheet() { + let router = AppRouter() + router.present(.balance) + router.dismissSheet() + #expect(router.presentedSheet == nil) + } + + @Test("present is idempotent") + func present_isIdempotent() { + let router = AppRouter() + router.present(.balance) + router.setPath([.currencyInfo(.usdc)], on: .balance) + router.present(.balance) + #expect(router.presentedSheet == .balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("dismissSheet on no-sheet is a no-op") + func dismissSheet_onNothing_isNoop() { + let router = AppRouter() + router.dismissSheet() + #expect(router.presentedSheet == nil) + } +} diff --git a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift index 70f7f39d4..7f7671cae 100644 --- a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift +++ b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift @@ -113,7 +113,6 @@ struct Regression_native_amount_mismatch { ]) let vm = WithdrawViewModel( - isPresented: .constant(true), container: .mock, sessionContainer: sessionContainer ) @@ -192,7 +191,6 @@ struct Regression_native_amount_mismatch { ) let vm = WithdrawViewModel( - isPresented: .constant(true), container: .mock, sessionContainer: sessionContainer ) diff --git a/FlipcashTests/TestSupport/AppRouter+TestSupport.swift b/FlipcashTests/TestSupport/AppRouter+TestSupport.swift new file mode 100644 index 000000000..0582c76ef --- /dev/null +++ b/FlipcashTests/TestSupport/AppRouter+TestSupport.swift @@ -0,0 +1,21 @@ +// +// AppRouter+TestSupport.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-04-27. +// + +import SwiftUI +@testable import Flipcash + +extension AppRouter { + + /// Builds a `NavigationPath` from a typed sequence of `Destination`s. + /// Used in tests to assert against `router[.]`. + @MainActor + static func navigationPath(_ destinations: AppRouter.Destination...) -> NavigationPath { + var path = NavigationPath() + for destination in destinations { path.append(destination) } + return path + } +} diff --git a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift index 1e963f233..d1e950767 100644 --- a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift +++ b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift @@ -28,7 +28,6 @@ enum WithdrawViewModelTestHelpers { ) return WithdrawViewModel( - isPresented: .constant(true), container: container, sessionContainer: sessionContainer ) diff --git a/FlipcashTests/WithdrawViewModelTests.swift b/FlipcashTests/WithdrawViewModelTests.swift index b5a669618..e49b0ecda 100644 --- a/FlipcashTests/WithdrawViewModelTests.swift +++ b/FlipcashTests/WithdrawViewModelTests.swift @@ -121,7 +121,6 @@ struct WithdrawViewModelTests { ) let viewModel = WithdrawViewModel( - isPresented: .constant(true), container: .mock, sessionContainer: container ) @@ -156,7 +155,6 @@ struct WithdrawViewModelTests { exchangedFiat: stored.computeExchangedValue(with: rate) ) let viewModel = WithdrawViewModel( - isPresented: .constant(true), container: .mock, sessionContainer: container ) From cfdc4a5f914ff92a85ddb63edef10f780d0dd18f Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 27 Apr 2026 19:05:14 -0400 Subject: [PATCH 2/8] refactor: parameterize router mapping tests and inline SettingsToggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 14+9+3 hand-rolled #expects in owningStack/sheet mapping tests with parameterized @Test(arguments:) — each pair now runs as an independent test case in Xcode. - Inline SettingsToggle into SettingsAppSettingsScreen — its single call site doesn't justify a standalone view. --- .../Settings/SettingsAppSettingsScreen.swift | 14 +++-- .../Screens/Settings/SettingsToggle.swift | 35 ----------- .../Navigation/AppRouterCrossStackTests.swift | 59 +++++++++++-------- 3 files changed, 42 insertions(+), 66 deletions(-) delete mode 100644 Flipcash/Core/Screens/Settings/SettingsToggle.swift diff --git a/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift index 02985d222..3944865f5 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift @@ -29,12 +29,14 @@ struct SettingsAppSettingsScreen: View { @ViewBuilder private func list() -> some View { VStack(alignment: .leading, spacing: 0) { - SettingsToggle( - image: .asset(.camera), - title: "Auto Start Camera", - isEnabled: cameraAutoStartDisabledBinding(), - insets: insets - ) + Row(insets: insets) { + Image.asset(.camera).frame(minWidth: 45) + Toggle("Auto Start Camera", isOn: cameraAutoStartDisabledBinding()) + .multilineTextAlignment(.leading) + .truncationMode(.tail) + .padding(.trailing, 2) + .tint(.textSuccess) + } } .font(.appDisplayXS) .foregroundColor(.textMain) diff --git a/Flipcash/Core/Screens/Settings/SettingsToggle.swift b/Flipcash/Core/Screens/Settings/SettingsToggle.swift deleted file mode 100644 index dfaf086a2..000000000 --- a/Flipcash/Core/Screens/Settings/SettingsToggle.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// SettingsToggle.swift -// Flipcash -// -// Created by Raul Riera on 2026-04-27. -// - -import SwiftUI -import FlipcashUI - -struct SettingsToggle: View { - - let image: Image - let title: String - let isEnabled: Binding - let insets: EdgeInsets - - init(image: Image, title: String, isEnabled: Binding, insets: EdgeInsets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0)) { - self.image = image - self.title = title - self.isEnabled = isEnabled - self.insets = insets - } - - var body: some View { - Row(insets: insets) { - image.frame(minWidth: 45) - Toggle(title, isOn: isEnabled) - .multilineTextAlignment(.leading) - .truncationMode(.tail) - .padding(.trailing, 2) - .tint(.textSuccess) - } - } -} diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index 6edf0ed7a..d94239035 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -82,32 +82,41 @@ struct AppRouterCrossStackTests { #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) } - @Test("owningStack maps wallet destinations to .balance") - func owningStack_walletDestinations_mapToBalance() { - #expect(AppRouter.Destination.currencyInfo(.usdc).owningStack == .balance) - #expect(AppRouter.Destination.discoverCurrencies.owningStack == .balance) - #expect(AppRouter.Destination.currencyCreationSummary.owningStack == .balance) - #expect(AppRouter.Destination.currencyCreationWizard.owningStack == .balance) - #expect(AppRouter.Destination.transactionHistory(.usdc).owningStack == .balance) + @Test( + "Destination maps to its owning stack", + arguments: [ + (AppRouter.Destination.currencyInfo(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.discoverCurrencies, AppRouter.Stack.balance), + (AppRouter.Destination.currencyCreationSummary, AppRouter.Stack.balance), + (AppRouter.Destination.currencyCreationWizard, AppRouter.Stack.balance), + (AppRouter.Destination.transactionHistory(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.settingsMyAccount, AppRouter.Stack.settings), + (AppRouter.Destination.settingsAdvancedFeatures, AppRouter.Stack.settings), + (AppRouter.Destination.settingsAppSettings, AppRouter.Stack.settings), + (AppRouter.Destination.settingsBetaFlags, AppRouter.Stack.settings), + (AppRouter.Destination.settingsAccountSelection, AppRouter.Stack.settings), + (AppRouter.Destination.settingsApplicationLogs, AppRouter.Stack.settings), + (AppRouter.Destination.accessKey, AppRouter.Stack.settings), + (AppRouter.Destination.depositCurrencyList, AppRouter.Stack.settings), + (AppRouter.Destination.withdraw, AppRouter.Stack.settings), + ] + ) + func destination_hasCorrectOwningStack( + _ destination: AppRouter.Destination, + expected: AppRouter.Stack + ) { + #expect(destination.owningStack == expected) } - @Test("owningStack maps settings destinations to .settings") - func owningStack_settingsDestinations_mapToSettings() { - #expect(AppRouter.Destination.settingsMyAccount.owningStack == .settings) - #expect(AppRouter.Destination.settingsAdvancedFeatures.owningStack == .settings) - #expect(AppRouter.Destination.settingsAppSettings.owningStack == .settings) - #expect(AppRouter.Destination.settingsBetaFlags.owningStack == .settings) - #expect(AppRouter.Destination.settingsAccountSelection.owningStack == .settings) - #expect(AppRouter.Destination.settingsApplicationLogs.owningStack == .settings) - #expect(AppRouter.Destination.accessKey.owningStack == .settings) - #expect(AppRouter.Destination.depositCurrencyList.owningStack == .settings) - #expect(AppRouter.Destination.withdraw.owningStack == .settings) - } - - @Test("Stack.sheet maps each stack to its corresponding sheet") - func stackSheet_isOneToOne() { - #expect(AppRouter.Stack.balance.sheet == .balance) - #expect(AppRouter.Stack.settings.sheet == .settings) - #expect(AppRouter.Stack.give.sheet == .give) + @Test( + "Stack maps to its sheet presentation", + arguments: [ + (AppRouter.Stack.balance, AppRouter.SheetPresentation.balance), + (AppRouter.Stack.settings, AppRouter.SheetPresentation.settings), + (AppRouter.Stack.give, AppRouter.SheetPresentation.give), + ] + ) + func stack_mapsToSheet(_ stack: AppRouter.Stack, expected: AppRouter.SheetPresentation) { + #expect(stack.sheet == expected) } } From 5b45fbff72c30bc0901eb8550d037f9356786675 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 27 Apr 2026 19:13:45 -0400 Subject: [PATCH 3/8] docs: document AppRouter and refresh stale CLAUDE.md sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Navigation: AppRouter section under Architecture & Patterns describing the canonical navigation system, NavigationPath storage, no-nested-NavigationStack rule, and local-vs-routed sheets. - Add two pitfalls: nested NavigationStack crashes with comparisonTypeMismatch; same-case leaf swaps need .id(value). - Add navigation key files to Quick Reference. - Update architecture diagram: Session is @Observable, not ObservableObject (migrated previously). - Drop Session from the legacy ObservableObject example list. - Fix test naming convention: drop the XCTest-style test prefix since the project uses Swift Testing. - Fix typo (indetify → identify). --- CLAUDE.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a842bb607..83f1de913 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ When adding new information, place it in the appropriate existing section. Remov ### Working Style -- **Understand the context.** Take your time to understand how the changes _should_ fit into the complete project. Perhaps a refactor is required. Perhaps the current structure is not ideal. Take your time to indetify this. +- **Understand the context.** Take your time to understand how the changes _should_ fit into the complete project. Perhaps a refactor is required. Perhaps the current structure is not ideal. Take your time to identify this. - **Double-check your work.** Verify changes compile and don't break existing functionality. - **Ask clarifying questions.** When requirements are ambiguous or something is unclear or can have multiple meanings, don't assume. Ask clarifying questions where needed but try to keep these as concise and as minimal as possible. @@ -127,7 +127,7 @@ case .insufficient(let shortfall): | `@AppStorage` wrapping `UserDefaults` manually | `@AppStorage` directly | For simple per-screen preferences | | `onChange(of:perform:)` (deprecated) | `onChange(of:initial:_:)` | Use `initial: true` when the handler should also fire on appear | -Existing `ObservableObject` classes (`Session`, `Client`, controllers) stay as-is until their dependents are migrated. A single class must use one system — either `ObservableObject` with `@Published`, or `@Observable`. Mixing causes silent observation failures. +Existing `ObservableObject` classes (`Client`, `FlipClient`) stay as-is until their dependents are migrated. A single class must use one system — either `ObservableObject` with `@Published`, or `@Observable`. Mixing causes silent observation failures. ### Generated Files @@ -223,7 +223,7 @@ Container (DI) ├── FlipClient (Flipcash APIs) ├── AccountManager (Keychain) └── SessionContainer (when logged in) - ├── Session (main state, ObservableObject) + ├── Session (main state, @Observable) ├── RatesController │ ├── VerifiedProtoService (actor – caches verified exchange rate + reserve state proofs) │ └── LiveMintDataStreamer (actor – bidirectional streaming for rates/reserves) @@ -254,6 +254,16 @@ let stream = service.openMessageStream(request) { response in ... } let stream = service.openMessageStream(request, callOptions: .streaming) { response in ... } ``` +### Navigation: AppRouter + +All navigation flows through `AppRouter` — a single `@Observable @MainActor` class on `SessionContainer`, injected via `@Environment(AppRouter.self)`. **Don't add screen-level `@State` sheet flags or `selectedXxx` bindings for navigation** — mutate the router instead. Deeplinks and push notifications call `router.navigate(to:)`; in-screen pushes call `router.push(_:on:)`. + +Top-level sheets (`Balance`, `Settings`, `Give`) each own a `NavigationStack(path: $router[.])` and register destinations via the `.appRouterDestinations(...)` modifier on their root content. Per-stack paths are `NavigationPath` (type-erased), so sub-flow destinations (e.g., `WithdrawNavigationPath`) coexist with top-level `Destination` cases on the same stack — register `.navigationDestination(for: SubFlowPath.self)` on the sub-flow root view and push via `router.pushAny(_:on:)`. **Don't nest a `NavigationStack` inside another stack's destination** — push/pop/push corrupts SwiftUI's stack state with `comparisonTypeMismatch`. + +**Local interaction sheets stay local.** Transient pickers (currency selection, buy/sell amount, funding selection) belong on the screen that owns them as `.sheet(...)` modifiers — they're interactions, not navigation. Only the navigation graph goes through the router. + +Every router mutation logs one INFO entry under `flipcash.router` — filter by that label to trace any navigation interaction. + ### Key Architectural Concepts 1. **Quarks** - Smallest unit of any currency (like cents for dollars) @@ -392,7 +402,7 @@ Use the project scripts — they encode the correct scheme, simulator, and desti ### Test Naming - Use descriptive names that explain the scenario -- Format: `func testMethodName_scenario_expectedResult()` or use `@Test("description")` +- Format: `func methodName_scenario_expectedResult()` paired with `@Test("description")` for the display name ### Test the Actual Implementation @@ -500,6 +510,8 @@ Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore` | Canceling/modifying `SendCashOperation` in `dismissCashBill` | **Never** explicitly call `cancel()` or `invalidateMessageStream()` on `SendCashOperation` from `dismissCashBill`. After a grab, the received bill is a **live** `SendCashOperation` that others can scan ("quick give and grab" chain). Setting `sendOperation = nil` is fine (deinit cleans up), but explicit teardown kills a live bill. The operation's `complete()` method handles stream teardown on success/failure. | | Using default `CallOptions` for streaming RPCs | Streaming RPCs (`openMessageStream`, `submitIntent`, `streamLiveMintData`, `statefulSwap`) must use `callOptions: .streaming`. The default 15s timeout silently kills long-lived streams. See [gRPC Call Options](#grpc-call-options). | | Showing a received bill without `verifiedState` | Every call to `showCashBill` must pass `verifiedState` — even for `received: true` bills. The received bill creates a live `SendCashOperation` for the "quick give and grab" chain. Without `verifiedState`, launchpad currency transfers fail with "reserve state is required". Both `receiveCash` (scan) and `receiveCashLink` (deep link) must provide it. | +| Nesting a `NavigationStack` inside another stack's destination | Crashes with `SwiftUI.AnyNavigationPath.Error.comparisonTypeMismatch` on push/pop/push. Drop the inner stack; register `.navigationDestination(for: SubFlowPath.self)` on the destination's root view and push sub-flow steps via `router.pushAny(_:on:)`. The parent stack's `NavigationPath` carries both the typed `Destination` cases and the sub-flow's Hashable values. | +| Cross-stack `navigate(to:)` shows stale leaf data | When two destinations have the same case but different associated values (e.g., `.currencyInfo(A)` → `.currencyInfo(B)`), SwiftUI keeps the existing view at the same path depth and `@State` survives — the leaf renders with old data. Add `.id(value)` to the destination view in `DestinationView` so each value forces a fresh view identity. | | `matchedGeometryEffect` applied after `.frame` | **`.matchedGeometryEffect` must come BEFORE `.frame` in the modifier chain.** Wrong order causes hero animations to fail silently: you see two separate views fading in/out at their own static positions instead of one morphing element. Paul Hudson's hackingwithswift example uses the wrong order and does not work on current iOS. Correct: `Rectangle().fill(.red).matchedGeometryEffect(id:in:).frame(width:height:)`. Incorrect: `Rectangle().fill(.red).frame(width:height:).matchedGeometryEffect(id:in:)`. Also note: `.transition(.identity)` on a parent containing matched views **kills the animation entirely** — matched geometry needs the parent view to remain in the tree briefly for interpolation, and `.identity` removes it instantly. | --- @@ -509,6 +521,13 @@ Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore` ### Key Files ``` +Navigation: +- Flipcash/Core/Navigation/AppRouter.swift (class + mutators + logging) +- Flipcash/Core/Navigation/AppRouter+Destination.swift (push targets) +- Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift (top-level sheets) +- Flipcash/Core/Navigation/AppRouter+Stack.swift (per-sheet stacks) +- Flipcash/Core/Navigation/AppRouter+DestinationView.swift (destination → view map) + Session & Auth: - Flipcash/Core/Session/Session.swift - Flipcash/Core/Session/SessionAuthenticator.swift From 41b933f1b65d17720d29a4f2745536ff04c8ba1f Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 28 Apr 2026 10:56:23 -0400 Subject: [PATCH 4/8] fix: land on ScanScreen with bill after currency launch Route the launch-cover dismiss through AppRouter so the whole balance sheet collapses instead of popping the wizard back to the summary. Show the bill after the sheet animation completes so it enters fresh on ScanScreen instead of being revealed underneath the closing sheet. --- .../Currency Creation/CurrencyCreationWizardScreen.swift | 7 ++++--- .../Currency Creation/CurrencyLaunchProcessingScreen.swift | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index c492ae90d..63b518436 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -23,6 +23,7 @@ struct CurrencyCreationWizardScreen: View { @Environment(RatesController.self) private var ratesController @Environment(WalletConnection.self) private var walletConnection @Environment(OnrampCoordinator.self) private var onrampCoordinator + @Environment(AppRouter.self) private var router @State private var step: WizardStep = .name @State private var direction: Direction = .forward @@ -278,7 +279,7 @@ struct CurrencyCreationWizardScreen: View { ) .environment(\.dismissParentContainer, { reservesLaunchContext = nil - dismiss() + router.dismissSheet() }) } } @@ -299,7 +300,7 @@ struct CurrencyCreationWizardScreen: View { .environment(\.dismissParentContainer, { onrampCoordinator.completion = nil isValidating = false - dismiss() + router.dismissSheet() }) } } @@ -318,7 +319,7 @@ struct CurrencyCreationWizardScreen: View { .environment(\.dismissParentContainer, { walletConnection.dismissProcessing() isValidating = false - dismiss() + router.dismissSheet() }) } } diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingScreen.swift index 5974f3740..b9328f865 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingScreen.swift @@ -82,10 +82,15 @@ struct CurrencyLaunchProcessingScreen: View { session: session, ratesController: ratesController ) + dismissParentContainer() + // Wait for the launch-cover and balance-sheet dismiss animations to + // finish before mutating bill state, so ScanScreen renders the bill + // as a fresh entrance instead of revealing it underneath the closing + // sheet. + try? await Task.sleep(for: .milliseconds(400)) if let description { session.showCashBill(description) } - dismissParentContainer() } } From fc7689d40d010317cc712ab6c57031f7af54c4e7 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 28 Apr 2026 11:06:00 -0400 Subject: [PATCH 5/8] fix: reset stack path when sheet is dismissed Dragging a sheet down to dismiss left its NavigationPath populated, so re-presenting the sheet (e.g. tapping Wallet again) restored the stale leaf instead of starting at root. dismissSheet now clears the dismissed stack's path. Sheet swaps still go through present(_:), which preserves both stacks' paths for return trips. --- .../AppRouter+SheetPresentation.swift | 11 ++++++++ Flipcash/Core/Navigation/AppRouter.swift | 6 +++++ FlipcashTests/Navigation/AppRouterTests.swift | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index e767fdc8d..4fc911fdb 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -18,6 +18,17 @@ extension AppRouter { var id: Self { self } + /// The stack hosted inside this sheet. Inverse of `Stack.sheet`. + /// Used by `dismissSheet` to clear the dismissed stack's path so a + /// re-presentation starts at root rather than restoring the stale leaf. + var stack: Stack { + switch self { + case .balance: .balance + case .settings: .settings + case .give: .give + } + } + var description: String { switch self { case .balance: "balance" diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 30972edf7..0c2c60139 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -144,9 +144,15 @@ final class AppRouter { ]) } + /// Dismisses the active sheet and clears its stack's path so a future + /// re-presentation starts at root. Other stacks' paths are untouched — + /// only `present(_:)`-driven sheet swaps preserve the dismissed sheet's + /// own path; an outright dismiss (close button or interactive swipe-down) + /// is treated as the user closing that flow. func dismissSheet() { guard let dismissing = presentedSheet else { return } presentedSheet = nil + paths[dismissing.stack] = NavigationPath() logger.info("Dismissed sheet", metadata: ["sheet": "\(dismissing)"]) } diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift index 13f3fec33..179ccf903 100644 --- a/FlipcashTests/Navigation/AppRouterTests.swift +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -140,4 +140,29 @@ struct AppRouterTests { router.dismissSheet() #expect(router.presentedSheet == nil) } + + @Test("dismissSheet clears the dismissed stack's path") + func dismissSheet_clearsDismissedStackPath() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(.usdc), on: .balance) + + router.dismissSheet() + + #expect(router[.balance].isEmpty, + "re-presenting the sheet must start at root, not restore the stale leaf") + } + + @Test("dismissSheet preserves other stacks' paths") + func dismissSheet_preservesOtherStackPaths() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(.usdc), on: .balance) + router.setPath([.settingsMyAccount], on: .settings) + + router.dismissSheet() + + #expect(router[.settings] == AppRouter.navigationPath(.settingsMyAccount), + "only the dismissed stack should be cleared") + } } From 14c26530301065dd855d1bf02254ed580e06bf2e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 28 Apr 2026 14:09:21 -0400 Subject: [PATCH 6/8] refactor: route Transaction History, Give, and Deposit through AppRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the last local @State-driven navigations with router pushes so the trace covers every nav action and deeplinks can reach these screens. Adds .currencyInfoForDeposit, .give, and .deposit destinations and surfaces the mint as its own metadata key in router logs. Reworks dismissSheet to mark-and-clear-on-reopen instead of deferring the path clear via Task — no more "push back, then dismiss" animation, no timing dependency, no async tests. Drops the bidirectional sync between giveViewModel.isPresented and router.presentedSheet that desync'd on swipe-down and stalled the next tap of the give button. --- .../Navigation/AppRouter+Destination.swift | 43 ++++++- .../AppRouter+DestinationView.swift | 63 +++++++++++ Flipcash/Core/Navigation/AppRouter.swift | 106 ++++++++++++------ .../Currency Info/CurrencyInfoScreen.swift | 26 +---- Flipcash/Core/Screens/Main/GiveScreen.swift | 13 +-- Flipcash/Core/Screens/Main/ScanScreen.swift | 28 +++-- .../Settings/DepositCurrencyListScreen.swift | 22 +--- .../Navigation/AppRouterCrossStackTests.swift | 31 ++--- FlipcashTests/Navigation/AppRouterTests.swift | 92 +++++++++++++-- 9 files changed, 302 insertions(+), 122 deletions(-) diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ddaf0da12..7a5209b37 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -16,10 +16,16 @@ extension AppRouter { // Wallet flow case currencyInfo(PublicKey) + /// Same screen as `currencyInfo` but auto-presents the funding-selection + /// sheet on appear. Modelled as a sibling case rather than an + /// associated-value flag so the trace shows "user wanted to deposit" + /// distinctly from "user opened currency info". + case currencyInfoForDeposit(PublicKey) case discoverCurrencies case currencyCreationSummary case currencyCreationWizard case transactionHistory(PublicKey) + case give(PublicKey) // Settings flow case settingsMyAccount @@ -30,31 +36,38 @@ extension AppRouter { case settingsApplicationLogs case accessKey case depositCurrencyList + case deposit(PublicKey) case withdraw /// The stack this destination naturally belongs in. Cross-stack /// navigation uses this to know which sheet to present. var owningStack: Stack { switch self { - case .currencyInfo, .discoverCurrencies, .currencyCreationSummary, - .currencyCreationWizard, .transactionHistory: + case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies, + .currencyCreationSummary, .currencyCreationWizard, + .transactionHistory, .give: return .balance case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs, - .accessKey, .depositCurrencyList, .withdraw: + .accessKey, .depositCurrencyList, .deposit, .withdraw: return .settings } } - /// Stable string for log filtering. Deliberately omits associated values - /// so PublicKey base58 strings never end up in interpolated log messages. + /// Stable, payload-free name. Used as the `destination` log key so a + /// trail can be filtered with `grep destination=currencyInfo` regardless + /// of which mint was opened. The mint itself is surfaced separately via + /// the `payload` metadata so it remains queryable but doesn't fragment + /// the destination buckets. var description: String { switch self { case .currencyInfo: "currencyInfo" + case .currencyInfoForDeposit: "currencyInfoForDeposit" case .discoverCurrencies: "discoverCurrencies" case .currencyCreationSummary: "currencyCreationSummary" case .currencyCreationWizard: "currencyCreationWizard" case .transactionHistory: "transactionHistory" + case .give: "give" case .settingsMyAccount: "settingsMyAccount" case .settingsAdvancedFeatures: "settingsAdvancedFeatures" case .settingsAppSettings: "settingsAppSettings" @@ -63,8 +76,28 @@ extension AppRouter { case .settingsApplicationLogs: "settingsApplicationLogs" case .accessKey: "accessKey" case .depositCurrencyList: "depositCurrencyList" + case .deposit: "deposit" case .withdraw: "withdraw" } } + + /// Identifying associated value, if any, suitable for log metadata. + /// Returns `nil` for payload-free destinations so the log key is + /// omitted rather than serialised as an empty string. + var payload: String? { + switch self { + case .currencyInfo(let mint), + .currencyInfoForDeposit(let mint), + .transactionHistory(let mint), + .give(let mint), + .deposit(let mint): + return mint.base58 + case .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, + .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, + .settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs, + .accessKey, .depositCurrencyList, .withdraw: + return nil + } + } } } diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index b7ce73e39..5ebbecabf 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -36,6 +36,15 @@ struct DestinationView: View { ) .id(mint) + case .currencyInfoForDeposit(let mint): + CurrencyInfoScreen( + mint: mint, + container: container, + sessionContainer: sessionContainer, + showFundingOnAppear: true + ) + .id(mint) + case .discoverCurrencies: CurrencyDiscoveryScreen( container: container, @@ -54,6 +63,19 @@ struct DestinationView: View { case .transactionHistory(let mint): TransactionHistoryScreen(mint: mint) + case .give(let mint): + // Builds a fresh `GiveViewModel` and primes its presentation + // lifecycle (`isPresented = true`) so `refreshSelectedBalance` + // and the entered-amount reset run before first render. The + // wrapper survives recomposition via `@State`, so the viewModel + // lasts the destination's lifetime. + GiveDestinationView( + mint: mint, + container: container, + sessionContainer: sessionContainer + ) + .id(mint) + // MARK: - Settings flow case .settingsMyAccount: @@ -96,6 +118,23 @@ struct DestinationView: View { case .depositCurrencyList: DepositCurrencyListScreen() + case .deposit(let mint): + // Resolves the cluster from the live `session.balance(for:)` lookup + // — the destination only carries the mint so it stays Hashable + + // Sendable. If the balance vanished between push and render + // (shouldn't happen from the in-app picker, but possible from a + // future deeplink), render an empty placeholder rather than crash. + if let balance = sessionContainer.session.balance(for: mint), + let vmAuthority = balance.vmAuthority { + DepositScreen( + cluster: sessionContainer.session.owner.use( + mint: mint, + timeAuthority: vmAuthority + ), + name: balance.name + ) + } + case .withdraw: WithdrawScreen( container: container, @@ -120,3 +159,27 @@ extension View { } } } + +/// Owns the `GiveViewModel` for the `.give(mint)` destination. Constructing +/// the viewModel inline in `DestinationView`'s switch would lose `@State` +/// semantics — every body evaluation would create a fresh instance — so the +/// dedicated wrapper preserves the same instance across recomposition. +/// +/// On creation, the viewModel's `isPresented = true` setter fires its didSet +/// (which calls `refreshSelectedBalance` and resets the entered amount). That +/// matches the previous behaviour where `CurrencyInfoScreen.onGive` set +/// `giveViewModel.isPresented = true` immediately before the navigation push. +private struct GiveDestinationView: View { + @State private var viewModel: GiveViewModel + + init(mint: PublicKey, container: Container, sessionContainer: SessionContainer) { + sessionContainer.ratesController.selectToken(mint) + let viewModel = GiveViewModel(container: container, sessionContainer: sessionContainer) + viewModel.isPresented = true + _viewModel = State(initialValue: viewModel) + } + + var body: some View { + GiveScreen(viewModel: viewModel) + } +} diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 0c2c60139..1d205999a 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -34,6 +34,14 @@ final class AppRouter { private var paths: [Stack: NavigationPath] = [:] + /// Sheets the user has explicitly dismissed (close button, swipe-down, or + /// programmatic `dismissSheet`) since their last presentation. The next + /// `present(_:)` of an entry in this set clears the sheet's stack path so + /// re-opening starts at root. Sheet swaps don't add to the set, so swap-back + /// preserves the prior path. Bounded by the number of `SheetPresentation` + /// cases. + private var dismissedSheets: Set = [] + init() {} /// Bindable per-stack `NavigationPath`. Drives `NavigationStack(path: $router[.balance])`. @@ -48,11 +56,22 @@ final class AppRouter { func push(_ destination: Destination, on stack: Stack) { paths[stack, default: NavigationPath()].append(destination) - logger.info("Pushed destination", metadata: [ - "stack": "\(stack)", - "destination": "\(destination)", - "depth": "\(paths[stack, default: NavigationPath()].count)", - ]) + logger.info("Push", metadata: navigationMetadata(stack: stack, destination: destination)) + } + + /// Pushes onto whatever stack is currently presented (`presentedSheet?.stack`). + /// Convenience for views hosted in more than one sheet (e.g. `GiveScreen`, + /// which appears as both the `.give` sheet root and a push inside `.balance`) + /// so they don't need to thread the owning stack down through their init. + /// No-op with a warning if no sheet is presented. + func push(_ destination: Destination) { + guard let stack = presentedSheet?.stack else { + logger.warning("Push attempted with no sheet presented", metadata: [ + "destination": "\(destination)", + ]) + return + } + push(destination, on: stack) } /// Pushes any Hashable value onto the stack. Used by sub-flows whose @@ -61,26 +80,22 @@ final class AppRouter { /// without nesting `NavigationStack`s. func pushAny(_ value: H, on stack: Stack) { paths[stack, default: NavigationPath()].append(value) - logger.info("Pushed sub-flow destination", metadata: [ + logger.info("Push (sub-flow)", metadata: [ "stack": "\(stack)", "type": "\(type(of: value))", - "depth": "\(paths[stack, default: NavigationPath()].count)", ]) } func pop(on stack: Stack) { guard !(paths[stack]?.isEmpty ?? true) else { return } paths[stack]?.removeLast() - logger.info("Popped destination", metadata: [ - "stack": "\(stack)", - "newDepth": "\(paths[stack, default: NavigationPath()].count)", - ]) + logger.info("Pop", metadata: ["stack": "\(stack)"]) } func popToRoot(on stack: Stack) { guard !(paths[stack]?.isEmpty ?? true) else { return } paths[stack] = NavigationPath() - logger.info("Popped to root", metadata: ["stack": "\(stack)"]) + logger.info("Reset stack", metadata: ["stack": "\(stack)"]) } /// Pops up to `count` items from the top of `stack`. Used by sub-flows @@ -93,10 +108,9 @@ final class AppRouter { for _ in 0..")", - ]) + logger.info("Set path", metadata: navigationMetadata(stack: stack, destination: destinations.last)) } - /// SwiftUI binding setter — receives an updated `NavigationPath` whose - /// length may differ from our last known state (e.g., the user popped - /// via swipe-back). + /// SwiftUI binding setter — fires when the NavigationStack writes a new + /// path back through the binding (system swipe-back, NavigationLink + /// activation, programmatic `dismiss()`). Distinguished from explicit + /// `setPath(_:on:)` so a trail differentiates "user gesture" from + /// "intent-driven jump". private func setPath(_ newPath: NavigationPath, on stack: Stack) { let oldPath = paths[stack, default: NavigationPath()] guard oldPath != newPath else { return } paths[stack] = newPath - logger.info("Replaced path", metadata: [ - "stack": "\(stack)", - "previousDepth": "\(oldPath.count)", - "newDepth": "\(newPath.count)", - ]) + logger.info("Path changed externally", metadata: ["stack": "\(stack)"]) } // MARK: - Sheet mutators /// Presents `sheet`. Idempotent: no-op if already presenting `sheet`. + /// + /// If `sheet` was previously dismissed (sits in `dismissedSheets`), its + /// stack path is cleared synchronously *before* the sheet mounts — so a + /// re-open lands at root. A sheet swap (presenting a different sheet + /// without going through `dismissSheet` first) leaves both paths intact, + /// preserving the original "swap-and-return" behaviour. + /// + /// Doing the clear here instead of inside `dismissSheet` avoids the + /// "push back, then dismiss" animation: dismissal lets the sheet's + /// snapshot slide off-screen with its current contents intact, and the + /// clear runs only when the user actively chooses to re-open. func present(_ sheet: SheetPresentation) { guard sheet != presentedSheet else { return } let previous = presentedSheet + if dismissedSheets.remove(sheet) != nil { + paths[sheet.stack] = NavigationPath() + } presentedSheet = sheet logger.info("Presented sheet", metadata: [ "sheet": "\(sheet)", @@ -144,18 +165,35 @@ final class AppRouter { ]) } - /// Dismisses the active sheet and clears its stack's path so a future - /// re-presentation starts at root. Other stacks' paths are untouched — - /// only `present(_:)`-driven sheet swaps preserve the dismissed sheet's - /// own path; an outright dismiss (close button or interactive swipe-down) - /// is treated as the user closing that flow. + /// Dismisses the active sheet and marks it as "explicitly closed" so the + /// next `present(_:)` of the same sheet clears its stack path. The path + /// itself is left untouched here — the dismissing sheet keeps its current + /// contents through the slide-down animation, and the clear happens on + /// re-open instead. func dismissSheet() { guard let dismissing = presentedSheet else { return } presentedSheet = nil - paths[dismissing.stack] = NavigationPath() + dismissedSheets.insert(dismissing) logger.info("Dismissed sheet", metadata: ["sheet": "\(dismissing)"]) } + // MARK: - Logging helpers + + /// Builds the standard navigation log metadata: `stack`, `destination`, + /// and (when the destination carries a `PublicKey` or similar) `payload`. + /// Shared by `push` and `setPath` so the trail format stays consistent and + /// the conditional payload-add lives in one place. + private func navigationMetadata(stack: Stack, destination: Destination?) -> Logger.Metadata { + var metadata: Logger.Metadata = [ + "stack": "\(stack)", + "destination": "\(destination.map(String.init(describing:)) ?? "")", + ] + if let payload = destination?.payload { + metadata["payload"] = "\(payload)" + } + return metadata + } + // MARK: - Cross-stack navigation /// Cross-stack navigation. Presents the destination's `owningStack` (swapping diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 4cf60cc0f..11242517e 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -11,20 +11,14 @@ import FlipcashCore struct CurrencyInfoScreen: View { @State private var viewModel: CurrencyInfoViewModel - @State private var giveViewModel: GiveViewModel @Environment(\.dismiss) private var dismiss + @Environment(AppRouter.self) private var router - @State private var transactionHistoryMint: PublicKey? @State private var isShowingFundingSelection: Bool = false @State private var presentedBuyViewModel: CurrencyBuyViewModel? @State private var presentedSellViewModel: CurrencySellViewModel? @State private var isShowingCurrencySelection: Bool = false - /// Drives the navigation push to `GiveScreen`. Separate from - /// `giveViewModel.isPresented` (which triggers business logic only) - /// so that `dismissParentContainer` can tear down the sheet without - /// an intermediate pop animation. - @State private var isShowingGive: Bool = false /// Non-nil while the Onramp sheet is presented. Setting it presents the /// sheet with a fresh `OnrampViewModel`; nil'ing it dismisses. @State private var onrampDestination: BuyTarget? @@ -74,11 +68,6 @@ struct CurrencyInfoScreen: View { self.showFundingOnAppear = showFundingOnAppear self.viewModel = viewModel - self.giveViewModel = GiveViewModel( - container: container, - sessionContainer: sessionContainer - ) - self.marketCapController = MarketCapController( mint: mint, ratesController: sessionContainer.ratesController, @@ -117,14 +106,12 @@ struct CurrencyInfoScreen: View { viewModel: viewModel, ratesController: ratesController, marketCapController: marketCapController, - onShowTransactionHistory: { transactionHistoryMint = metadata.mint }, + onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint), on: .balance) }, onShowCurrencySelection: { isShowingCurrencySelection = true }, onBuy: { isShowingFundingSelection = true }, onGive: { Analytics.buttonTapped(name: .give) - ratesController.selectToken(mint) - giveViewModel.isPresented = true - isShowingGive = true + router.push(.give(mint), on: .balance) }, onSell: { Analytics.buttonTapped(name: .sell) @@ -166,9 +153,6 @@ struct CurrencyInfoScreen: View { isShowingFundingSelection = true } } - .navigationDestinationCompat(item: $transactionHistoryMint) { mint in - TransactionHistoryScreen(mint: mint) - } .fullScreenCover(item: Bindable(walletConnection).processing) { processing in NavigationStack { SwapProcessingScreen( @@ -197,9 +181,6 @@ struct CurrencyInfoScreen: View { } } } - .navigationDestination(isPresented: $isShowingGive) { - GiveScreen(viewModel: giveViewModel) - } .sheet(isPresented: Bindable(walletConnection).isShowingAmountEntry) { if let metadata = viewModel.mintMetadata { NavigationStack { @@ -217,7 +198,6 @@ struct CurrencyInfoScreen: View { } } } - .dialog(item: $giveViewModel.dialogItem) .sheet(item: $presentedBuyViewModel) { buyViewModel in CurrencyBuyAmountScreen(viewModel: buyViewModel) } diff --git a/Flipcash/Core/Screens/Main/GiveScreen.swift b/Flipcash/Core/Screens/Main/GiveScreen.swift index f5c00765d..3fb6849ce 100644 --- a/Flipcash/Core/Screens/Main/GiveScreen.swift +++ b/Flipcash/Core/Screens/Main/GiveScreen.swift @@ -24,6 +24,7 @@ struct GiveScreen: View { @Environment(Session.self) private var session @Environment(RatesController.self) private var ratesController + @Environment(AppRouter.self) private var router @Bindable private var viewModel: GiveViewModel @@ -97,13 +98,11 @@ struct GiveScreen: View { .id(viewModel.selectedBalance?.stored.mint) } } - .navigationDestination(item: $viewModel.depositMint) { mint in - CurrencyInfoScreen( - mint: mint, - container: viewModel.container, - sessionContainer: viewModel.sessionContainer, - showFundingOnAppear: true - ) + .onChange(of: viewModel.depositMint) { _, mint in + guard let mint else { return } + // Clear the trigger so a subsequent tap re-fires the push. + viewModel.depositMint = nil + router.push(.currencyInfoForDeposit(mint)) } .sheet(isPresented: $isShowingTokenSelection) { SelectCurrencyScreen( diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 25df13518..079b3fbb8 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -164,16 +164,6 @@ struct ScanScreen: View { router.dismissSheet() giveViewModel.isPresented = false } - // Mirror GiveViewModel's business-logic flag to the router. The model - // owns "should the give flow be open" (it falls back to false on - // no-balance); the router owns presentation. Sync them here. - .onChange(of: giveViewModel.isPresented) { _, isPresented in - if isPresented { - router.present(.give) - } else if router.presentedSheet == .give { - router.dismissSheet() - } - } // Reset button state on bill dismissal — `sendButtonState` outlives individual bills. .onChange(of: session.billState.bill) { _, newBill in guard newBill == nil else { return } @@ -304,9 +294,12 @@ struct ScanScreen: View { fullWidth: true, aligment: .bottom ) { - // Setting the model flag triggers the in-model balance check; - // the .onChange in `body` mirrors success to the router. + // `isPresented = true` runs the viewModel's didSet (balance + // check + entered-amount reset). Present the sheet directly + // — no .onChange relay, so swipe-down dismiss can't desync + // the flag from the router and stall the next tap. giveViewModel.isPresented = true + router.present(.give) } ToastContainer(toast: toast) { @@ -386,7 +379,10 @@ private struct RoutedSheet: View { let sessionContainer: SessionContainer let giveViewModel: GiveViewModel + @Environment(AppRouter.self) private var router + var body: some View { + @Bindable var router = router switch sheet { case .balance: BalanceScreen( @@ -399,14 +395,16 @@ private struct RoutedSheet: View { sessionContainer: sessionContainer ) case .give: - NavigationStack { + // Stack bound to the router so deposit-mint pushes from inside + // GiveScreen (`.currencyInfoForDeposit`) actually render. + NavigationStack(path: $router[.give]) { GiveScreen(viewModel: giveViewModel) + .appRouterDestinations(container: container, sessionContainer: sessionContainer) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { ToolbarCloseButton { - // Mirrored back to the router via the - // `.onChange(of: giveViewModel.isPresented)` in ScanScreen. giveViewModel.isPresented = false + router.dismissSheet() } } } diff --git a/Flipcash/Core/Screens/Settings/DepositCurrencyListScreen.swift b/Flipcash/Core/Screens/Settings/DepositCurrencyListScreen.swift index 5b2cef790..437bdb582 100644 --- a/Flipcash/Core/Screens/Settings/DepositCurrencyListScreen.swift +++ b/Flipcash/Core/Screens/Settings/DepositCurrencyListScreen.swift @@ -13,8 +13,8 @@ struct DepositCurrencyListScreen: View { @Environment(Session.self) private var session @Environment(RatesController.self) private var ratesController + @Environment(AppRouter.self) private var router - @State private var selectedBalance: ExchangedBalance? @State private var selectedMint: PublicKey? // Skip session.balances(for:) to avoid filtering out zero-balance currencies. @@ -52,12 +52,6 @@ struct DepositCurrencyListScreen: View { } .navigationTitle("Select Currency") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(item: $selectedBalance) { balance in - DepositScreen( - cluster: depositCluster(for: balance.stored), - name: balance.stored.name - ) - } .onAppear { handleAutoSelect() } @@ -66,19 +60,13 @@ struct DepositCurrencyListScreen: View { // MARK: - Actions - private func selectCurrency(_ balance: ExchangedBalance) { - selectedBalance = balance + router.push(.deposit(balance.stored.mint), on: .settings) } private func handleAutoSelect() { - guard let mint = selectedMint else { return } + guard let mint = selectedMint, + balances.contains(where: { $0.stored.mint == mint }) else { return } selectedMint = nil - selectedBalance = balances.first(where: { $0.stored.mint == mint }) - } - - private func depositCluster(for balance: StoredBalance) -> AccountCluster { - session.owner.use( - mint: balance.mint, - timeAuthority: balance.vmAuthority! - ) + router.push(.deposit(mint), on: .settings) } } diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index d94239035..8de2e96fe 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -85,20 +85,23 @@ struct AppRouterCrossStackTests { @Test( "Destination maps to its owning stack", arguments: [ - (AppRouter.Destination.currencyInfo(.usdc), AppRouter.Stack.balance), - (AppRouter.Destination.discoverCurrencies, AppRouter.Stack.balance), - (AppRouter.Destination.currencyCreationSummary, AppRouter.Stack.balance), - (AppRouter.Destination.currencyCreationWizard, AppRouter.Stack.balance), - (AppRouter.Destination.transactionHistory(.usdc), AppRouter.Stack.balance), - (AppRouter.Destination.settingsMyAccount, AppRouter.Stack.settings), - (AppRouter.Destination.settingsAdvancedFeatures, AppRouter.Stack.settings), - (AppRouter.Destination.settingsAppSettings, AppRouter.Stack.settings), - (AppRouter.Destination.settingsBetaFlags, AppRouter.Stack.settings), - (AppRouter.Destination.settingsAccountSelection, AppRouter.Stack.settings), - (AppRouter.Destination.settingsApplicationLogs, AppRouter.Stack.settings), - (AppRouter.Destination.accessKey, AppRouter.Stack.settings), - (AppRouter.Destination.depositCurrencyList, AppRouter.Stack.settings), - (AppRouter.Destination.withdraw, AppRouter.Stack.settings), + (AppRouter.Destination.currencyInfo(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.currencyInfoForDeposit(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.discoverCurrencies, AppRouter.Stack.balance), + (AppRouter.Destination.currencyCreationSummary, AppRouter.Stack.balance), + (AppRouter.Destination.currencyCreationWizard, AppRouter.Stack.balance), + (AppRouter.Destination.transactionHistory(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.give(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.settingsMyAccount, AppRouter.Stack.settings), + (AppRouter.Destination.settingsAdvancedFeatures, AppRouter.Stack.settings), + (AppRouter.Destination.settingsAppSettings, AppRouter.Stack.settings), + (AppRouter.Destination.settingsBetaFlags, AppRouter.Stack.settings), + (AppRouter.Destination.settingsAccountSelection, AppRouter.Stack.settings), + (AppRouter.Destination.settingsApplicationLogs, AppRouter.Stack.settings), + (AppRouter.Destination.accessKey, AppRouter.Stack.settings), + (AppRouter.Destination.depositCurrencyList, AppRouter.Stack.settings), + (AppRouter.Destination.deposit(.usdc), AppRouter.Stack.settings), + (AppRouter.Destination.withdraw, AppRouter.Stack.settings), ] ) func destination_hasCorrectOwningStack( diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift index 179ccf903..268dab0a2 100644 --- a/FlipcashTests/Navigation/AppRouterTests.swift +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -107,6 +107,23 @@ struct AppRouterTests { #expect(router[.settings] == AppRouter.navigationPath(.settingsMyAccount)) } + @Test("push(_:) derives the stack from the presented sheet") + func push_inferredStack_usesPresentedSheet() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(.usdc)) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("push(_:) is a no-op when no sheet is presented") + func push_inferredStack_noopWhenNoSheet() { + let router = AppRouter() + router.push(.currencyInfo(.usdc)) + #expect(router[.balance].isEmpty) + #expect(router[.settings].isEmpty) + #expect(router[.give].isEmpty) + } + // MARK: - present / dismissSheet @Test("present sets the sheet") @@ -141,28 +158,89 @@ struct AppRouterTests { #expect(router.presentedSheet == nil) } - @Test("dismissSheet clears the dismissed stack's path") - func dismissSheet_clearsDismissedStackPath() { + @Test("dismissSheet leaves the path intact for the dismiss-animation snapshot") + func dismissSheet_leavesPathIntact() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(.usdc), on: .balance) + + router.dismissSheet() + + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc)), + "path should survive dismiss so the closing sheet animates with its current contents") + } + + @Test("re-presenting a previously-dismissed sheet clears its stack path") + func present_afterDismiss_clearsPath() { let router = AppRouter() router.present(.balance) router.push(.currencyInfo(.usdc), on: .balance) + router.dismissSheet() + + router.present(.balance) + + #expect(router[.balance].isEmpty, + "re-opening after a dismiss must start at root") + } + @Test("re-presenting after dismiss + opening another sheet still clears on return") + func present_afterDismissAndIntermediate_stillClearsOnReturn() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(.usdc), on: .balance) router.dismissSheet() + router.present(.settings) + + router.present(.balance) #expect(router[.balance].isEmpty, - "re-presenting the sheet must start at root, not restore the stale leaf") + "the dismissed-marker survives across other presentations") } - @Test("dismissSheet preserves other stacks' paths") - func dismissSheet_preservesOtherStackPaths() { + @Test("sheet swap (no dismiss between) preserves both stacks' paths") + func present_swap_preservesPaths() { let router = AppRouter() router.present(.balance) router.push(.currencyInfo(.usdc), on: .balance) router.setPath([.settingsMyAccount], on: .settings) - router.dismissSheet() + router.present(.settings) + router.present(.balance) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc)), + "swap-back must restore the original path") #expect(router[.settings] == AppRouter.navigationPath(.settingsMyAccount), - "only the dismissed stack should be cleared") + "the swapped-from path must survive") + } + + // MARK: - Destination payload + + @Test( + "destinations carrying a mint expose its base58 as the log payload", + arguments: [ + AppRouter.Destination.currencyInfo(.usdc), + AppRouter.Destination.currencyInfoForDeposit(.usdc), + AppRouter.Destination.transactionHistory(.usdc), + AppRouter.Destination.give(.usdc), + AppRouter.Destination.deposit(.usdc), + ] + ) + func destination_payload_returnsMintForKeyedCases(_ destination: AppRouter.Destination) { + #expect(destination.payload == PublicKey.usdc.base58) + } + + @Test( + "payload-free destinations return nil so the log key is omitted", + arguments: [ + AppRouter.Destination.discoverCurrencies, + AppRouter.Destination.currencyCreationSummary, + AppRouter.Destination.currencyCreationWizard, + AppRouter.Destination.settingsMyAccount, + AppRouter.Destination.depositCurrencyList, + AppRouter.Destination.withdraw, + ] + ) + func destination_payload_returnsNilForKeylessCases(_ destination: AppRouter.Destination) { + #expect(destination.payload == nil) } } From 8f1172b465304c901ad1b25782b8aa3254d8d5df Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 28 Apr 2026 14:09:47 -0400 Subject: [PATCH 7/8] chore: drop redundant Analytics track debug log Router navigation logs now cover the user-action trail; the per-event Analytics debug line was duplicating that signal. --- Flipcash/Utilities/Analytics.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Flipcash/Utilities/Analytics.swift b/Flipcash/Utilities/Analytics.swift index 4ce3d72cc..8699045aa 100644 --- a/Flipcash/Utilities/Analytics.swift +++ b/Flipcash/Utilities/Analytics.swift @@ -50,7 +50,6 @@ enum Analytics { } private static func track(_ name: String, properties: [String: AnalyticsValue]? = nil) { - logger.debug("Track", metadata: ["event": "\(name)", "properties": "\(properties ?? [:])"]) mixpanel.track(event: name, properties: properties) } } From 95f44922d191861665170ad3b222194a9a910561 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 28 Apr 2026 14:13:59 -0400 Subject: [PATCH 8/8] docs: tighten AppRouter section with deeplink test and path lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local-vs-router rule needed a clearer tiebreaker for new screens, and the dismiss/present clear-on-reopen contract wasn't documented anywhere — easy for future contributors to add manual popToRoot calls around their own dismissals and fight the router. --- CLAUDE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83f1de913..50802b944 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -260,7 +260,11 @@ All navigation flows through `AppRouter` — a single `@Observable @MainActor` c Top-level sheets (`Balance`, `Settings`, `Give`) each own a `NavigationStack(path: $router[.])` and register destinations via the `.appRouterDestinations(...)` modifier on their root content. Per-stack paths are `NavigationPath` (type-erased), so sub-flow destinations (e.g., `WithdrawNavigationPath`) coexist with top-level `Destination` cases on the same stack — register `.navigationDestination(for: SubFlowPath.self)` on the sub-flow root view and push via `router.pushAny(_:on:)`. **Don't nest a `NavigationStack` inside another stack's destination** — push/pop/push corrupts SwiftUI's stack state with `comparisonTypeMismatch`. -**Local interaction sheets stay local.** Transient pickers (currency selection, buy/sell amount, funding selection) belong on the screen that owns them as `.sheet(...)` modifiers — they're interactions, not navigation. Only the navigation graph goes through the router. +**Local interaction sheets stay local.** Transient pickers (currency selection, buy/sell amount, funding selection) and operation-bound modals (swap/launch processing covers) belong on the screen that owns them as `.sheet(...)` / `.fullScreenCover(...)` modifiers — they're interactions or in-flight status, not navigation. + +**The test:** if a deeplink could reasonably land the user here, it's a destination — route through `AppRouter`. If not, keep it local. + +**Sheet path lifecycle.** `dismissSheet` leaves the dismissed sheet's `NavigationPath` populated so the closing animation runs with its current contents. The path is cleared on the next `present(_:)` of that same sheet, so re-opens land at root. Sheet swaps (presenting another sheet without dismissing first) preserve both paths for swap-back. Don't add manual `popToRoot` calls around your own dismissal — let the router handle it. Every router mutation logs one INFO entry under `flipcash.router` — filter by that label to trace any navigation interaction.