diff --git a/CLAUDE.md b/CLAUDE.md index a842bb607..50802b944 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,20 @@ 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) 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. + ### Key Architectural Concepts 1. **Quarks** - Smallest unit of any currency (like cents for dollars) @@ -392,7 +406,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 +514,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 +525,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 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..7a5209b37 --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -0,0 +1,103 @@ +// +// 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) + /// 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 + case settingsAdvancedFeatures + case settingsAppSettings + case settingsBetaFlags + case settingsAccountSelection + 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, .currencyInfoForDeposit, .discoverCurrencies, + .currencyCreationSummary, .currencyCreationWizard, + .transactionHistory, .give: + return .balance + case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, + .settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs, + .accessKey, .depositCurrencyList, .deposit, .withdraw: + return .settings + } + } + + /// 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" + case .settingsBetaFlags: "settingsBetaFlags" + case .settingsAccountSelection: "settingsAccountSelection" + 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 new file mode 100644 index 000000000..5ebbecabf --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -0,0 +1,185 @@ +// +// 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 .currencyInfoForDeposit(let mint): + CurrencyInfoScreen( + mint: mint, + container: container, + sessionContainer: sessionContainer, + showFundingOnAppear: true + ) + .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) + + 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: + 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 .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, + 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 + ) + } + } +} + +/// 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+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift new file mode 100644 index 000000000..4fc911fdb --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -0,0 +1,40 @@ +// +// 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 } + + /// 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" + 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..1d205999a --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -0,0 +1,224 @@ +// +// 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] = [:] + + /// 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])`. + /// 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("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 + /// 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("Push (sub-flow)", metadata: [ + "stack": "\(stack)", + "type": "\(type(of: value))", + ]) + } + + func pop(on stack: Stack) { + guard !(paths[stack]?.isEmpty ?? true) else { return } + paths[stack]?.removeLast() + logger.info("Pop", metadata: ["stack": "\(stack)"]) + } + + func popToRoot(on stack: Stack) { + guard !(paths[stack]?.isEmpty ?? true) else { return } + paths[stack] = NavigationPath() + logger.info("Reset stack", 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..")", + ]) + } + + /// 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 + 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 + /// 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 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() } } 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..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, @@ -103,24 +92,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 { @@ -135,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) @@ -184,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( @@ -215,9 +181,6 @@ struct CurrencyInfoScreen: View { } } } - .navigationDestination(isPresented: $isShowingGive) { - GiveScreen(viewModel: giveViewModel) - } .sheet(isPresented: Bindable(walletConnection).isShowingAmountEntry) { if let metadata = viewModel.mintMetadata { NavigationStack { @@ -235,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/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/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 05c272ed2..079b3fbb8 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,8 +161,7 @@ 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 } // Reset button state on bill dismissal — `sendButtonState` outlives individual bills. @@ -260,23 +275,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 +292,16 @@ 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 + ) { + // `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) } - -// 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 +310,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 +368,48 @@ 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 + + @Environment(AppRouter.self) private var router + + var body: some View { + @Bindable var router = router + switch sheet { + case .balance: + BalanceScreen( + container: container, + sessionContainer: sessionContainer + ) + case .settings: + SettingsScreen( + container: container, + sessionContainer: sessionContainer + ) + case .give: + // 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 { + 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/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..3944865f5 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift @@ -0,0 +1,53 @@ +// +// 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) { + 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) + } + + 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/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/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) } } diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift new file mode 100644 index 000000000..8de2e96fe --- /dev/null +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -0,0 +1,125 @@ +// +// 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( + "Destination maps to its owning stack", + arguments: [ + (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( + _ destination: AppRouter.Destination, + expected: AppRouter.Stack + ) { + #expect(destination.owningStack == expected) + } + + @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) + } +} diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift new file mode 100644 index 000000000..268dab0a2 --- /dev/null +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -0,0 +1,246 @@ +// +// 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)) + } + + @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") + 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) + } + + @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, + "the dismissed-marker survives across other presentations") + } + + @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.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), + "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) + } +} 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 )