From dfe81a12b1d3f11a33e70c5b32f267f969dfc40e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 13:19:32 -0400 Subject: [PATCH 01/33] feat: add .buy router sheet and BuyFlowPath sub-flow Foundation for the amount-first buy flow: adds a `.buy(PublicKey)` case to `AppRouter.SheetPresentation`, a matching `.buy` case to `AppRouter.Stack`, and the `BuyFlowPath` sub-flow enum for screens pushed via `router.pushAny` onto the new buy stack. `Stack.buy.sheet` precondition-fails because the buy sheet carries a mint payload that can't be synthesized from the stack alone; buy is always entered via `router.present(.buy(mint))` directly. `RoutedSheet` gets a temporary `EmptyView` placeholder for the new case to keep the exhaustive switch compiling; the actual `BuyAmountScreen` lands in the next task. --- .../AppRouter+SheetPresentation.swift | 4 ++ .../Core/Navigation/AppRouter+Stack.swift | 8 +++ .../Core/Screens/Main/Buy/BuyFlowPath.swift | 26 +++++++++ Flipcash/Core/Screens/Main/ScanScreen.swift | 5 ++ .../Navigation/AppRouterBuySheetTests.swift | 54 +++++++++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift create mode 100644 FlipcashTests/Navigation/AppRouterBuySheetTests.swift diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 295cbaff..4a52dbac 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -6,6 +6,7 @@ // import Foundation +import FlipcashCore extension AppRouter { @@ -16,6 +17,7 @@ extension AppRouter { case settings case give case discover + case buy(PublicKey) var id: Self { self } @@ -28,6 +30,7 @@ extension AppRouter { case .settings: .settings case .give: .give case .discover: .discover + case .buy: .buy } } @@ -37,6 +40,7 @@ extension AppRouter { case .settings: "settings" case .give: "give" case .discover: "discover" + case .buy: "buy" } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift index 4a64355f..21d2fb02 100644 --- a/Flipcash/Core/Navigation/AppRouter+Stack.swift +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -17,15 +17,22 @@ extension AppRouter { case settings case give case discover + case buy /// The sheet a stack is presented in. Cross-stack navigation uses /// this to know which top-level modal to surface. + /// + /// `.buy` is excluded — its sheet carries a mint payload that can't be + /// synthesized from the stack alone. Buy is always entered via + /// `router.present(.buy(mint))` directly, never via `navigate(to:)`. var sheet: SheetPresentation { switch self { case .balance: .balance case .settings: .settings case .give: .give case .discover: .discover + case .buy: + preconditionFailure("buy sheet must be presented via router.present(.buy(mint)); not reachable via Stack.sheet") } } @@ -35,6 +42,7 @@ extension AppRouter { case .settings: "settings" case .give: "give" case .discover: "discover" + case .buy: "buy" } } } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift new file mode 100644 index 00000000..039ca6ef --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -0,0 +1,26 @@ +// +// BuyFlowPath.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import Foundation +import FlipcashCore + +/// Sub-flow path for the buy stack. The `.buy(mint)` sheet's root is +/// `BuyAmountScreen`; secondary screens (Phantom education/confirm, USDC +/// deposit education/address, post-buy processing) are pushed onto the same +/// stack via `router.pushAny(_:on:)`. +/// +/// Modelled as a Hashable enum (not `AppRouter.Destination` cases) because the +/// associated values include `ExchangedFiat` and `SwapId` — both already +/// Hashable + Sendable. Keeping these out of `Destination` matches the +/// `WithdrawNavigationPath` pattern. +enum BuyFlowPath: Hashable { + case phantomEducation(mint: PublicKey, amount: ExchangedFiat) + case phantomConfirm(mint: PublicKey, amount: ExchangedFiat) + case usdcDepositEducation(mint: PublicKey, amount: ExchangedFiat) + case usdcDepositAddress(mint: PublicKey, amount: ExchangedFiat) + case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat, swapType: SwapType) +} diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 47243bb4..e314318d 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -381,6 +381,11 @@ private struct RoutedSheet: View { } } } + case .buy: + // Placeholder — replaced with `BuyAmountScreen` in the next task. + // The case exists now so the enum's exhaustive switch compiles + // alongside the new `.buy(PublicKey)` sheet presentation. + EmptyView() } } } diff --git a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift new file mode 100644 index 00000000..0caa124e --- /dev/null +++ b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift @@ -0,0 +1,54 @@ +// +// AppRouterBuySheetTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-05-12. +// + +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("AppRouter buy sheet") +@MainActor +struct AppRouterBuySheetTests { + + @Test("present(.buy(mint)) sets presentedSheet to .buy with the given mint") + func presentBuySheet() { + let router = AppRouter() + let mint = PublicKey.usdf + + router.present(.buy(mint)) + + #expect(router.presentedSheet == .buy(mint)) + } + + @Test("present(.buy) targets the .buy stack") + func buySheetTargetsBuyStack() { + let router = AppRouter() + let mint = PublicKey.usdf + + router.present(.buy(mint)) + + #expect(router.presentedSheet?.stack == .buy) + } + + @Test("pushAny BuyFlowPath onto the .buy stack appends the value") + func pushAnyBuyFlowPath() { + let router = AppRouter() + let mint = PublicKey.usdf + router.present(.buy(mint)) + + // Mirrors `CurrencyBuyViewModel.maxPossibleAmount` — build a minimal + // ExchangedFiat with zero on-chain amount against a fixed USD rate. + let rate = Rate(fx: 1, currency: .usd) + let pinned = ExchangedFiat.compute( + onChainAmount: .zero(mint: .usdf), + rate: rate, + supplyQuarks: nil + ) + router.pushAny(BuyFlowPath.phantomEducation(mint: mint, amount: pinned)) + + #expect(router[.buy].count == 1) + } +} From b4f5dab6d7a967508ee32f780d8a33716d4729be Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 13:25:19 -0400 Subject: [PATCH 02/33] chore: address review nits on buy router foundation --- Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift | 2 +- FlipcashTests/Navigation/AppRouterBuySheetTests.swift | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift index 039ca6ef..5e00eb3c 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -11,7 +11,7 @@ import FlipcashCore /// Sub-flow path for the buy stack. The `.buy(mint)` sheet's root is /// `BuyAmountScreen`; secondary screens (Phantom education/confirm, USDC /// deposit education/address, post-buy processing) are pushed onto the same -/// stack via `router.pushAny(_:on:)`. +/// stack via `router.pushAny(_:)`. /// /// Modelled as a Hashable enum (not `AppRouter.Destination` cases) because the /// associated values include `ExchangedFiat` and `SwapId` — both already diff --git a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift index 0caa124e..c91b1199 100644 --- a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift @@ -41,10 +41,9 @@ struct AppRouterBuySheetTests { // Mirrors `CurrencyBuyViewModel.maxPossibleAmount` — build a minimal // ExchangedFiat with zero on-chain amount against a fixed USD rate. - let rate = Rate(fx: 1, currency: .usd) let pinned = ExchangedFiat.compute( onChainAmount: .zero(mint: .usdf), - rate: rate, + rate: .oneToOne, supplyQuarks: nil ) router.pushAny(BuyFlowPath.phantomEducation(mint: mint, amount: pinned)) From 792851a2a0690bc8396eb59cf85ed86a7d7d1d85 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 13:39:49 -0400 Subject: [PATCH 03/33] feat: add BuyAmountViewModel with USDF balance gate --- .../Screens/Main/Buy/BuyAmountViewModel.swift | 181 ++++++++++++++++++ .../Main/Buy/PurchaseMethodContext.swift | 21 ++ .../Buy/BuyAmountViewModelTests.swift | 147 ++++++++++++++ .../SessionContainer+TestSupport.swift | 11 +- 4 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift create mode 100644 FlipcashTests/Buy/BuyAmountViewModelTests.swift diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift new file mode 100644 index 00000000..427e8293 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -0,0 +1,181 @@ +// +// BuyAmountViewModel.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.buy-amount") + +@Observable +@MainActor +final class BuyAmountViewModel: Identifiable { + var actionButtonState: ButtonState = .normal + var enteredAmount: String = "" + var dialogItem: DialogItem? + var pendingMethodSelection: PurchaseMethodContext? + + let mint: PublicKey + let currencyName: String + + var enteredFiat: ExchangedFiat? { + computeAmount(using: ratesController.rateForBalanceCurrency()) + } + + var canPerformAction: Bool { + guard enteredFiat != nil else { return false } + return EnterAmountCalculator.isWithinDisplayLimit( + enteredAmount: enteredAmount, + max: maxPossibleAmount.nativeAmount + ) + } + + /// Single-transaction cap exposed by the server via `Limits.sendLimitFor`. + /// Matches what `EnterAmountView`'s subtitle renders for `.buy` mode, so + /// the in-view cap and the view-model gate stay aligned. + var maxPossibleAmount: ExchangedFiat { + let rate = ratesController.rateForBalanceCurrency() + let maxNative = session.sendLimitFor(currency: rate.currency)?.maxPerDay + ?? FiatAmount.zero(in: rate.currency) + return ExchangedFiat(nativeAmount: maxNative, rate: rate) + } + + var screenTitle: String { "Amount" } + + @ObservationIgnored private let session: Session + @ObservationIgnored private let ratesController: RatesController + + init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { + self.mint = mint + self.currencyName = currencyName + self.session = session + self.ratesController = ratesController + } + + // MARK: - Submission + + /// Single source of truth for amount submission. Pin verified state, compute + /// quarks against the pin, run limit + balance gates, then either auto-buy + /// (when USDF covers the amount) or hand off to the funding picker via + /// ``pendingMethodSelection``. + /// + /// `session` is captured in init; the caller only injects `router` because + /// SwiftUI's `@Environment` isn't reliably available from a viewmodel. + func amountEnteredAction(router: AppRouter) async { + guard enteredFiat != nil else { return } + actionButtonState = .loading + + guard let (amount, pin) = await prepareSubmission() else { + actionButtonState = .normal + dialogItem = .staleRate + return + } + + let sendLimit = session.sendLimitFor(currency: amount.nativeAmount.currency) ?? .zero + guard amount.nativeAmount.value <= sendLimit.maxPerDay.value else { + logger.info("Buy rejected: amount exceeds limit", metadata: [ + "amount": "\(amount.nativeAmount.formatted())", + "max_per_day": "\(sendLimit.maxPerDay.value)", + "currency": "\(amount.nativeAmount.currency)", + ]) + actionButtonState = .normal + showLimitsError() + return + } + + if usdfBalanceCovers(amount) { + await performAutoBuy(amount: amount, pin: pin, router: router) + } else { + actionButtonState = .normal + pendingMethodSelection = PurchaseMethodContext( + mint: mint, + currencyName: currencyName, + amount: amount, + verifiedState: pin + ) + } + } + + private func usdfBalanceCovers(_ amount: ExchangedFiat) -> Bool { + guard let balance = session.balance(for: .usdf) else { return false } + return balance.usdf.value >= amount.usdfValue.value + } + + private func performAutoBuy(amount: ExchangedFiat, pin: VerifiedState, router: AppRouter) async { + do { + let swapId = try await session.buy(amount: amount, verifiedState: pin, of: mint) + actionButtonState = .normal + router.pushAny(BuyFlowPath.processing( + swapId: swapId, + currencyName: currencyName, + amount: amount, + swapType: .buyWithReserves + )) + } catch Session.Error.insufficientBalance { + // Race: balance gate said OK but session.buy disagreed. Route to picker. + actionButtonState = .normal + pendingMethodSelection = PurchaseMethodContext( + mint: mint, + currencyName: currencyName, + amount: amount, + verifiedState: pin + ) + } catch Session.Error.verifiedStateStale { + actionButtonState = .normal + } catch { + ErrorReporting.captureError( + error, + reason: "Failed to auto-buy currency from BuyAmountScreen", + metadata: ["mint": mint.base58, "amount": amount.nativeAmount.formatted()] + ) + actionButtonState = .normal + showGenericError() + } + } + + /// Pin verified state once, compute amount against the pin. + /// Mirrors CurrencyBuyViewModel.prepareSubmission so quarks can't drift. + private func prepareSubmission() async -> (amount: ExchangedFiat, pinnedState: VerifiedState)? { + let currency = ratesController.balanceCurrency + guard let pin = await ratesController.currentPinnedState(for: currency, mint: .usdf) else { + return nil + } + guard let amount = computeAmount(using: pin.rate) else { return nil } + return (amount, pin) + } + + private func computeAmount(using rate: Rate) -> ExchangedFiat? { + guard !enteredAmount.isEmpty else { return nil } + // Use Decimal(string:) — the keypad always emits "." regardless of locale. + // NumberFormatter.decimal(from:) breaks on non-"." locales (CLAUDE.md pitfall). + guard let amount = Decimal(string: enteredAmount) else { return nil } + return ExchangedFiat( + nativeAmount: FiatAmount(value: amount, currency: rate.currency), + rate: rate + ) + } + + // MARK: - Dialogs + + private func showLimitsError() { + dialogItem = .init( + style: .destructive, + title: "Transaction Limit Reached", + subtitle: "You can only buy up to the transaction limit at a time", + dismissable: true + ) { .okay(kind: .destructive) } + } + + private func showGenericError() { + dialogItem = .init( + style: .destructive, + title: "Something Went Wrong", + subtitle: "Please try again later", + dismissable: true + ) { .okay(kind: .destructive) } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift new file mode 100644 index 00000000..d80282df --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift @@ -0,0 +1,21 @@ +// +// PurchaseMethodContext.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import Foundation +import FlipcashCore + +/// Snapshot of pinned submission state that travels from the amount-entry +/// screen into whichever funding branch the user picks (Apple Pay, Phantom, +/// Other Wallet). Carrying the pin avoids re-fetching the verified state at +/// each step and preserves the pin-at-compute invariant. +struct PurchaseMethodContext: Identifiable { + let id = UUID() + let mint: PublicKey + let currencyName: String + let amount: ExchangedFiat + let verifiedState: VerifiedState +} diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift new file mode 100644 index 00000000..b118ad61 --- /dev/null +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -0,0 +1,147 @@ +// +// BuyAmountViewModelTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-05-12. +// + +import Foundation +import Testing +import FlipcashCore +@testable import FlipcashCore +@testable import Flipcash + +@Suite("BuyAmountViewModel — USDF gate") +@MainActor +struct BuyAmountViewModelTests { + + // MARK: - Test fixtures + + /// Server-provided per-day limit that the gate must clear before any + /// submission. Set high enough that the test entered amounts ($1–$20) all + /// pass; the only thing varying between tests is the USDF balance. + private static let testSendLimit = SendLimit( + nextTransaction: FiatAmount(value: 1000, currency: .usd), + maxPerTransaction: FiatAmount(value: 1000, currency: .usd), + maxPerDay: FiatAmount(value: 1000, currency: .usd) + ) + + /// Builds a `SessionContainer` with the given USDF balance and seeds the + /// fresh verified state + send limits the viewmodel needs to reach the + /// USDF gate. Without these, `prepareSubmission` returns nil and the + /// flow short-circuits at `dialogItem = .staleRate`. + private static func makeContainer(usdfQuarks: UInt64) async throws -> SessionContainer { + let holdings: [SessionContainer.Holding] = usdfQuarks == 0 + ? [] + : [.init(mint: MintMetadata.usdf, quarks: usdfQuarks)] + + let container = try SessionContainer.makeTest( + holdings: holdings, + limits: Limits( + sinceDate: .now, + fetchDate: .now, + sendLimits: [.usd: testSendLimit] + ) + ) + + // Pin a fresh USD verified state so prepareSubmission() succeeds. + await container.ratesController.verifiedProtoService.saveRates([ + .freshRate(currencyCode: "USD", rate: 1.0) + ]) + + return container + } + + private static func makeViewModel( + mint: PublicKey = .usdf, + currencyName: String = "Jeffy", + container: SessionContainer + ) -> BuyAmountViewModel { + BuyAmountViewModel( + mint: mint, + currencyName: currencyName, + session: container.session, + ratesController: container.ratesController + ) + } + + // MARK: - Gate decision tests + + @Test("Sufficient USDF balance does not surface the funding picker") + func sufficientBalance_doesNotOpenPicker() async throws { + // $50 USDF (6 decimals) → balance covers $20 entry. + let container = try await Self.makeContainer(usdfQuarks: 50_000_000) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.buy(.usdf)) + + viewModel.enteredAmount = "20" + await viewModel.amountEnteredAction(router: router) + + // Gate routed to auto-buy, not the picker. The subsequent + // session.buy network call is out of scope for this unit test — + // here we only assert the gate decision. + #expect(viewModel.pendingMethodSelection == nil) + } + + @Test("Insufficient USDF balance opens the funding picker") + func insufficientBalance_opensPicker() async throws { + // $5 USDF cannot cover a $20 buy. + let container = try await Self.makeContainer(usdfQuarks: 5_000_000) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.buy(.usdf)) + + viewModel.enteredAmount = "20" + await viewModel.amountEnteredAction(router: router) + + let context = try #require(viewModel.pendingMethodSelection) + #expect(context.amount.nativeAmount.value > 0) + // No push fired — picker is a local sheet, not a stack destination. + #expect(router[.buy].count == 0) + } + + @Test("Zero USDF balance opens the funding picker") + func zeroBalance_opensPicker() async throws { + let container = try await Self.makeContainer(usdfQuarks: 0) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.buy(.usdf)) + + viewModel.enteredAmount = "1" + await viewModel.amountEnteredAction(router: router) + + #expect(viewModel.pendingMethodSelection != nil) + } + + @Test("Pinned amount is carried into the PurchaseMethodContext") + func pinPropagation() async throws { + let container = try await Self.makeContainer(usdfQuarks: 0) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.buy(.usdf)) + + viewModel.enteredAmount = "10" + await viewModel.amountEnteredAction(router: router) + + let context = try #require(viewModel.pendingMethodSelection) + // Native USD amount round-trips through the pin into the context. + #expect(context.amount.nativeAmount.value == 10) + #expect(context.amount.nativeAmount.currency == .usd) + } + + @Test("Empty entered amount does nothing on submit") + func emptyAmount_noop() async throws { + let container = try await Self.makeContainer(usdfQuarks: 50_000_000) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.buy(.usdf)) + + viewModel.enteredAmount = "" + await viewModel.amountEnteredAction(router: router) + + #expect(viewModel.pendingMethodSelection == nil) + #expect(router[.buy].count == 0) + #expect(viewModel.dialogItem == nil) + } +} diff --git a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift index 862be692..357f7951 100644 --- a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift +++ b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift @@ -23,10 +23,15 @@ extension SessionContainer { /// at construction, so the returned container's `session.balances` /// reflects the seed on the first access. /// + /// Pass `limits` to also seed `session.limits` — useful for view + /// models that gate on `sendLimitFor(currency:)` (the limits + /// Updateable also loads at Session init, so this must run before + /// the Session is constructed). + /// /// Each call produces an independent database file so tests don't /// share balance state. @MainActor - static func makeTest(holdings: [Holding]) throws -> SessionContainer { + static func makeTest(holdings: [Holding], limits: Limits? = nil) throws -> SessionContainer { let database = try Database( url: URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("give-test-\(UUID().uuidString).sqlite") @@ -45,6 +50,10 @@ extension SessionContainer { } } + if let limits { + try database.insertLimits(limits) + } + let ratesController = RatesController(container: .mock, database: database) let session = Session( container: .mock, From 525db8a10a98018a008afdafd3b8e73630dd4c25 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 13:48:47 -0400 Subject: [PATCH 04/33] chore: address review nits on BuyAmountViewModel --- .../Screens/Main/Buy/BuyAmountViewModel.swift | 31 ++++++++++--------- .../Buy/BuyAmountViewModelTests.swift | 1 - 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 427e8293..631919c1 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -19,8 +19,8 @@ final class BuyAmountViewModel: Identifiable { var dialogItem: DialogItem? var pendingMethodSelection: PurchaseMethodContext? - let mint: PublicKey - let currencyName: String + @ObservationIgnored let mint: PublicKey + @ObservationIgnored let currencyName: String var enteredFiat: ExchangedFiat? { computeAmount(using: ratesController.rateForBalanceCurrency()) @@ -37,6 +37,10 @@ final class BuyAmountViewModel: Identifiable { /// Single-transaction cap exposed by the server via `Limits.sendLimitFor`. /// Matches what `EnterAmountView`'s subtitle renders for `.buy` mode, so /// the in-view cap and the view-model gate stay aligned. + /// + /// Intentionally diverges from `CurrencyBuyViewModel.maxPossibleAmount` + /// (balance-derived): the buy-via-onramp branch must allow amounts that + /// exceed the user's current USDF balance — funding tops it up. var maxPossibleAmount: ExchangedFiat { let rate = ratesController.rateForBalanceCurrency() let maxNative = session.sendLimitFor(currency: rate.currency)?.maxPerDay @@ -91,15 +95,19 @@ final class BuyAmountViewModel: Identifiable { await performAutoBuy(amount: amount, pin: pin, router: router) } else { actionButtonState = .normal - pendingMethodSelection = PurchaseMethodContext( - mint: mint, - currencyName: currencyName, - amount: amount, - verifiedState: pin - ) + routeToPicker(amount: amount, pin: pin) } } + private func routeToPicker(amount: ExchangedFiat, pin: VerifiedState) { + pendingMethodSelection = PurchaseMethodContext( + mint: mint, + currencyName: currencyName, + amount: amount, + verifiedState: pin + ) + } + private func usdfBalanceCovers(_ amount: ExchangedFiat) -> Bool { guard let balance = session.balance(for: .usdf) else { return false } return balance.usdf.value >= amount.usdfValue.value @@ -118,12 +126,7 @@ final class BuyAmountViewModel: Identifiable { } catch Session.Error.insufficientBalance { // Race: balance gate said OK but session.buy disagreed. Route to picker. actionButtonState = .normal - pendingMethodSelection = PurchaseMethodContext( - mint: mint, - currencyName: currencyName, - amount: amount, - verifiedState: pin - ) + routeToPicker(amount: amount, pin: pin) } catch Session.Error.verifiedStateStale { actionButtonState = .normal } catch { diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift index b118ad61..d1c40bd5 100644 --- a/FlipcashTests/Buy/BuyAmountViewModelTests.swift +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -7,7 +7,6 @@ import Foundation import Testing -import FlipcashCore @testable import FlipcashCore @testable import Flipcash From 3ba932b90db4e0c17f58776e5617a4f14491474a Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 13:53:43 -0400 Subject: [PATCH 05/33] feat: scaffold buy flow screens and register .buy sheet --- .../Screens/Main/Buy/BuyAmountScreen.swift | 53 +++++++++++++++++++ .../Main/Buy/BuyFlowDestinationView.swift | 41 ++++++++++++++ .../Main/Buy/PhantomConfirmScreen.swift | 18 +++++++ .../Main/Buy/PhantomEducationScreen.swift | 18 +++++++ .../Main/Buy/PurchaseMethodSheet.swift | 18 +++++++ .../Main/Buy/USDCDepositAddressScreen.swift | 18 +++++++ .../Main/Buy/USDCDepositEducationScreen.swift | 18 +++++++ Flipcash/Core/Screens/Main/ScanScreen.swift | 31 +++++++++-- 8 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift new file mode 100644 index 00000000..0a6b6e81 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -0,0 +1,53 @@ +// +// BuyAmountScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +struct BuyAmountScreen: View { + + @State private var viewModel: BuyAmountViewModel + + @Environment(AppRouter.self) private var router + + init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { + self._viewModel = State(initialValue: BuyAmountViewModel( + mint: mint, + currencyName: currencyName, + session: session, + ratesController: ratesController + )) + } + + var body: some View { + @Bindable var viewModel = viewModel + Background(color: .backgroundMain) { + EnterAmountView( + mode: .buy, + enteredAmount: $viewModel.enteredAmount, + subtitle: .singleTransactionLimit, + actionState: $viewModel.actionButtonState, + actionEnabled: { _ in viewModel.canPerformAction }, + action: { + Task { await viewModel.amountEnteredAction(router: router) } + } + ) + .foregroundStyle(.textMain) + .padding(20) + } + .navigationTitle(viewModel.screenTitle) + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $viewModel.dialogItem) + .sheet(item: $viewModel.pendingMethodSelection) { context in + PurchaseMethodSheet( + context: context, + onDismiss: { viewModel.pendingMethodSelection = nil } + ) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift new file mode 100644 index 00000000..4a1801d2 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift @@ -0,0 +1,41 @@ +// +// BuyFlowDestinationView.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +/// Sub-flow dispatcher for the `.buy` stack. Registered via +/// `.navigationDestination(for: BuyFlowPath.self)` on the stack root. +struct BuyFlowDestinationView: View { + + let path: BuyFlowPath + let container: Container + let sessionContainer: SessionContainer + + var body: some View { + switch path { + case .phantomEducation(let mint, let amount): + PhantomEducationScreen(mint: mint, amount: amount) + case .phantomConfirm(let mint, let amount): + PhantomConfirmScreen(mint: mint, amount: amount) + case .usdcDepositEducation(let mint, let amount): + USDCDepositEducationScreen(mint: mint, amount: amount) + case .usdcDepositAddress(let mint, let amount): + USDCDepositAddressScreen(mint: mint, amount: amount) + case .processing(let swapId, let currencyName, let amount, let swapType): + // swapType varies per funding path: .buyWithReserves for auto-buy, + // .buyWithPhantom for Phantom, .buyWithCoinbase for Apple Pay. + // Carried via BuyFlowPath so the pushing site picks the correct value. + SwapProcessingScreen( + swapId: swapId, + swapType: swapType, + currencyName: currencyName, + amount: amount + ) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift new file mode 100644 index 00000000..447bf494 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -0,0 +1,18 @@ +// +// PhantomConfirmScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +struct PhantomConfirmScreen: View { + let mint: PublicKey + let amount: ExchangedFiat + + var body: some View { + Text("PhantomConfirmScreen — Task 8 stub") + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift new file mode 100644 index 00000000..aa644684 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift @@ -0,0 +1,18 @@ +// +// PhantomEducationScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +struct PhantomEducationScreen: View { + let mint: PublicKey + let amount: ExchangedFiat + + var body: some View { + Text("PhantomEducationScreen — Task 7 stub") + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift new file mode 100644 index 00000000..fbf9bd7a --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -0,0 +1,18 @@ +// +// PurchaseMethodSheet.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +struct PurchaseMethodSheet: View { + let context: PurchaseMethodContext + let onDismiss: () -> Void + + var body: some View { + Text("PurchaseMethodSheet — Task 6 stub") + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift new file mode 100644 index 00000000..ba1d9034 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift @@ -0,0 +1,18 @@ +// +// USDCDepositAddressScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +struct USDCDepositAddressScreen: View { + let mint: PublicKey + let amount: ExchangedFiat + + var body: some View { + Text("USDCDepositAddressScreen — Task 9 stub") + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift new file mode 100644 index 00000000..a1dc0d9f --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift @@ -0,0 +1,18 @@ +// +// USDCDepositEducationScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +struct USDCDepositEducationScreen: View { + let mint: PublicKey + let amount: ExchangedFiat + + var body: some View { + Text("USDCDepositEducationScreen — Task 9 stub") + } +} diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index e314318d..a349e324 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -381,11 +381,32 @@ private struct RoutedSheet: View { } } } - case .buy: - // Placeholder — replaced with `BuyAmountScreen` in the next task. - // The case exists now so the enum's exhaustive switch compiles - // alongside the new `.buy(PublicKey)` sheet presentation. - EmptyView() + case .buy(let mint): + NavigationStack(path: $router[.buy]) { + // Resolve currency name from session.balance(for: mint) when + // available; fall back to a placeholder if the mint isn't in + // the user's balance set (e.g., a deeplink to a currency they + // don't yet hold). + BuyAmountScreen( + mint: mint, + currencyName: sessionContainer.session.balance(for: mint)?.name ?? "this currency", + session: sessionContainer.session, + ratesController: sessionContainer.ratesController + ) + .appRouterDestinations(container: container, sessionContainer: sessionContainer) + .navigationDestination(for: BuyFlowPath.self) { path in + BuyFlowDestinationView( + path: path, + container: container, + sessionContainer: sessionContainer + ) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(action: router.dismissSheet) + } + } + } } } } From 92ce901f8b0e699e890fcd2ead3a2694c5323d41 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 13:59:52 -0400 Subject: [PATCH 06/33] chore: address review nits on buy flow scaffold --- .../Screens/Main/Buy/BuyFlowDestinationView.swift | 5 ----- Flipcash/Core/Screens/Main/ScanScreen.swift | 15 ++++++--------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift index 4a1801d2..dcf5739f 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift @@ -13,8 +13,6 @@ import FlipcashCore struct BuyFlowDestinationView: View { let path: BuyFlowPath - let container: Container - let sessionContainer: SessionContainer var body: some View { switch path { @@ -27,9 +25,6 @@ struct BuyFlowDestinationView: View { case .usdcDepositAddress(let mint, let amount): USDCDepositAddressScreen(mint: mint, amount: amount) case .processing(let swapId, let currencyName, let amount, let swapType): - // swapType varies per funding path: .buyWithReserves for auto-buy, - // .buyWithPhantom for Phantom, .buyWithCoinbase for Apple Pay. - // Carried via BuyFlowPath so the pushing site picks the correct value. SwapProcessingScreen( swapId: swapId, swapType: swapType, diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index a349e324..fcdacfd0 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -383,10 +383,11 @@ private struct RoutedSheet: View { } case .buy(let mint): NavigationStack(path: $router[.buy]) { - // Resolve currency name from session.balance(for: mint) when - // available; fall back to a placeholder if the mint isn't in - // the user's balance set (e.g., a deeplink to a currency they - // don't yet hold). + // TODO: surface the currency name via the deeplink payload so + // "Purchasing X" copy stays accurate for users who don't yet + // hold the mint. Today the fallback "this currency" only fires + // for that edge case; in-app entries always have the balance + // row populated. BuyAmountScreen( mint: mint, currencyName: sessionContainer.session.balance(for: mint)?.name ?? "this currency", @@ -395,11 +396,7 @@ private struct RoutedSheet: View { ) .appRouterDestinations(container: container, sessionContainer: sessionContainer) .navigationDestination(for: BuyFlowPath.self) { path in - BuyFlowDestinationView( - path: path, - container: container, - sessionContainer: sessionContainer - ) + BuyFlowDestinationView(path: path) } .toolbar { ToolbarItem(placement: .topBarTrailing) { From 736a3079309e191155cf01596bd51ef9faaa2cc1 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:06:45 -0400 Subject: [PATCH 07/33] feat: route currency-info buy button to new .buy sheet --- .../Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 9280d613..8547e508 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -108,7 +108,7 @@ struct CurrencyInfoScreen: View { marketCapController: marketCapController, onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, onShowCurrencySelection: { isShowingCurrencySelection = true }, - onBuy: { isShowingFundingSelection = true }, + onBuy: { router.present(.buy(mint)) }, onGive: { Analytics.buttonTapped(name: .give) router.push(.give(mint)) From 3592f91451c13cd655999a9f4460b5d102d4a0b3 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:14:34 -0400 Subject: [PATCH 08/33] feat: implement PurchaseMethodSheet with Apple Pay / Phantom / Other Wallet --- .../Main/Buy/PurchaseMethodSheet.swift | 129 +++++++++++++++++- .../Buy/PurchaseMethodSheetTests.swift | 81 +++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 FlipcashTests/Buy/PurchaseMethodSheetTests.swift diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index fbf9bd7a..cbe94134 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -7,12 +7,139 @@ import SwiftUI import FlipcashCore +import FlipcashUI +/// Half-sheet picker shown when a buy intent cannot be filled from the USDF +/// reserve alone. Lists the funding methods available to the current user +/// (Apple Pay via Coinbase, Phantom, generic Other Wallet) and routes each +/// selection into the corresponding sub-flow on the buy stack. struct PurchaseMethodSheet: View { + + let context: PurchaseMethodContext + let onDismiss: () -> Void + + @Environment(AppRouter.self) private var router + @Environment(Session.self) private var session + + enum Method: Hashable { + case applePay + case phantom + case otherWallet + } + + /// Source of truth for which rows render. Pure function so visibility can + /// be unit-tested without instantiating SwiftUI views. + static func methods(forSession session: Session) -> [Method] { + var result: [Method] = [] + if session.hasCoinbaseOnramp { + result.append(.applePay) + } + result.append(.phantom) + result.append(.otherWallet) + return result + } + + var body: some View { + PartialSheet { + VStack(spacing: 12) { + HStack { + Text("Select Purchase Method") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + Spacer() + } + .padding(.vertical, 20) + + if session.hasCoinbaseOnramp { + ApplePayMethodButton( + context: context, + onDismiss: onDismiss + ) + } + + PhantomMethodButton( + context: context, + onDismiss: onDismiss, + router: router + ) + + OtherWalletMethodButton( + context: context, + onDismiss: onDismiss, + router: router + ) + + Button("Dismiss", action: onDismiss) + .buttonStyle(.subtle) + } + .padding() + } + } +} + +// Each row is its own struct so the body stays flat and avoids `@ViewBuilder` +// private functions (per CLAUDE.md's "no view functions" rule). + +private struct ApplePayMethodButton: View { + let context: PurchaseMethodContext + let onDismiss: () -> Void + + var body: some View { + Button { + onDismiss() + // TODO Task 10: wire up coordinator.startBuy(...) for the Apple + // Pay verification + Coinbase order flow. For Task 6 the tap + // just closes the picker so the rest of the visual flow can be + // smoke-tested without a runtime crash. + } label: { + HStack(spacing: 4) { + Text("Debit Card with") + Text("\u{F8FF} Pay") + .font(.body.bold()) + } + } + .buttonStyle(.filled) + } +} + +private struct PhantomMethodButton: View { + let context: PurchaseMethodContext + let onDismiss: () -> Void + let router: AppRouter + + var body: some View { + Button { + onDismiss() + Task { @MainActor in + try? await Task.sleep(for: AppRouter.dismissAnimationDuration) + router.pushAny(BuyFlowPath.phantomEducation(mint: context.mint, amount: context.amount)) + } + } label: { + HStack(spacing: 4) { + Image.asset(.phantom) + .renderingMode(.template) + .resizable() + .frame(width: 20, height: 20) + Text("Phantom") + } + } + .buttonStyle(.filled) + } +} + +private struct OtherWalletMethodButton: View { let context: PurchaseMethodContext let onDismiss: () -> Void + let router: AppRouter var body: some View { - Text("PurchaseMethodSheet — Task 6 stub") + Button("Other Wallet") { + onDismiss() + Task { @MainActor in + try? await Task.sleep(for: AppRouter.dismissAnimationDuration) + router.pushAny(BuyFlowPath.usdcDepositEducation(mint: context.mint, amount: context.amount)) + } + } + .buttonStyle(.filled) } } diff --git a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift new file mode 100644 index 00000000..86e83548 --- /dev/null +++ b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift @@ -0,0 +1,81 @@ +// +// PurchaseMethodSheetTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-05-12. +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@Suite("PurchaseMethodSheet — visibility") +@MainActor +struct PurchaseMethodSheetTests { + + /// Disables the global Coinbase beta-flag toggle so tests deterministically + /// drive `hasCoinbaseOnramp` through `userFlags?.hasCoinbase` only. Without + /// this, a developer who happened to enable the beta flag on their machine + /// would see different behavior than CI. + private static func clearBetaFlag() { + BetaFlags.shared.set(.enableCoinbase, enabled: false) + } + + @Test("Apple Pay row hidden when hasCoinbaseOnramp is false") + func applePayHiddenWhenNoCoinbase() throws { + Self.clearBetaFlag() + let container = try SessionContainer.makeTest(holdings: []) + // Default test session has no userFlags set, so hasCoinbase is false. + + let methods = PurchaseMethodSheet.methods(forSession: container.session) + + #expect(!methods.contains(.applePay)) + #expect(methods.contains(.phantom)) + #expect(methods.contains(.otherWallet)) + } + + @Test("Apple Pay row visible when hasCoinbaseOnramp is true") + func applePayVisibleWhenCoinbaseAvailable() throws { + Self.clearBetaFlag() + let container = try SessionContainer.makeTest(holdings: []) + container.session.userFlags = UserFlags( + isRegistered: true, + isStaff: false, + onrampProviders: [.coinbaseVirtual], + preferredOnrampProvider: .coinbaseVirtual, + minBuildNumber: 0, + billExchangeDataTimeout: nil, + newCurrencyPurchaseAmount: .zero(mint: .usdf), + newCurrencyFeeAmount: .zero(mint: .usdf), + withdrawalFeeAmount: TokenAmount(quarks: 0, mint: .usdf) + ) + + let methods = PurchaseMethodSheet.methods(forSession: container.session) + + #expect(methods.contains(.applePay)) + #expect(methods.contains(.phantom)) + #expect(methods.contains(.otherWallet)) + } + + @Test("Apple Pay appears first when available") + func applePayOrderedFirst() throws { + Self.clearBetaFlag() + let container = try SessionContainer.makeTest(holdings: []) + container.session.userFlags = UserFlags( + isRegistered: true, + isStaff: false, + onrampProviders: [.coinbaseVirtual], + preferredOnrampProvider: .coinbaseVirtual, + minBuildNumber: 0, + billExchangeDataTimeout: nil, + newCurrencyPurchaseAmount: .zero(mint: .usdf), + newCurrencyFeeAmount: .zero(mint: .usdf), + withdrawalFeeAmount: TokenAmount(quarks: 0, mint: .usdf) + ) + + let methods = PurchaseMethodSheet.methods(forSession: container.session) + + #expect(methods.first == .applePay) + } +} From 773125928bc3677467ce0f648aeea6be54701299 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:21:39 -0400 Subject: [PATCH 09/33] chore: address review nits on PurchaseMethodSheet --- .../Main/Buy/PurchaseMethodSheet.swift | 5 +- .../Buy/PurchaseMethodSheetTests.swift | 77 ++++++++----------- 2 files changed, 35 insertions(+), 47 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index cbe94134..c4aaf61f 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -77,9 +77,6 @@ struct PurchaseMethodSheet: View { } } -// Each row is its own struct so the body stays flat and avoids `@ViewBuilder` -// private functions (per CLAUDE.md's "no view functions" rule). - private struct ApplePayMethodButton: View { let context: PurchaseMethodContext let onDismiss: () -> Void @@ -94,7 +91,7 @@ private struct ApplePayMethodButton: View { } label: { HStack(spacing: 4) { Text("Debit Card with") - Text("\u{F8FF} Pay") + Text("\u{F8FF}Pay") .font(.body.bold()) } } diff --git a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift index 86e83548..f0a23048 100644 --- a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift +++ b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift @@ -14,30 +14,20 @@ import Testing @MainActor struct PurchaseMethodSheetTests { - /// Disables the global Coinbase beta-flag toggle so tests deterministically - /// drive `hasCoinbaseOnramp` through `userFlags?.hasCoinbase` only. Without - /// this, a developer who happened to enable the beta flag on their machine - /// would see different behavior than CI. - private static func clearBetaFlag() { - BetaFlags.shared.set(.enableCoinbase, enabled: false) + /// Runs `body` with `BetaFlags.enableCoinbase` forced to the requested + /// state, restoring the original value (which is persisted to UserDefaults + /// by `BetaFlags.set`) when `body` returns. Without restoration the suite + /// leaks the disabled flag to whichever suite runs next. + private static func withCoinbaseBetaFlag(enabled: Bool, _ body: () throws -> R) rethrows -> R { + let original = BetaFlags.shared.hasEnabled(.enableCoinbase) + BetaFlags.shared.set(.enableCoinbase, enabled: enabled) + defer { BetaFlags.shared.set(.enableCoinbase, enabled: original) } + return try body() } - @Test("Apple Pay row hidden when hasCoinbaseOnramp is false") - func applePayHiddenWhenNoCoinbase() throws { - Self.clearBetaFlag() - let container = try SessionContainer.makeTest(holdings: []) - // Default test session has no userFlags set, so hasCoinbase is false. - - let methods = PurchaseMethodSheet.methods(forSession: container.session) - - #expect(!methods.contains(.applePay)) - #expect(methods.contains(.phantom)) - #expect(methods.contains(.otherWallet)) - } - - @Test("Apple Pay row visible when hasCoinbaseOnramp is true") - func applePayVisibleWhenCoinbaseAvailable() throws { - Self.clearBetaFlag() + /// Builds a `SessionContainer` whose `userFlags.hasCoinbase` is `true`. + /// Tests that need Apple Pay visible reuse this fixture. + private static func makeContainerWithCoinbase() throws -> SessionContainer { let container = try SessionContainer.makeTest(holdings: []) container.session.userFlags = UserFlags( isRegistered: true, @@ -50,32 +40,33 @@ struct PurchaseMethodSheetTests { newCurrencyFeeAmount: .zero(mint: .usdf), withdrawalFeeAmount: TokenAmount(quarks: 0, mint: .usdf) ) + return container + } + + @Test("Apple Pay row hidden when hasCoinbaseOnramp is false") + func applePayHiddenWhenNoCoinbase() throws { + try Self.withCoinbaseBetaFlag(enabled: false) { + let container = try SessionContainer.makeTest(holdings: []) + // Default test session has no userFlags set, so hasCoinbase is false. - let methods = PurchaseMethodSheet.methods(forSession: container.session) + let methods = PurchaseMethodSheet.methods(forSession: container.session) - #expect(methods.contains(.applePay)) - #expect(methods.contains(.phantom)) - #expect(methods.contains(.otherWallet)) + #expect(!methods.contains(.applePay)) + #expect(methods.contains(.phantom)) + #expect(methods.contains(.otherWallet)) + } } - @Test("Apple Pay appears first when available") - func applePayOrderedFirst() throws { - Self.clearBetaFlag() - let container = try SessionContainer.makeTest(holdings: []) - container.session.userFlags = UserFlags( - isRegistered: true, - isStaff: false, - onrampProviders: [.coinbaseVirtual], - preferredOnrampProvider: .coinbaseVirtual, - minBuildNumber: 0, - billExchangeDataTimeout: nil, - newCurrencyPurchaseAmount: .zero(mint: .usdf), - newCurrencyFeeAmount: .zero(mint: .usdf), - withdrawalFeeAmount: TokenAmount(quarks: 0, mint: .usdf) - ) + @Test("Apple Pay row visible and ordered first when hasCoinbaseOnramp is true") + func applePayVisibleAndFirst() throws { + try Self.withCoinbaseBetaFlag(enabled: false) { + let container = try Self.makeContainerWithCoinbase() - let methods = PurchaseMethodSheet.methods(forSession: container.session) + let methods = PurchaseMethodSheet.methods(forSession: container.session) - #expect(methods.first == .applePay) + #expect(methods.first == .applePay) + #expect(methods.contains(.phantom)) + #expect(methods.contains(.otherWallet)) + } } } From 190fd0effc15db6dc411673095307e8b7e2b2450 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:26:44 -0400 Subject: [PATCH 10/33] feat: implement PhantomEducationScreen with state-driven push to confirm --- .../Main/Buy/Components/PhantomUSDCHero.swift | 37 ++++++++++++++++ .../Main/Buy/PhantomEducationScreen.swift | 43 ++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift diff --git a/Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift b/Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift new file mode 100644 index 00000000..551e72ce --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift @@ -0,0 +1,37 @@ +// +// PhantomUSDCHero.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashUI + +/// Dual-logo hero used by both `PhantomEducationScreen` ("disconnected" state) +/// and `PhantomConfirmScreen` ("connected" state, with checkmark badge). +struct PhantomUSDCHero: View { + + let connected: Bool + + var body: some View { + HStack(spacing: -8) { + Image.asset(.phantom) + .resizable() + .frame(width: 80, height: 80) + .clipShape(Circle()) + .overlay(alignment: .bottomTrailing) { + if connected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.green) + } + } + Image.asset(.solanaUSDC) + .resizable() + .frame(width: 80, height: 80) + .clipShape(Circle()) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift index aa644684..a2c80958 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift @@ -7,12 +7,53 @@ import SwiftUI import FlipcashCore +import FlipcashUI +/// "Buy With Phantom" pre-flight: explains the swap, then triggers +/// `WalletConnection.connectToPhantom()`. When Phantom returns and the +/// underlying `session` becomes non-nil, the `.onChange` observer pushes +/// `BuyFlowPath.phantomConfirm` automatically. `initial: true` also handles +/// the case where the user lands on this screen already connected (a prior +/// Phantom session persists in the keychain across launches). struct PhantomEducationScreen: View { + let mint: PublicKey let amount: ExchangedFiat + @Environment(AppRouter.self) private var router + @Environment(WalletConnection.self) private var walletConnection + var body: some View { - Text("PhantomEducationScreen — Task 7 stub") + Background(color: .backgroundMain) { + ScrollView { + VStack(spacing: 24) { + Spacer(minLength: 60) + PhantomUSDCHero(connected: false) + Text("Buy With Phantom") + .font(.appTitle) + .foregroundStyle(Color.textMain) + Text("Purchase using Solana USDC in Phantom. Simply connect your wallet and confirm the transaction.") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Spacer() + } + } + .safeAreaInset(edge: .bottom) { + BubbleButton(text: "Connect Your Phantom Wallet") { + walletConnection.connectToPhantom() + } + .padding() + } + } + .navigationTitle("Purchase") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: walletConnection.session != nil, initial: true) { _, isConnected in + // Auto-advance when Phantom returns from the connect deeplink, or + // immediately on appear if a prior session already exists. + guard isConnected else { return } + router.pushAny(BuyFlowPath.phantomConfirm(mint: mint, amount: amount)) + } } } From 5807f22f5d2ef1e6309e5e6cecaba782fbf099e8 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:31:24 -0400 Subject: [PATCH 11/33] feat: implement PhantomConfirmScreen and chain to .processing Replace the Task 8 stub with the real "Connected" screen. The CTA triggers WalletConnection.requestSwap(usdc:token:), and an onChange observer pushes BuyFlowPath.processing onto the buy stack when state transitions to .buying(_, isFailed: false). SwapProcessingScreen owns the remaining success/cancel lifecycle via its existing isProcessingCancelled observer. --- .../Main/Buy/PhantomConfirmScreen.swift | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index 447bf494..ad52c32f 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -7,12 +7,99 @@ import SwiftUI import FlipcashCore +import FlipcashUI +private let logger = Logger(label: "flipcash.phantom-confirm") + +/// Post-Phantom-auth confirmation screen. The user taps "Confirm In Phantom" to +/// trigger `WalletConnection.requestSwap(...)`, which deep-links into Phantom +/// for transaction signing. When `walletConnection.state` transitions to +/// `.buying(ExternalSwapProcessing, isFailed: false)`, the `.onChange` +/// observer pushes `BuyFlowPath.processing` onto the buy stack with the swap +/// id and Phantom swap type. From there `SwapProcessingScreen` owns the +/// remaining lifecycle (success / cancel display) via its own +/// `walletConnection.isProcessingCancelled` observer. struct PhantomConfirmScreen: View { + let mint: PublicKey let amount: ExchangedFiat + @Environment(AppRouter.self) private var router + @Environment(WalletConnection.self) private var walletConnection + @Environment(Session.self) private var session + + @State private var dialogItem: DialogItem? + var body: some View { - Text("PhantomConfirmScreen — Task 8 stub") + Background(color: .backgroundMain) { + ScrollView { + VStack(spacing: 24) { + Spacer(minLength: 60) + PhantomUSDCHero(connected: true) + Text("Connected") + .font(.appTitle) + .foregroundStyle(Color.textMain) + Text("Confirm the transaction in Phantom to continue") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Spacer() + } + } + .safeAreaInset(edge: .bottom) { + BubbleButton(text: "Confirm In Phantom") { + confirmInPhantom() + } + .padding() + } + } + .navigationTitle("Confirmation") + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $dialogItem) + .onChange(of: walletConnection.state) { _, newState in + // Push the processing screen the moment the swap context appears. + // `state` flips to `.buying(..., isFailed: false)` immediately + // after Phantom returns a signed transaction (see + // `WalletConnection.completeSwap`). A later chain-submission + // failure flips `isFailed` to true; `SwapProcessingScreen` + // observes that via `walletConnection.isProcessingCancelled`. + guard case .buying(let processing, false) = newState else { return } + router.pushAny(BuyFlowPath.processing( + swapId: processing.swapId, + currencyName: processing.currencyName, + amount: amount, + swapType: .buyWithPhantom + )) + } + } + + private func confirmInPhantom() { + Task { + do { + let metadata = try await session.fetchMintMetadata(mint: mint) + try await walletConnection.requestSwap( + usdc: amount.onChainAmount, + token: metadata.metadata + ) + } catch { + logger.error("Failed to request Phantom swap", metadata: [ + "mint": "\(mint.base58)", + "amount": "\(amount.nativeAmount.formatted())", + "error": "\(error)", + ]) + ErrorReporting.captureError( + error, + reason: "Failed to request Phantom swap from PhantomConfirmScreen", + metadata: ["mint": mint.base58] + ) + dialogItem = .init( + style: .destructive, + title: "Something Went Wrong", + subtitle: "We couldn't open Phantom. Please try again.", + dismissable: true + ) { .okay(kind: .destructive) } + } + } } } From 068f97185ad717e0deda39032db8bf2cf7738b48 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:36:36 -0400 Subject: [PATCH 12/33] feat: implement USDC deposit education and address screens --- .../Main/Buy/USDCDepositAddressScreen.swift | 60 ++++++++++++++++- .../Main/Buy/USDCDepositEducationScreen.swift | 66 ++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift index ba1d9034..8dc2305b 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift @@ -7,12 +7,70 @@ import SwiftUI import FlipcashCore +import FlipcashUI +/// Displays the per-user USDC deposit address — the USDF swap PDA's USDC ATA. +/// Anything received here is auto-converted 1:1 to USDF on receipt by the +/// server-side watcher. Mirrors the established settings `DepositScreen` +/// pattern (`ImmutableField` + `CodeButton` with `.successText("Copied")`). struct USDCDepositAddressScreen: View { + let mint: PublicKey let amount: ExchangedFiat + @Environment(Session.self) private var session + @State private var buttonState: ButtonState = .normal + + private var depositAddress: String? { + // The per-user staging address on USDF: USDC sent here is converted + // 1:1 to USDF for the user. `ata` is the SPL-token receive address + // derived from the swap PDA (an SPL transfer must target a token + // account, not the PDA itself). + MintMetadata.usdf + .timelockSwapAccounts(owner: session.owner.authorityPublicKey)? + .ata.publicKey.base58 + } + var body: some View { - Text("USDCDepositAddressScreen — Task 9 stub") + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 20) { + Text("Deposit funds into your wallet by sending USDC to your deposit address below. Tap to copy.") + .font(.appTextMedium) + .foregroundStyle(.textSecondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + if let depositAddress { + Button { + copy(depositAddress) + } label: { + ImmutableField(depositAddress) + } + } else { + Text("Deposit address unavailable") + .font(.appTextMedium) + .foregroundStyle(.textSecondary) + } + + Spacer() + + if let depositAddress { + CodeButton( + state: buttonState, + style: .filled, + title: "Copy Address", + action: { copy(depositAddress) } + ) + } + } + .padding(20) + } + .navigationTitle("Deposit USDC") + .navigationBarTitleDisplayMode(.inline) + } + + private func copy(_ value: String) { + UIPasteboard.general.string = value + buttonState = .successText("Copied") } } diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift index a1dc0d9f..ecd8ead1 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift @@ -7,12 +7,76 @@ import SwiftUI import FlipcashCore +import FlipcashUI +/// Pre-flight for the "Other Wallet" path: explains that incoming Solana USDC +/// is auto-converted 1:1 to USDF on receipt. Tapping Next pushes +/// `BuyFlowPath.usdcDepositAddress` so the user can copy the destination +/// address. struct USDCDepositEducationScreen: View { + let mint: PublicKey let amount: ExchangedFiat + @Environment(AppRouter.self) private var router + var body: some View { - Text("USDCDepositEducationScreen — Task 9 stub") + Background(color: .backgroundMain) { + VStack(spacing: 24) { + Spacer() + + ConversionGraphic() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Convert USDC to USDF") + + VStack(spacing: 8) { + Text("Deposit USDC") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + Text("Your Solana USDC will be converted 1:1 to USD on Flipcash (USDF)") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 40) + + Spacer() + + Button("Next") { + router.pushAny(BuyFlowPath.usdcDepositAddress(mint: mint, amount: amount)) + } + .buttonStyle(.filled) + } + .padding(20) + } + .navigationTitle("Deposit") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct ConversionGraphic: View { + var body: some View { + HStack(spacing: 16) { + // Mirrors `WithdrawIntroScreen.ConversionGraphic`: the solanaUSDC + // asset's viewBox is 143x145 because the Solana hex badge sits + // down-right of the main USDC circle (128.3 wide). Frame ratio + // 111x113 scales the main circle to ~100x100, matching the + // Flipcash icon. The offset re-centers the main circle. + Image.asset(.solanaUSDC) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 111, height: 113) + .offset(x: 6, y: 7) + + Image.system(.arrowRight) + .foregroundStyle(Color.textSecondary) + + Image.asset(.flipcash) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + } } } From b0c02213518df0d7367ebb81e97a9d4322c766e6 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:41:32 -0400 Subject: [PATCH 13/33] feat: wire Apple Pay button through OnrampCoordinator on buy flow --- .../Core/Screens/Main/Buy/BuyAmountScreen.swift | 14 ++++++++++++++ .../Screens/Main/Buy/PurchaseMethodSheet.swift | 13 +++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index 0a6b6e81..de1a4d8f 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -14,6 +14,7 @@ struct BuyAmountScreen: View { @State private var viewModel: BuyAmountViewModel @Environment(AppRouter.self) private var router + @Environment(OnrampCoordinator.self) private var coordinator init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { self._viewModel = State(initialValue: BuyAmountViewModel( @@ -49,5 +50,18 @@ struct BuyAmountScreen: View { onDismiss: { viewModel.pendingMethodSelection = nil } ) } + .sheet(isPresented: Bindable(coordinator).isShowingVerificationFlow) { + VerifyInfoScreen(onrampCoordinator: coordinator) + } + .onChange(of: coordinator.completion) { _, newValue in + guard case .buyProcessing(let swapId, let currencyName, let amount) = newValue else { return } + router.pushAny(BuyFlowPath.processing( + swapId: swapId, + currencyName: currencyName, + amount: amount, + swapType: .buyWithCoinbase + )) + coordinator.completion = nil + } } } diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index c4aaf61f..6a1ef056 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -81,13 +81,18 @@ private struct ApplePayMethodButton: View { let context: PurchaseMethodContext let onDismiss: () -> Void + @Environment(OnrampCoordinator.self) private var coordinator + var body: some View { Button { onDismiss() - // TODO Task 10: wire up coordinator.startBuy(...) for the Apple - // Pay verification + Coinbase order flow. For Task 6 the tap - // just closes the picker so the rest of the visual flow can be - // smoke-tested without a runtime crash. + Task { @MainActor in + try? await Task.sleep(for: AppRouter.dismissAnimationDuration) + coordinator.start( + .buy(mint: context.mint, displayName: context.currencyName), + amount: context.amount + ) + } } label: { HStack(spacing: 4) { Text("Debit Card with") From cd9ef71f7e1cd3bc0a2c5f4108ec8f222cb1c4ea Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:43:01 -0400 Subject: [PATCH 14/33] feat: deeplink .currencyInfoForDeposit opens new buy sheet --- .../Navigation/AppRouter+DestinationView.swift | 2 +- .../Main/Currency Info/CurrencyInfoScreen.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index ebfa1e0d..75fe8f31 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -41,7 +41,7 @@ struct DestinationView: View { mint: mint, container: container, sessionContainer: sessionContainer, - showFundingOnAppear: true + showBuyOnAppear: true ) .id(mint) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 8547e508..444b91a5 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -49,7 +49,7 @@ struct CurrencyInfoScreen: View { private let ratesController: RatesController private let sessionContainer: SessionContainer private let marketCapController: MarketCapController - private let showFundingOnAppear: Bool + private let showBuyOnAppear: Bool // MARK: - Init - @@ -58,14 +58,14 @@ struct CurrencyInfoScreen: View { viewModel: CurrencyInfoViewModel, container: Container, sessionContainer: SessionContainer, - showFundingOnAppear: Bool + showBuyOnAppear: Bool ) { self.mint = mint self.container = container self.ratesController = sessionContainer.ratesController self.session = sessionContainer.session self.sessionContainer = sessionContainer - self.showFundingOnAppear = showFundingOnAppear + self.showBuyOnAppear = showBuyOnAppear self.viewModel = viewModel self.marketCapController = MarketCapController( @@ -77,7 +77,7 @@ struct CurrencyInfoScreen: View { /// Creates the screen by mint address. Metadata is loaded from the database /// (fast path) or fetched from the network, showing a loading state until ready. - init(mint: PublicKey, container: Container, sessionContainer: SessionContainer, showFundingOnAppear: Bool = false) { + init(mint: PublicKey, container: Container, sessionContainer: SessionContainer, showBuyOnAppear: Bool = false) { self.init( mint: mint, viewModel: CurrencyInfoViewModel( @@ -88,7 +88,7 @@ struct CurrencyInfoScreen: View { ), container: container, sessionContainer: sessionContainer, - showFundingOnAppear: showFundingOnAppear + showBuyOnAppear: showBuyOnAppear ) } @@ -149,8 +149,8 @@ struct CurrencyInfoScreen: View { ratesController.ensureMintSubscribed(mint) await viewModel.loadMintMetadata() - if showFundingOnAppear { - isShowingFundingSelection = true + if showBuyOnAppear { + router.present(.buy(mint)) } } .fullScreenCover(item: Bindable(walletConnection).processing) { processing in From 48abb6ebfef4eefa3f385b45f4f7b6245e7452b8 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 14:54:50 -0400 Subject: [PATCH 15/33] refactor: delete legacy buy/onramp screens and viewmodels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amount-first buy flow replaces them — drop CurrencyBuyAmountScreen, CurrencyBuyViewModel, OnrampAmountScreen, and OnrampViewModel along with the sheet plumbing in CurrencyInfoScreen. Extract OnrampDeeplinkInbox, OnrampVerificationPath, and ApplePayOverlay into their own files alongside OnrampCoordinator, and migrate the buy regression tests to BuyAmountViewModel. FundingSelectionSheet stays; the launch wizard still uses it. --- .../Controllers/Onramp/ApplePayOverlay.swift | 29 +++ .../Onramp/OnrampCoordinator.swift | 4 + .../Onramp/OnrampDeeplinkInbox.swift | 17 ++ .../Onramp/OnrampHostModifier.swift | 7 +- .../Onramp/OnrampVerificationPath.swift | 21 ++ .../Screens/Main/Buy/BuyAmountViewModel.swift | 10 +- .../Currency Info/CurrencyInfoScreen.swift | 69 ------ .../CurrencyBuyAmountScreen.swift | 70 ------ .../Currency Swap/CurrencyBuyViewModel.swift | 217 ------------------ .../Screens/Onramp/OnrampAmountScreen.swift | 109 --------- .../Core/Screens/Onramp/OnrampViewModel.swift | 171 -------------- FlipcashTests/CurrencyBuyViewModelTests.swift | 113 --------- .../Navigation/AppRouterBuySheetTests.swift | 4 +- .../Regression_native_amount_mismatch.swift | 57 +++-- .../CurrencyBuyRegressionTests.swift | 44 ---- .../WalletCallbackRegressionTests.swift | 63 ----- .../Support/Screens/AmountEntryScreen.swift | 2 +- .../Screens/FundingSelectionScreen.swift | 51 ---- 18 files changed, 122 insertions(+), 936 deletions(-) create mode 100644 Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift create mode 100644 Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift create mode 100644 Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift delete mode 100644 Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyAmountScreen.swift delete mode 100644 Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift delete mode 100644 Flipcash/Core/Screens/Onramp/OnrampAmountScreen.swift delete mode 100644 Flipcash/Core/Screens/Onramp/OnrampViewModel.swift delete mode 100644 FlipcashTests/CurrencyBuyViewModelTests.swift delete mode 100644 FlipcashUITests/Regression/CurrencyBuyRegressionTests.swift delete mode 100644 FlipcashUITests/Regression/WalletCallbackRegressionTests.swift delete mode 100644 FlipcashUITests/Support/Screens/FundingSelectionScreen.swift diff --git a/Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift b/Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift new file mode 100644 index 00000000..b26adc96 --- /dev/null +++ b/Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift @@ -0,0 +1,29 @@ +// +// ApplePayOverlay.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore + +/// Invisible overlay that hosts the Coinbase Apple Pay WKWebView. The view is +/// rendered at zero opacity (it exists only to drive Apple Pay's JS payment +/// flow in the background) and explicitly excluded from hit testing and +/// accessibility so the covered region of the amount keypad remains tappable +/// and VoiceOver users don't land on a silent 300×300 zone. +struct ApplePayOverlay: View { + + let order: OnrampOrderResponse? + let onEvent: (ApplePayEvent) -> Void + + var body: some View { + if let order { + ApplePayWebView(url: order.paymentLink.url, onMessage: onEvent) + .frame(width: 300, height: 300) + .opacity(0) + .allowsHitTesting(false) + .accessibilityHidden(true) + .id(order.id) + } + } +} diff --git a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift index 0ed5016f..022e26df 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift @@ -913,6 +913,10 @@ final class OnrampCoordinator { // MARK: - Supporting types - +enum OnrampError: Error { + case missingCoinbaseApiKey +} + private enum Origin: Int { case root case info diff --git a/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift b/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift new file mode 100644 index 00000000..c8827508 --- /dev/null +++ b/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift @@ -0,0 +1,17 @@ +// +// OnrampDeeplinkInbox.swift +// Flipcash +// + +import SwiftUI + +/// Long-lived store for Onramp email verification deeplinks. `DeepLinkController` +/// drops incoming verifications here; `OnrampHostModifier` observes the value +/// with `.onChange(initial: true)` and hands it off to `OnrampCoordinator`, so +/// whether the link arrived before or after the sheet opened the verification +/// is picked up through the same entry point. Lives on `SessionContainer` so +/// it survives sheet dismissal but not logout. +@Observable +final class OnrampDeeplinkInbox { + var pendingEmailVerification: VerificationDescription? +} diff --git a/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift b/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift index 6e8554db..b562675a 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift @@ -14,9 +14,10 @@ import FlipcashCore /// The verification sheet is NOT hosted here — SwiftUI only allows one modal /// sheet per presentation context, and when the user is several sheets deep /// (Wallet → Discovery → Wizard → FundingSelection) the root-level sheet slot -/// is already taken. Screens that initiate an onramp (`OnrampAmountScreen`, -/// `CurrencyCreationWizardScreen`) host the verification sheet themselves so -/// it presents on top of whatever sheet stack the user is already in. +/// is already taken. Screens that initiate an onramp (`PurchaseMethodSheet` +/// via the `.buy` router stack, `CurrencyCreationWizardScreen` for launch) +/// host the verification sheet themselves so it presents on top of whatever +/// sheet stack the user is already in. struct OnrampHostModifier: ViewModifier { @Environment(OnrampCoordinator.self) private var onrampCoordinator diff --git a/Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift b/Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift new file mode 100644 index 00000000..1305b2a2 --- /dev/null +++ b/Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift @@ -0,0 +1,21 @@ +// +// OnrampVerificationPath.swift +// Flipcash +// + +import FlipcashCore + +/// Navigation path for `VerifyInfoScreen`'s NavigationStack. +enum OnrampVerificationPath: Hashable { + case info + case enterPhoneNumber + case confirmPhoneNumberCode + case enterEmail + case confirmEmailCode +} + +extension Profile { + var canCreateCoinbaseOrder: Bool { + phone != nil && email?.isEmpty == false + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 631919c1..d4e7bbb3 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -38,9 +38,9 @@ final class BuyAmountViewModel: Identifiable { /// Matches what `EnterAmountView`'s subtitle renders for `.buy` mode, so /// the in-view cap and the view-model gate stay aligned. /// - /// Intentionally diverges from `CurrencyBuyViewModel.maxPossibleAmount` - /// (balance-derived): the buy-via-onramp branch must allow amounts that - /// exceed the user's current USDF balance — funding tops it up. + /// This is the daily send limit, not a balance-derived cap: the buy flow + /// must allow amounts that exceed the user's current USDF balance, since + /// funding (Apple Pay / Phantom / etc.) tops it up. var maxPossibleAmount: ExchangedFiat { let rate = ratesController.rateForBalanceCurrency() let maxNative = session.sendLimitFor(currency: rate.currency)?.maxPerDay @@ -140,8 +140,8 @@ final class BuyAmountViewModel: Identifiable { } } - /// Pin verified state once, compute amount against the pin. - /// Mirrors CurrencyBuyViewModel.prepareSubmission so quarks can't drift. + /// Pin verified state once, compute amount against the pin so quarks + /// stay tied to the rate the server is about to verify. private func prepareSubmission() async -> (amount: ExchangedFiat, pinnedState: VerifiedState)? { let currency = ratesController.balanceCurrency guard let pin = await ratesController.currentPinnedState(for: currency, mint: .usdf) else { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 444b91a5..cae04857 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -15,21 +15,8 @@ struct CurrencyInfoScreen: View { @Environment(\.dismiss) private var dismiss @Environment(AppRouter.self) private var router - @State private var isShowingFundingSelection: Bool = false - @State private var presentedBuyViewModel: CurrencyBuyViewModel? @State private var presentedSellViewModel: CurrencySellViewModel? @State private var isShowingCurrencySelection: 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? - @State private var pendingOnrampTarget: BuyTarget? - - /// Identifying data for the Coinbase onramp sheet trigger. - private struct BuyTarget: Identifiable, Hashable { - let mint: PublicKey - let displayName: String - var id: String { mint.base58 } - } let session: Session @@ -47,7 +34,6 @@ struct CurrencyInfoScreen: View { private let mint: PublicKey private let container: Container private let ratesController: RatesController - private let sessionContainer: SessionContainer private let marketCapController: MarketCapController private let showBuyOnAppear: Bool @@ -64,7 +50,6 @@ struct CurrencyInfoScreen: View { self.container = container self.ratesController = sessionContainer.ratesController self.session = sessionContainer.session - self.sessionContainer = sessionContainer self.showBuyOnAppear = showBuyOnAppear self.viewModel = viewModel @@ -198,66 +183,12 @@ struct CurrencyInfoScreen: View { } } } - .sheet(item: $presentedBuyViewModel) { buyViewModel in - CurrencyBuyAmountScreen(viewModel: buyViewModel) - } .sheet(item: $presentedSellViewModel) { sellViewModel in CurrencySellAmountScreen(viewModel: sellViewModel) } .sheet(isPresented: $isShowingCurrencySelection) { CurrencySelectionScreen(ratesController: ratesController) } - .sheet(isPresented: $isShowingFundingSelection, onDismiss: { - // SwiftUI allows only one modal sheet at a time, so we can't set - // `onrampDestination` in the same frame as dismissing the funding - // sheet — the second sheet gets swallowed. Defer the handoff until - // the funding sheet has fully dismissed. - guard let target = pendingOnrampTarget else { return } - pendingOnrampTarget = nil - onrampDestination = target - }) { - if let metadata = viewModel.mintMetadata { - FundingSelectionSheet( - reserveBalance: viewModel.reserveBalance, - isCoinbaseAvailable: session.hasCoinbaseOnramp, - onSelectReserves: { - Analytics.buttonTapped(name: .buyWithReserves) - presentedBuyViewModel = CurrencyBuyViewModel( - currencyPublicKey: metadata.mint, - currencyName: metadata.name, - session: session, - ratesController: ratesController - ) - isShowingFundingSelection = false - }, - onSelectCoinbase: { - Analytics.buttonTapped(name: .buyWithCoinbase) - pendingOnrampTarget = BuyTarget( - mint: metadata.mint, - displayName: metadata.name - ) - isShowingFundingSelection = false - }, - onSelectPhantom: { - Analytics.buttonTapped(name: .buyWithPhantom) - walletConnection.connectToPhantom() - isShowingFundingSelection = false - }, - onDismiss: { - isShowingFundingSelection = false - } - ) - } - } - .sheet(item: $onrampDestination) { target in - OnrampAmountScreen.forBuying( - mint: target.mint, - displayName: target.displayName, - session: sessionContainer.session, - onrampCoordinator: onrampCoordinator, - onDismiss: { onrampDestination = nil } - ) - } .dialog(item: Bindable(walletConnection).dialogItem) } diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyAmountScreen.swift deleted file mode 100644 index db469116..00000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyAmountScreen.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// CurrencyBuyAmountScreen.swift -// Code -// -// Created by Raul Riera on 2025-12-18. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct CurrencyBuyAmountScreen: View { - @Bindable private var viewModel: CurrencyBuyViewModel - @Environment(\.dismiss) var dismissAction: DismissAction - @Environment(RatesController.self) private var ratesController - - @State private var isShowingCurrencySelection: Bool = false - - // MARK: - Init - - - init(viewModel: CurrencyBuyViewModel) { - self.viewModel = viewModel - } - - // MARK: - Body - - - var body: some View { - NavigationStack(path: $viewModel.path) { - Background(color: .backgroundMain) { - EnterAmountView( - mode: .buy, - enteredAmount: $viewModel.enteredAmount, - subtitle: .balanceWithLimit(viewModel.maxPossibleAmount), - actionState: $viewModel.actionButtonState, - actionEnabled: { _ in - viewModel.canPerformAction - }, - action: viewModel.amountEnteredAction, - currencySelectionAction: showCurrencySelection - ) - .foregroundStyle(.textMain) - .padding(20) - } - .navigationTitle(viewModel.screenTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: CurrencyBuyPath.self) { step in - switch step { - case .processing(let swapId, let currencyName, let amount): - SwapProcessingScreen(swapId: swapId, swapType: .buyWithReserves, currencyName: currencyName, amount: amount) - .environment(\.dismissParentContainer, { - dismissAction() - }) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton { dismissAction() } - } - } - .dialog(item: $viewModel.dialogItem) - .sheet(isPresented: $isShowingCurrencySelection) { - CurrencySelectionScreen(ratesController: ratesController) - } - } - } - - private func showCurrencySelection() { - isShowingCurrencySelection.toggle() - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift deleted file mode 100644 index 26c33bc6..00000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// CurrencyBuyViewModel.swift -// Code -// -// Created by Raul Riera on 2025-12-18. -// - -import SwiftUI -import FlipcashCore -import FlipcashUI -import Logging - -private let logger = Logger(label: "flipcash.swap-service") - -@Observable -class CurrencyBuyViewModel: Identifiable { - var actionButtonState: ButtonState = .normal - var enteredAmount: String = "" - var dialogItem: DialogItem? - var path: [CurrencyBuyPath] = [] - - var enteredFiat: ExchangedFiat? { - computeAmount(using: ratesController.rateForBalanceCurrency()) - } - - var canPerformAction: Bool { - guard enteredFiat != nil else { - return false - } - - return EnterAmountCalculator.isWithinDisplayLimit( - enteredAmount: enteredAmount, - max: maxPossibleAmount.nativeAmount - ) - } - - var screenTitle: String { - return "Amount To Buy" - } - - var maxPossibleAmount: ExchangedFiat { - let rate = ratesController.rateForBalanceCurrency() - let zero = ExchangedFiat.compute( - onChainAmount: .zero(mint: .usdf), - rate: rate, - supplyQuarks: nil - ) - - guard let balance = session.balance(for: .usdf) else { - return zero - } - - return balance.computeExchangedValue(with: rate) - } - - @ObservationIgnored private let session: Session - @ObservationIgnored private let ratesController: RatesController - @ObservationIgnored private let destination: PublicKey - @ObservationIgnored private let currencyName: String - - // MARK: - Init - - - init(currencyPublicKey: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { - self.destination = currencyPublicKey - self.currencyName = currencyName - self.session = session - self.ratesController = ratesController - } - - // MARK: - Actions - - - func amountEnteredAction() { - guard enteredFiat != nil else { - return - } - - performBuy() - } - - /// Resolves the pin and computes the submission amount against it — one - /// fetch for both, so quarks can't drift from the submitted pin. - func prepareSubmission() async -> (amount: ExchangedFiat, pinnedState: VerifiedState)? { - let currency = ratesController.balanceCurrency - guard let pin = await ratesController.currentPinnedState(for: currency, mint: .usdf) else { - return nil - } - guard let amount = computeAmount(using: pin.rate) else { - return nil - } - return (amount, pin) - } - - /// Used by the display preview (live rate) and the submit path (pinned rate). - private func computeAmount(using rate: Rate) -> ExchangedFiat? { - guard !enteredAmount.isEmpty else { - return nil - } - - guard let amount = NumberFormatter.decimal(from: enteredAmount) else { - return nil - } - - let mint: PublicKey = .usdf - let entered = ExchangedFiat( - nativeAmount: FiatAmount(value: amount, currency: rate.currency), - rate: rate - ) - - // Cap to balance to handle rounding differences between display and entry. Since our display rounds HALF_UP - guard let balance = session.balance(for: .usdf) else { - return entered - } - - // If entered USDF value exceeds balance, cap it to the balance - if entered.usdfValue.value > balance.usdf.value { - return ExchangedFiat.compute( - onChainAmount: TokenAmount(quarks: balance.quarks, mint: mint), - rate: rate, - supplyQuarks: nil - ) - } - - return entered - } - - private func performBuy() { - actionButtonState = .loading - - Task { - guard let (buyAmount, pin) = await prepareSubmission() else { - actionButtonState = .normal - dialogItem = .staleRate - return - } - - let sendLimit = session.sendLimitFor(currency: buyAmount.nativeAmount.currency) ?? .zero - - guard buyAmount.nativeAmount.value <= sendLimit.maxPerDay.value else { - logger.info("Buy rejected: amount exceeds limit", metadata: [ - "amount": "\(buyAmount.nativeAmount.formatted())", - "max_per_day": "\(sendLimit.maxPerDay.value)", - "currency": "\(buyAmount.nativeAmount.currency)", - ]) - actionButtonState = .normal - showLimitsError() - return - } - - do { - let swapId = try await session.buy(amount: buyAmount, verifiedState: pin, of: destination) - path.append(.processing(swapId: swapId, currencyName: currencyName, amount: buyAmount)) - } catch Session.Error.insufficientBalance { - actionButtonState = .normal - showInsufficientBalanceError() - } catch Session.Error.verifiedStateStale { - // Session.assertFresh already logged; reset the button so the user can retry. - actionButtonState = .normal - } catch { - ErrorReporting.captureError( - error, - reason: "Failed to buy currency", - metadata: [ - "mint": destination.base58, - "amount": buyAmount.nativeAmount.formatted(), - ] - ) - actionButtonState = .normal - showGenericError() - } - } - } - - // MARK: - Reset - - - private func resetEnteredAmount() { - enteredAmount = "" - } - - // MARK: - Dialogs - - - private func showInsufficientBalanceError() { - dialogItem = .init( - style: .destructive, - title: "Insufficient Balance", - subtitle: "Please enter a lower amount and try again", - dismissable: true - ) { - .okay(kind: .destructive) - } - } - - private func showLimitsError() { - dialogItem = .init( - style: .destructive, - title: "Transaction Limit Reached", - subtitle: "You can only buy up to the transaction limit at a time", - dismissable: true - ) { - .okay(kind: .destructive) - } - } - - private func showGenericError() { - dialogItem = .init( - style: .destructive, - title: "Something Went Wrong", - subtitle: "Please try again later", - dismissable: true - ) { - .okay(kind: .destructive) - } - } -} - -enum CurrencyBuyPath: Hashable { - case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat) -} diff --git a/Flipcash/Core/Screens/Onramp/OnrampAmountScreen.swift b/Flipcash/Core/Screens/Onramp/OnrampAmountScreen.swift deleted file mode 100644 index 4b2e3028..00000000 --- a/Flipcash/Core/Screens/Onramp/OnrampAmountScreen.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// OnrampAmountScreen.swift -// Code -// -// Created by Dima Bart on 2025-04-17. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct OnrampAmountScreen: View { - - @State private var viewModel: OnrampViewModel - @Environment(OnrampCoordinator.self) private var onrampCoordinator - - private let onDismiss: () -> Void - - // MARK: - Init - - - static func forBuying( - mint: PublicKey, - displayName: String, - session: Session, - onrampCoordinator: OnrampCoordinator, - onDismiss: @escaping () -> Void - ) -> OnrampAmountScreen { - OnrampAmountScreen( - viewModel: .forBuying( - mint: mint, - displayName: displayName, - session: session, - onrampCoordinator: onrampCoordinator - ), - onDismiss: onDismiss - ) - } - - private init( - viewModel: OnrampViewModel, - onDismiss: @escaping () -> Void - ) { - _viewModel = State(wrappedValue: viewModel) - self.onDismiss = onDismiss - } - - // MARK: - Body - - - var body: some View { - @Bindable var viewModel = viewModel - @Bindable var onrampCoordinator = onrampCoordinator - NavigationStack { - Background(color: .backgroundMain) { - EnterAmountView( - mode: .onramp, - enteredAmount: $viewModel.enteredAmount, - subtitle: .singleTransactionLimit, - actionState: .constant(onrampCoordinator.isProcessingPayment ? .loading : .normal), - actionEnabled: { _ in viewModel.enteredFiat != nil }, - action: viewModel.customAmountEnteredAction, - currencySelectionAction: nil, - ) - .foregroundStyle(.textMain) - .padding(20) - } - .navigationTitle("Amount to Add") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if !onrampCoordinator.isProcessingPayment { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: onDismiss) - } - } - } - .interactiveDismissDisabled(onrampCoordinator.isProcessingPayment) - } - .dialog(item: $viewModel.dialogItem) - .dialog(item: $onrampCoordinator.dialogItem) - .sheet(isPresented: $onrampCoordinator.isShowingVerificationFlow) { - VerifyInfoScreen(onrampCoordinator: onrampCoordinator) - } - .onChange(of: onrampCoordinator.completion) { _, completion in - guard case .buyProcessing = completion else { return } - onDismiss() - } - } -} - -/// Invisible overlay that hosts the Coinbase Apple Pay WKWebView. The view is -/// rendered at zero opacity (it exists only to drive Apple Pay's JS payment -/// flow in the background) and explicitly excluded from hit testing and -/// accessibility so the covered region of the amount keypad remains tappable -/// and VoiceOver users don't land on a silent 300×300 zone. -struct ApplePayOverlay: View { - - let order: OnrampOrderResponse? - let onEvent: (ApplePayEvent) -> Void - - var body: some View { - if let order { - ApplePayWebView(url: order.paymentLink.url, onMessage: onEvent) - .frame(width: 300, height: 300) - .opacity(0) - .allowsHitTesting(false) - .accessibilityHidden(true) - .id(order.id) - } - } -} diff --git a/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift b/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift deleted file mode 100644 index 02df554b..00000000 --- a/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// OnrampViewModel.swift -// Code -// -// Created by Dima Bart on 2025-08-11. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -private let logger = Logger(label: "flipcash.onramp") - -/// Long-lived store for Onramp email verification deeplinks. `DeepLinkController` -/// drops incoming verifications here; `OnrampHostModifier` observes the value -/// with `.onChange(initial: true)` and hands it off to `OnrampCoordinator`, so -/// whether the link arrived before or after the sheet opened the verification -/// is picked up through the same entry point. Lives on `SessionContainer` so -/// it survives sheet dismissal but not logout. -@Observable -final class OnrampDeeplinkInbox { - var pendingEmailVerification: VerificationDescription? -} - -@Observable -class OnrampViewModel { - - var enteredAmount: String = "" - - var dialogItem: DialogItem? - - let displayName: String - - var enteredFiat: ExchangedFiat? { - guard !enteredAmount.isEmpty else { - return nil - } - - guard let amount = NumberFormatter.decimal(from: enteredAmount) else { - return nil - } - - return ExchangedFiat( - nativeAmount: FiatAmount(value: amount, currency: .usd), - rate: .oneToOne - ) - } - - @ObservationIgnored private let session: Session - @ObservationIgnored private let mint: PublicKey - @ObservationIgnored private let onrampCoordinator: OnrampCoordinator - - // MARK: - Init - - - static func forBuying( - mint: PublicKey, - displayName: String, - session: Session, - onrampCoordinator: OnrampCoordinator - ) -> OnrampViewModel { - OnrampViewModel( - displayName: displayName, - mint: mint, - session: session, - onrampCoordinator: onrampCoordinator - ) - } - - private init( - displayName: String, - mint: PublicKey, - session: Session, - onrampCoordinator: OnrampCoordinator - ) { - self.displayName = displayName - self.mint = mint - self.session = session - self.onrampCoordinator = onrampCoordinator - } - - // MARK: - Actions - - - func customAmountEnteredAction() { - guard let exchangedFiat = enteredFiat else { - return - } - - guard let maxPerDay = session.sendLimitFor(currency: exchangedFiat.nativeAmount.currency)?.maxPerDay else { - return - } - - guard exchangedFiat.nativeAmount.value <= maxPerDay.value else { - logger.info("Onramp rejected: amount exceeds limit", metadata: [ - "amount": "\(exchangedFiat.nativeAmount.formatted())", - "max_per_day": "\(maxPerDay.value)", - "currency": "\(exchangedFiat.nativeAmount.currency)", - ]) - showAmountTooLargeError() - return - } - - guard exchangedFiat.nativeAmount.value >= 5.00 else { - showAmountTooSmallError() - return - } - - onrampCoordinator.start( - .buy(mint: mint, displayName: displayName), - amount: exchangedFiat - ) - } - - // MARK: - Dialog Factories - - - private func presentDestructiveDialog( - title: String, - subtitle: String, - action: @escaping DialogAction.DialogActionHandler = {} - ) { - dialogItem = .init( - style: .destructive, - title: title, - subtitle: subtitle, - dismissable: true, - ) { - .okay(kind: .destructive, action: action) - } - } - - // MARK: - Errors - - - private func showAmountTooSmallError() { - presentDestructiveDialog( - title: "$5 Minimum Purchase", - subtitle: "Please enter an amount of $5 or higher" - ) - } - - private func showAmountTooLargeError() { - presentDestructiveDialog( - title: "Amount Too Large", - subtitle: "Please enter a smaller amount" - ) - } -} - -// MARK: - Paths - - -/// Navigation path for `VerifyInfoScreen`'s NavigationStack. -enum OnrampVerificationPath: Hashable { - case info - case enterPhoneNumber - case confirmPhoneNumberCode - case enterEmail - case confirmEmailCode -} - -// MARK: - Profile - - -extension Profile { - var canCreateCoinbaseOrder: Bool { - phone != nil && email?.isEmpty == false - } -} - -// MARK: - OnrampError - - -enum OnrampError: Error { - case missingCoinbaseApiKey -} - diff --git a/FlipcashTests/CurrencyBuyViewModelTests.swift b/FlipcashTests/CurrencyBuyViewModelTests.swift deleted file mode 100644 index de5abc36..00000000 --- a/FlipcashTests/CurrencyBuyViewModelTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// CurrencyBuyViewModelTests.swift -// FlipcashTests -// -// Created by Raul Riera on 2026-01-02. -// - -import Foundation -import Testing -import SwiftUI -import FlipcashUI -@testable import FlipcashCore -@testable import Flipcash - -@MainActor -struct CurrencyBuyViewModelTests { - - // MARK: - Test Helpers - - - /// CAD rate: 1 USD = 1.35 CAD - static let cadRate = Rate(fx: 1.35, currency: .cad) - - /// Helper to create a test view model with CAD as the selected currency. - /// Uses the mock SessionContainer which has no seeded balance. - static func createViewModel() -> CurrencyBuyViewModel { - let sessionContainer = SessionContainer.mock - - sessionContainer.ratesController.configureTestRates(balanceCurrency: .cad, rates: [cadRate]) - - return CurrencyBuyViewModel( - currencyPublicKey: .usdf, - currencyName: "USDF", - session: sessionContainer.session, - ratesController: sessionContainer.ratesController - ) - } - - /// Helper to create a view model backed by a real database seeded with USDF balance, - /// so `canPerformAction` can reach the display-limit check. - static func createViewModelWithBalance() throws -> CurrencyBuyViewModel { - // 10 USDF (10_000_000 quarks at 6 decimals) - let container = try SessionContainer.makeTest(holdings: [ - .init(mint: MintMetadata.usdf, quarks: 10_000_000) - ]) - - container.ratesController.configureTestRates(balanceCurrency: .cad, rates: [cadRate]) - - return CurrencyBuyViewModel( - currencyPublicKey: .jeffy, - currencyName: "Test", - session: container.session, - ratesController: container.ratesController - ) - } - - // MARK: - Initialization Tests - - - @Test - func testInitialization_DefaultValues() { - // Given/When: Creating a new view model - let viewModel = Self.createViewModel() - - // Then: Initial state should be correct - #expect(viewModel.actionButtonState == .normal) - #expect(viewModel.enteredAmount == "") - #expect(viewModel.dialogItem == nil) - #expect(viewModel.canPerformAction == false) - } - - // MARK: - Entered Fiat Direction Tests - - - @Test - func testEnteredFiat_WithCADEntry_NativeIsCAD_USDFValueIsUSD() throws { - // Given: A view model with 1 CAD entered - // Rate is 1.35 (1 USD = 1.35 CAD), so 1 CAD = ~0.74 USD usdf value - let viewModel = Self.createViewModel() - viewModel.enteredAmount = "1" - - // When: Getting the enteredFiat from the viewModel - let exchangedFiat = try #require(viewModel.enteredFiat) - - // Then: Native should be in CAD (the entry currency) - #expect(exchangedFiat.nativeAmount.currency == .cad) - - // Then: USDF value should be in USD (the base currency) - #expect(exchangedFiat.usdfValue.currency == .usd) - - // Then: Rate should match our configured CAD rate - #expect(exchangedFiat.currencyRate.currency == .cad) - #expect(exchangedFiat.currencyRate.fx == Self.cadRate.fx) - - // Then: The USD value should be less than the native CAD value - // because 1 CAD < 1 USD (1 CAD ≈ 0.74 USD at 1.35 rate) - #expect(exchangedFiat.usdfValue.value < exchangedFiat.nativeAmount.value) - } - - // MARK: - canPerformAction - - - @Test("canPerformAction is false when enteredAmount is empty") - func canPerformAction_emptyAmount_returnsFalse() { - let viewModel = Self.createViewModel() - - #expect(viewModel.canPerformAction == false) - } - - @Test("canPerformAction is true when a valid amount is within the display cap") - func canPerformAction_validAmount_returnsTrue() throws { - let viewModel = try Self.createViewModelWithBalance() - viewModel.enteredAmount = "1" - - #expect(viewModel.canPerformAction == true) - } -} diff --git a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift index c91b1199..b32f63a1 100644 --- a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift @@ -39,8 +39,8 @@ struct AppRouterBuySheetTests { let mint = PublicKey.usdf router.present(.buy(mint)) - // Mirrors `CurrencyBuyViewModel.maxPossibleAmount` — build a minimal - // ExchangedFiat with zero on-chain amount against a fixed USD rate. + // Minimal ExchangedFiat with zero on-chain amount against a fixed + // USD rate, just enough to satisfy BuyFlowPath.phantomEducation. let pinned = ExchangedFiat.compute( onChainAmount: .zero(mint: .usdf), rate: .oneToOne, diff --git a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift index 118effce..ca8e1ac4 100644 --- a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift +++ b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift @@ -4,14 +4,15 @@ // // Regression coverage for the "native amount does not match sell amount" // server error. Invariants: -// - `prepareSubmission` computes quarks against the pinned rate (and supply +// - Submission paths compute quarks against the pinned rate (and supply // where applicable), not the live cache. -// - When no fresh pin is cached, `prepareSubmission` returns nil so submit -// bails with a dialog instead of submitting an intent the server rejects. +// - When no fresh pin is cached, the submission path bails with a dialog +// instead of submitting an intent the server rejects. // import Foundation import Testing +import FlipcashUI @testable import Flipcash @testable import FlipcashCore @@ -24,10 +25,23 @@ struct Regression_native_amount_mismatch { // MARK: - Scenario D (buy) - @Test("Scenario D (buy): prepareSubmission computes quarks from the PINNED rate, not the live cache") - func scenarioD_buyPrepareSubmissionUsesPinnedRate() async throws { + @Test("Scenario D (buy): amountEnteredAction computes quarks from the PINNED rate, not the live cache") + func scenarioD_buyAmountEnteredActionUsesPinnedRate() async throws { // Pinned rate: 1 USD = 1.35 CAD. Live cache drifted to 1.37 after the pin was captured. - let sessionContainer = SessionContainer.mock + // Zero USDF balance so the picker path is taken and the pinned amount surfaces in + // `pendingMethodSelection` for inspection. + let sessionContainer = try SessionContainer.makeTest( + holdings: [], + limits: Limits( + sinceDate: .now, + fetchDate: .now, + sendLimits: [.cad: SendLimit( + nextTransaction: FiatAmount(value: 1000, currency: .cad), + maxPerTransaction: FiatAmount(value: 1000, currency: .cad), + maxPerDay: FiatAmount(value: 1000, currency: .cad) + )] + ) + ) sessionContainer.ratesController.configureTestRates( balanceCurrency: .cad, rates: [Rate(fx: 1.37, currency: .cad)] @@ -36,22 +50,26 @@ struct Regression_native_amount_mismatch { .freshRate(currencyCode: "CAD", rate: 1.35) ]) - let vm = CurrencyBuyViewModel( - currencyPublicKey: .usdf, + let vm = BuyAmountViewModel( + mint: .usdf, currencyName: "USDF", session: sessionContainer.session, ratesController: sessionContainer.ratesController ) vm.enteredAmount = "1" - let submission = try #require(await vm.prepareSubmission()) + let router = AppRouter() + router.present(.buy(.usdf)) + await vm.amountEnteredAction(router: router) + + let context = try #require(vm.pendingMethodSelection) // $1 CAD / 1.35 × 10^6, HALF_UP rounded via scaleUpInt → 740_741 USDF quarks. // The buggy live path (1.37) would round to 729_927 quarks — the value // the server rejected in production. - #expect(submission.amount.onChainAmount.quarks == 740_741) - #expect(submission.amount.currencyRate.fx == Decimal(1.35)) - #expect(submission.pinnedState.exchangeRate == 1.35) + #expect(context.amount.onChainAmount.quarks == 740_741) + #expect(context.amount.currencyRate.fx == Decimal(1.35)) + #expect(context.verifiedState.exchangeRate == 1.35) } // MARK: - Scenario D (sell) @@ -130,8 +148,8 @@ struct Regression_native_amount_mismatch { // MARK: - Scenario E (buy) - @Test("Scenario E (buy): prepareSubmission returns nil when no fresh pin is cached") - func scenarioE_buyPrepareSubmissionReturnsNilWhenNoPin() async { + @Test("Scenario E (buy): amountEnteredAction surfaces .staleRate when no fresh pin is cached") + func scenarioE_buyAmountEnteredActionSurfacesStaleRateWhenNoPin() async { // Live rate is configured, but nothing is seeded in the verified proto // service — the submit path has no pin to use and must bail. let sessionContainer = SessionContainer.mock @@ -140,17 +158,20 @@ struct Regression_native_amount_mismatch { rates: [Rate(fx: 1.35, currency: .cad)] ) - let vm = CurrencyBuyViewModel( - currencyPublicKey: .usdf, + let vm = BuyAmountViewModel( + mint: .usdf, currencyName: "USDF", session: sessionContainer.session, ratesController: sessionContainer.ratesController ) vm.enteredAmount = "1" - let submission = await vm.prepareSubmission() + let router = AppRouter() + router.present(.buy(.usdf)) + await vm.amountEnteredAction(router: router) - #expect(submission == nil) + #expect(vm.pendingMethodSelection == nil) + #expect(vm.dialogItem?.title == DialogItem.staleRate.title) } // MARK: - Scenario E (sell) diff --git a/FlipcashUITests/Regression/CurrencyBuyRegressionTests.swift b/FlipcashUITests/Regression/CurrencyBuyRegressionTests.swift deleted file mode 100644 index 6497e6df..00000000 --- a/FlipcashUITests/Regression/CurrencyBuyRegressionTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// CurrencyBuyRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression test for the full currency buy flow using USDF reserves. -/// -/// **Prerequisites:** -/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` -/// - The test account must have USDF reserves -/// - The test account must have at least one non-USDF currency visible in Wallet -final class CurrencyBuyRegressionTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - func testBuyCurrency_fullFlowWithReserves() { - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let funding = FundingSelectionScreen(app: app) - let amountEntry = AmountEntryScreen(app: app) - let processing = SwapProcessingUIScreen(app: app) - - assertMainScreenReached() - - // Navigate: Main → Wallet → first currency → CurrencyInfoScreen - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - - // Buy → select USDF reserves → enter $0.01 → submit - waitAndTap(currencyInfo.buyButton) - funding.selectUSDF(from: self) - amountEntry.enterMinimumAmount() - waitUntilHittableAndTap(amountEntry.buyActionButton) - - // Wait for swap to complete and dismiss - processing.waitForCompletionAndDismiss() - - // Verify we returned to CurrencyInfoScreen - currencyInfo.assertReached() - } -} diff --git a/FlipcashUITests/Regression/WalletCallbackRegressionTests.swift b/FlipcashUITests/Regression/WalletCallbackRegressionTests.swift deleted file mode 100644 index dc22e6e6..00000000 --- a/FlipcashUITests/Regression/WalletCallbackRegressionTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// WalletCallbackRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -final class WalletCallbackRegressionTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - /// Simulates Phantom returning a "walletConnected" callback while - /// the user is on the CurrencyInfoScreen. The screen must remain - /// visible — not reset to the root. - func testWalletConnectedCallback_doesNotResetInterface() { - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let funding = FundingSelectionScreen(app: app) - - assertMainScreenReached() - - // Navigate to CurrencyInfoScreen → Buy → select Phantom - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - waitAndTap(currencyInfo.buyButton) - funding.selectPhantom(from: self) - - // Phantom would open here — simulate its redirect back - // with a walletConnected callback. The encrypted payload - // won't decrypt (no real Phantom session), but the - // interface must NOT reset — that's the regression. - let walletConnectedURL = URL(string: "https://app.flipcash.com/wallet/walletConnected?nonce=test&data=test")! - XCUIDevice.shared.system.open(walletConnectedURL) - - // Verify we're still on CurrencyInfoScreen, not reset to root - currencyInfo.assertReached(timeout: 5) - } - - /// Simulates Phantom returning an error (user cancelled) after - /// tapping Buy → Phantom. The screen must remain visible. - func testWalletErrorCallback_doesNotResetInterface() { - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let funding = FundingSelectionScreen(app: app) - - assertMainScreenReached() - - // Navigate to CurrencyInfoScreen → Buy → select Phantom - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - waitAndTap(currencyInfo.buyButton) - funding.selectPhantom(from: self) - - // Simulate Phantom returning with errorCode=4001 (user cancelled) - let walletErrorURL = URL(string: "https://app.flipcash.com/wallet/walletConnected?errorCode=4001")! - XCUIDevice.shared.system.open(walletErrorURL) - - // Verify we're still on CurrencyInfoScreen - currencyInfo.assertReached(timeout: 5) - } -} diff --git a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift index f5f1a897..e2579bcb 100644 --- a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift +++ b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift @@ -22,7 +22,7 @@ struct AmountEntryScreen { var nextButton: XCUIElement { app.buttons["Next"] } /// The "Buy" `CodeButton` on the amount entry screen. - /// When the CurrencyBuyAmountScreen sheet is presented, there are two "Buy" buttons in + /// When the BuyAmountScreen sheet is presented, there are two "Buy" buttons in /// the hierarchy — the footer button on CurrencyInfoScreen (index 0, behind the sheet) /// and the action button on the amount entry sheet (index 1, on top). var buyActionButton: XCUIElement { diff --git a/FlipcashUITests/Support/Screens/FundingSelectionScreen.swift b/FlipcashUITests/Support/Screens/FundingSelectionScreen.swift deleted file mode 100644 index 429d7e74..00000000 --- a/FlipcashUITests/Support/Screens/FundingSelectionScreen.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// FundingSelectionScreen.swift -// FlipcashUITests -// - -import XCTest - -/// Page object for the FundingSelectionSheet that appears when buying a currency. -@MainActor -struct FundingSelectionScreen { - - private let app: XCUIApplication - - init(app: XCUIApplication) { - self.app = app - } - - // MARK: - Elements - - /// The USDF reserves button. Title is dynamic ("USDF ($X.XX)"), matched by prefix. - var usdfReservesButton: XCUIElement { - app.buttons.matching( - NSPredicate(format: "label BEGINSWITH 'USDF'") - ).firstMatch - } - - /// The Phantom wallet button. Label includes "Solana USDC With, Phantom". - var phantomButton: XCUIElement { - app.buttons.matching( - NSPredicate(format: "label CONTAINS 'Phantom'") - ).firstMatch - } - - // MARK: - Actions - - func selectUSDF(from testCase: BaseUITestCase) { - testCase.waitAndTap( - usdfReservesButton, - timeout: 10, - "Expected USDF reserves option in funding sheet" - ) - } - - func selectPhantom(from testCase: BaseUITestCase) { - testCase.waitAndTap( - phantomButton, - timeout: 10, - "Expected Phantom option in funding sheet" - ) - } -} From 2cc21ddadbeb91910c4d150f5ff81d34b5b01cbb Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 15:08:08 -0400 Subject: [PATCH 16/33] chore: address /simplify findings on buy flow --- .../Onramp/OnrampHostModifier.swift | 11 ++--- .../Screens/Main/Buy/BuyAmountViewModel.swift | 32 ++++++-------- .../Main/Buy/PurchaseMethodSheet.swift | 43 ++++++++++++------- .../Main/Buy/USDCDepositAddressScreen.swift | 20 ++++----- 4 files changed, 57 insertions(+), 49 deletions(-) diff --git a/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift b/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift index b562675a..c7bbcb0e 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift @@ -13,11 +13,12 @@ import FlipcashCore /// /// The verification sheet is NOT hosted here — SwiftUI only allows one modal /// sheet per presentation context, and when the user is several sheets deep -/// (Wallet → Discovery → Wizard → FundingSelection) the root-level sheet slot -/// is already taken. Screens that initiate an onramp (`PurchaseMethodSheet` -/// via the `.buy` router stack, `CurrencyCreationWizardScreen` for launch) -/// host the verification sheet themselves so it presents on top of whatever -/// sheet stack the user is already in. +/// (Wallet → Discovery → Wizard → funding picker) the root-level sheet slot +/// is already taken. Each screen that owns the user's path into onramp hosts +/// the verification sheet itself so it presents on top of whatever sheet +/// stack the user is in: `BuyAmountScreen` for the buy flow (verification +/// fires when the user taps Apple Pay in `PurchaseMethodSheet`), and +/// `CurrencyCreationWizardScreen` for the launch flow. struct OnrampHostModifier: ViewModifier { @Environment(OnrampCoordinator.self) private var onrampCoordinator diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index d4e7bbb3..2db404e4 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -63,12 +63,9 @@ final class BuyAmountViewModel: Identifiable { // MARK: - Submission /// Single source of truth for amount submission. Pin verified state, compute - /// quarks against the pin, run limit + balance gates, then either auto-buy - /// (when USDF covers the amount) or hand off to the funding picker via - /// ``pendingMethodSelection``. - /// - /// `session` is captured in init; the caller only injects `router` because - /// SwiftUI's `@Environment` isn't reliably available from a viewmodel. + /// quarks against the pin, run limit + balance gates, then either submit + /// the buy directly (when USDF covers the amount) or hand off to the + /// funding picker via ``pendingMethodSelection``. func amountEnteredAction(router: AppRouter) async { guard enteredFiat != nil else { return } actionButtonState = .loading @@ -92,7 +89,7 @@ final class BuyAmountViewModel: Identifiable { } if usdfBalanceCovers(amount) { - await performAutoBuy(amount: amount, pin: pin, router: router) + await performBuy(amount: amount, pin: pin, router: router) } else { actionButtonState = .normal routeToPicker(amount: amount, pin: pin) @@ -113,8 +110,9 @@ final class BuyAmountViewModel: Identifiable { return balance.usdf.value >= amount.usdfValue.value } - private func performAutoBuy(amount: ExchangedFiat, pin: VerifiedState, router: AppRouter) async { + private func performBuy(amount: ExchangedFiat, pin: VerifiedState, router: AppRouter) async { do { + Analytics.buttonTapped(name: .buyWithReserves) let swapId = try await session.buy(amount: amount, verifiedState: pin, of: mint) actionButtonState = .normal router.pushAny(BuyFlowPath.processing( @@ -130,13 +128,18 @@ final class BuyAmountViewModel: Identifiable { } catch Session.Error.verifiedStateStale { actionButtonState = .normal } catch { + logger.error("Failed to buy currency from BuyAmountScreen", metadata: [ + "mint": "\(mint.base58)", + "amount": "\(amount.nativeAmount.formatted())", + "error": "\(error)", + ]) ErrorReporting.captureError( error, - reason: "Failed to auto-buy currency from BuyAmountScreen", + reason: "Failed to buy currency from BuyAmountScreen", metadata: ["mint": mint.base58, "amount": amount.nativeAmount.formatted()] ) actionButtonState = .normal - showGenericError() + dialogItem = .somethingWentWrong } } @@ -172,13 +175,4 @@ final class BuyAmountViewModel: Identifiable { dismissable: true ) { .okay(kind: .destructive) } } - - private func showGenericError() { - dialogItem = .init( - style: .destructive, - title: "Something Went Wrong", - subtitle: "Please try again later", - dismissable: true - ) { .okay(kind: .destructive) } - } } diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index 6a1ef056..b4805ab1 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -50,25 +50,15 @@ struct PurchaseMethodSheet: View { } .padding(.vertical, 20) - if session.hasCoinbaseOnramp { - ApplePayMethodButton( + ForEach(Self.methods(forSession: session), id: \.self) { method in + MethodButton( + method: method, context: context, - onDismiss: onDismiss + onDismiss: onDismiss, + router: router ) } - PhantomMethodButton( - context: context, - onDismiss: onDismiss, - router: router - ) - - OtherWalletMethodButton( - context: context, - onDismiss: onDismiss, - router: router - ) - Button("Dismiss", action: onDismiss) .buttonStyle(.subtle) } @@ -77,6 +67,27 @@ struct PurchaseMethodSheet: View { } } +/// Dispatch wrapper so the parent body can `ForEach` over `methods(forSession:)` +/// and have a single source of truth for which rows render. The concrete row +/// structs below own the visual + side-effect details. +private struct MethodButton: View { + let method: PurchaseMethodSheet.Method + let context: PurchaseMethodContext + let onDismiss: () -> Void + let router: AppRouter + + var body: some View { + switch method { + case .applePay: + ApplePayMethodButton(context: context, onDismiss: onDismiss) + case .phantom: + PhantomMethodButton(context: context, onDismiss: onDismiss, router: router) + case .otherWallet: + OtherWalletMethodButton(context: context, onDismiss: onDismiss, router: router) + } + } +} + private struct ApplePayMethodButton: View { let context: PurchaseMethodContext let onDismiss: () -> Void @@ -85,6 +96,7 @@ private struct ApplePayMethodButton: View { var body: some View { Button { + Analytics.buttonTapped(name: .buyWithCoinbase) onDismiss() Task { @MainActor in try? await Task.sleep(for: AppRouter.dismissAnimationDuration) @@ -111,6 +123,7 @@ private struct PhantomMethodButton: View { var body: some View { Button { + Analytics.buttonTapped(name: .buyWithPhantom) onDismiss() Task { @MainActor in try? await Task.sleep(for: AppRouter.dismissAnimationDuration) diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift index 8dc2305b..a0072875 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift @@ -20,16 +20,7 @@ struct USDCDepositAddressScreen: View { @Environment(Session.self) private var session @State private var buttonState: ButtonState = .normal - - private var depositAddress: String? { - // The per-user staging address on USDF: USDC sent here is converted - // 1:1 to USDF for the user. `ata` is the SPL-token receive address - // derived from the swap PDA (an SPL transfer must target a token - // account, not the PDA itself). - MintMetadata.usdf - .timelockSwapAccounts(owner: session.owner.authorityPublicKey)? - .ata.publicKey.base58 - } + @State private var depositAddress: String? var body: some View { Background(color: .backgroundMain) { @@ -67,6 +58,15 @@ struct USDCDepositAddressScreen: View { } .navigationTitle("Deposit USDC") .navigationBarTitleDisplayMode(.inline) + .task { + // PDA derivation runs Ed25519 isOnCurve checks against up to 256 + // candidate seeds, so resolve once on appear instead of on every + // body evaluation (`buttonState` flips to `.successText("Copied")` + // on tap, which would re-derive otherwise). + depositAddress = MintMetadata.usdf + .timelockSwapAccounts(owner: session.owner.authorityPublicKey)? + .ata.publicKey.base58 + } } private func copy(_ value: String) { From a78a8bcf40d6b3211bf65e466d3a8df53fa21a5f Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 15:22:57 -0400 Subject: [PATCH 17/33] fix: address SwiftUI + concurrency + testing review findings on buy flow --- .../Onramp/OnrampDeeplinkInbox.swift | 1 + .../Screens/Main/Buy/BuyAmountScreen.swift | 3 +- .../Core/Screens/Main/Buy/BuyFlowPath.swift | 2 +- .../Main/Buy/PhantomConfirmScreen.swift | 21 ++++++++----- .../Main/Buy/PhantomEducationScreen.swift | 8 ++++- .../Main/Buy/PurchaseMethodContext.swift | 2 +- .../Main/Buy/PurchaseMethodSheet.swift | 6 ++-- .../Main/Buy/USDCDepositAddressScreen.swift | 5 ++-- .../Buy/BuyAmountViewModelTests.swift | 30 ++++++++----------- 9 files changed, 44 insertions(+), 34 deletions(-) diff --git a/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift b/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift index c8827508..1d806551 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift @@ -12,6 +12,7 @@ import SwiftUI /// is picked up through the same entry point. Lives on `SessionContainer` so /// it survives sheet dismissal but not logout. @Observable +@MainActor final class OnrampDeeplinkInbox { var pendingEmailVerification: VerificationDescription? } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index de1a4d8f..8b071976 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -27,6 +27,7 @@ struct BuyAmountScreen: View { var body: some View { @Bindable var viewModel = viewModel + @Bindable var coordinator = coordinator Background(color: .backgroundMain) { EnterAmountView( mode: .buy, @@ -50,7 +51,7 @@ struct BuyAmountScreen: View { onDismiss: { viewModel.pendingMethodSelection = nil } ) } - .sheet(isPresented: Bindable(coordinator).isShowingVerificationFlow) { + .sheet(isPresented: $coordinator.isShowingVerificationFlow) { VerifyInfoScreen(onrampCoordinator: coordinator) } .onChange(of: coordinator.completion) { _, newValue in diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift index 5e00eb3c..1ca19f41 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -17,7 +17,7 @@ import FlipcashCore /// associated values include `ExchangedFiat` and `SwapId` — both already /// Hashable + Sendable. Keeping these out of `Destination` matches the /// `WithdrawNavigationPath` pattern. -enum BuyFlowPath: Hashable { +enum BuyFlowPath: Hashable, Sendable { case phantomEducation(mint: PublicKey, amount: ExchangedFiat) case phantomConfirm(mint: PublicKey, amount: ExchangedFiat) case usdcDepositEducation(mint: PublicKey, amount: ExchangedFiat) diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index ad52c32f..f4bec6ab 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -29,6 +29,7 @@ struct PhantomConfirmScreen: View { @Environment(Session.self) private var session @State private var dialogItem: DialogItem? + @State private var confirmTask: Task? var body: some View { Background(color: .backgroundMain) { @@ -57,6 +58,13 @@ struct PhantomConfirmScreen: View { .navigationTitle("Confirmation") .navigationBarTitleDisplayMode(.inline) .dialog(item: $dialogItem) + .onDisappear { + // Cancel any in-flight swap request if the user backs out before + // Phantom returns. Without this, requestSwap's deeplink-out and + // pendingSwap mutation can fire against a popped screen. + confirmTask?.cancel() + confirmTask = nil + } .onChange(of: walletConnection.state) { _, newState in // Push the processing screen the moment the swap context appears. // `state` flips to `.buying(..., isFailed: false)` immediately @@ -75,13 +83,17 @@ struct PhantomConfirmScreen: View { } private func confirmInPhantom() { - Task { + confirmTask?.cancel() + confirmTask = Task { do { let metadata = try await session.fetchMintMetadata(mint: mint) + try Task.checkCancellation() try await walletConnection.requestSwap( usdc: amount.onChainAmount, token: metadata.metadata ) + } catch is CancellationError { + return } catch { logger.error("Failed to request Phantom swap", metadata: [ "mint": "\(mint.base58)", @@ -93,12 +105,7 @@ struct PhantomConfirmScreen: View { reason: "Failed to request Phantom swap from PhantomConfirmScreen", metadata: ["mint": mint.base58] ) - dialogItem = .init( - style: .destructive, - title: "Something Went Wrong", - subtitle: "We couldn't open Phantom. Please try again.", - dismissable: true - ) { .okay(kind: .destructive) } + dialogItem = .somethingWentWrong } } } diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift index a2c80958..d3a41b89 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift @@ -23,6 +23,11 @@ struct PhantomEducationScreen: View { @Environment(AppRouter.self) private var router @Environment(WalletConnection.self) private var walletConnection + /// Single-shot advance latch. Without this, popping back from + /// `phantomConfirm` re-fires the auto-push (the user is still connected) + /// and the back button effectively does nothing. + @State private var didAutoAdvance = false + var body: some View { Background(color: .backgroundMain) { ScrollView { @@ -52,7 +57,8 @@ struct PhantomEducationScreen: View { .onChange(of: walletConnection.session != nil, initial: true) { _, isConnected in // Auto-advance when Phantom returns from the connect deeplink, or // immediately on appear if a prior session already exists. - guard isConnected else { return } + guard isConnected, !didAutoAdvance else { return } + didAutoAdvance = true router.pushAny(BuyFlowPath.phantomConfirm(mint: mint, amount: amount)) } } diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift index d80282df..5b71a228 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift @@ -12,7 +12,7 @@ import FlipcashCore /// screen into whichever funding branch the user picks (Apple Pay, Phantom, /// Other Wallet). Carrying the pin avoids re-fetching the verified state at /// each step and preserves the pin-at-compute invariant. -struct PurchaseMethodContext: Identifiable { +struct PurchaseMethodContext: Identifiable, Sendable { let id = UUID() let mint: PublicKey let currencyName: String diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index b4805ab1..2391b1eb 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -98,7 +98,7 @@ private struct ApplePayMethodButton: View { Button { Analytics.buttonTapped(name: .buyWithCoinbase) onDismiss() - Task { @MainActor in + Task { try? await Task.sleep(for: AppRouter.dismissAnimationDuration) coordinator.start( .buy(mint: context.mint, displayName: context.currencyName), @@ -125,7 +125,7 @@ private struct PhantomMethodButton: View { Button { Analytics.buttonTapped(name: .buyWithPhantom) onDismiss() - Task { @MainActor in + Task { try? await Task.sleep(for: AppRouter.dismissAnimationDuration) router.pushAny(BuyFlowPath.phantomEducation(mint: context.mint, amount: context.amount)) } @@ -150,7 +150,7 @@ private struct OtherWalletMethodButton: View { var body: some View { Button("Other Wallet") { onDismiss() - Task { @MainActor in + Task { try? await Task.sleep(for: AppRouter.dismissAnimationDuration) router.pushAny(BuyFlowPath.usdcDepositEducation(mint: context.mint, amount: context.amount)) } diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift index a0072875..831f1dd7 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift @@ -58,11 +58,12 @@ struct USDCDepositAddressScreen: View { } .navigationTitle("Deposit USDC") .navigationBarTitleDisplayMode(.inline) - .task { + .onAppear { // PDA derivation runs Ed25519 isOnCurve checks against up to 256 // candidate seeds, so resolve once on appear instead of on every // body evaluation (`buttonState` flips to `.successText("Copied")` - // on tap, which would re-derive otherwise). + // on tap, which would re-derive otherwise). The work is synchronous + // so `.onAppear` is the right tool — `.task` would be misleading. depositAddress = MintMetadata.usdf .timelockSwapAccounts(owner: session.owner.authorityPublicKey)? .ata.publicKey.base58 diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift index d1c40bd5..6b282590 100644 --- a/FlipcashTests/Buy/BuyAmountViewModelTests.swift +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -83,15 +83,20 @@ struct BuyAmountViewModelTests { #expect(viewModel.pendingMethodSelection == nil) } - @Test("Insufficient USDF balance opens the funding picker") - func insufficientBalance_opensPicker() async throws { - // $5 USDF cannot cover a $20 buy. - let container = try await Self.makeContainer(usdfQuarks: 5_000_000) + @Test( + "Balance below the entered amount opens the funding picker", + arguments: [ + (usdfQuarks: UInt64(0), enteredAmount: "1"), + (usdfQuarks: UInt64(5_000_000), enteredAmount: "20"), + ] + ) + func insufficientBalance_opensPicker(usdfQuarks: UInt64, enteredAmount: String) async throws { + let container = try await Self.makeContainer(usdfQuarks: usdfQuarks) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() router.present(.buy(.usdf)) - viewModel.enteredAmount = "20" + viewModel.enteredAmount = enteredAmount await viewModel.amountEnteredAction(router: router) let context = try #require(viewModel.pendingMethodSelection) @@ -100,19 +105,6 @@ struct BuyAmountViewModelTests { #expect(router[.buy].count == 0) } - @Test("Zero USDF balance opens the funding picker") - func zeroBalance_opensPicker() async throws { - let container = try await Self.makeContainer(usdfQuarks: 0) - let viewModel = Self.makeViewModel(container: container) - let router = AppRouter() - router.present(.buy(.usdf)) - - viewModel.enteredAmount = "1" - await viewModel.amountEnteredAction(router: router) - - #expect(viewModel.pendingMethodSelection != nil) - } - @Test("Pinned amount is carried into the PurchaseMethodContext") func pinPropagation() async throws { let container = try await Self.makeContainer(usdfQuarks: 0) @@ -142,5 +134,7 @@ struct BuyAmountViewModelTests { #expect(viewModel.pendingMethodSelection == nil) #expect(router[.buy].count == 0) #expect(viewModel.dialogItem == nil) + // Loading flicker on an empty submit would be a regression. + #expect(viewModel.actionButtonState == .normal) } } From 30c4d73190462749a0594498165ea42f24f8e652 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 15:38:58 -0400 Subject: [PATCH 18/33] fix: buy flow as navigation push instead of top-level sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping Buy on CurrencyInfoScreen was dismissing the wallet sheet and presenting a new one — AppRouter swaps sheets instead of stacking, so the user lost the underlying screen. SwapProcessingScreen OK had no handler to close the flow. Make .buyAmount(mint) a regular Destination on the balance stack so it pushes naturally on top of CurrencyInfo. BuyAmountScreen captures the parent path depth on first appear and provides a dismissParentContainer closure that pops every sub-flow screen back to the parent, restoring the back-arrow + OK-tap behavior. Drops .buy(PublicKey) SheetPresentation and .buy Stack as unused. --- .../Navigation/AppRouter+Destination.swift | 9 ++++-- .../AppRouter+DestinationView.swift | 9 ++++++ .../AppRouter+SheetPresentation.swift | 3 -- .../Core/Navigation/AppRouter+Stack.swift | 8 ----- .../Screens/Main/Buy/BuyAmountScreen.swift | 32 +++++++++++++++++++ .../Currency Info/CurrencyInfoScreen.swift | 4 +-- Flipcash/Core/Screens/Main/ScanScreen.swift | 23 ------------- .../Buy/BuyAmountViewModelTests.swift | 12 +++---- .../Navigation/AppRouterBuySheetTests.swift | 30 ++++++++--------- .../Regression_native_amount_mismatch.swift | 4 +-- 10 files changed, 71 insertions(+), 63 deletions(-) diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 6848f7ea..5eb0ad0f 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -16,11 +16,12 @@ extension AppRouter { // Wallet flow case currencyInfo(PublicKey) - /// Same screen as `currencyInfo` but auto-presents the funding-selection - /// sheet on appear. Modelled as a sibling case rather than an + /// Same screen as `currencyInfo` but auto-pushes the buy amount entry + /// 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 buyAmount(PublicKey) case discoverCurrencies case currencyCreationSummary case currencyCreationWizard @@ -43,7 +44,7 @@ extension AppRouter { /// navigation uses this to know which sheet to present. var owningStack: Stack { switch self { - case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies, + case .currencyInfo, .currencyInfoForDeposit, .buyAmount, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .transactionHistory, .give: return .balance @@ -63,6 +64,7 @@ extension AppRouter { switch self { case .currencyInfo: "currencyInfo" case .currencyInfoForDeposit: "currencyInfoForDeposit" + case .buyAmount: "buyAmount" case .discoverCurrencies: "discoverCurrencies" case .currencyCreationSummary: "currencyCreationSummary" case .currencyCreationWizard: "currencyCreationWizard" @@ -88,6 +90,7 @@ extension AppRouter { switch self { case .currencyInfo(let mint), .currencyInfoForDeposit(let mint), + .buyAmount(let mint), .transactionHistory(let mint), .give(let mint), .deposit(let mint): diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 75fe8f31..c7690bec 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -45,6 +45,15 @@ struct DestinationView: View { ) .id(mint) + case .buyAmount(let mint): + BuyAmountScreen( + mint: mint, + currencyName: sessionContainer.session.balance(for: mint)?.name ?? "this currency", + session: sessionContainer.session, + ratesController: sessionContainer.ratesController + ) + .id(mint) + case .discoverCurrencies: CurrencyDiscoveryScreen( container: container, diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 4a52dbac..7e2fd31e 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -17,7 +17,6 @@ extension AppRouter { case settings case give case discover - case buy(PublicKey) var id: Self { self } @@ -30,7 +29,6 @@ extension AppRouter { case .settings: .settings case .give: .give case .discover: .discover - case .buy: .buy } } @@ -40,7 +38,6 @@ extension AppRouter { case .settings: "settings" case .give: "give" case .discover: "discover" - case .buy: "buy" } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift index 21d2fb02..4a64355f 100644 --- a/Flipcash/Core/Navigation/AppRouter+Stack.swift +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -17,22 +17,15 @@ extension AppRouter { case settings case give case discover - case buy /// The sheet a stack is presented in. Cross-stack navigation uses /// this to know which top-level modal to surface. - /// - /// `.buy` is excluded — its sheet carries a mint payload that can't be - /// synthesized from the stack alone. Buy is always entered via - /// `router.present(.buy(mint))` directly, never via `navigate(to:)`. var sheet: SheetPresentation { switch self { case .balance: .balance case .settings: .settings case .give: .give case .discover: .discover - case .buy: - preconditionFailure("buy sheet must be presented via router.present(.buy(mint)); not reachable via Stack.sheet") } } @@ -42,7 +35,6 @@ extension AppRouter { case .settings: "settings" case .give: "give" case .discover: "discover" - case .buy: "buy" } } } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index 8b071976..c2ee2e8c 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -12,6 +12,11 @@ import FlipcashUI struct BuyAmountScreen: View { @State private var viewModel: BuyAmountViewModel + /// Path depth on the balance stack at the entry to the buy flow. Captured + /// on first appear so the buy flow can pop back to whatever pushed it + /// (typically `currencyInfo`) when the user completes or cancels, without + /// knowing how many sub-flow screens were pushed in between. + @State private var parentDepth: Int? @Environment(AppRouter.self) private var router @Environment(OnrampCoordinator.self) private var coordinator @@ -44,6 +49,10 @@ struct BuyAmountScreen: View { } .navigationTitle(viewModel.screenTitle) .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: BuyFlowPath.self) { path in + BuyFlowDestinationView(path: path) + } + .environment(\.dismissParentContainer, dismissBuyFlow) .dialog(item: $viewModel.dialogItem) .sheet(item: $viewModel.pendingMethodSelection) { context in PurchaseMethodSheet( @@ -64,5 +73,28 @@ struct BuyAmountScreen: View { )) coordinator.completion = nil } + .onAppear { + if parentDepth == nil { + // path.count includes this view's own push (we're already on + // the stack when onAppear fires). Subtract 1 to get the depth + // of the screen that triggered the buy — what we pop back to + // when the user finishes the flow. + parentDepth = max(0, router[.balance].count - 1) + } + } + } + + /// Pops every screen pushed since the buy flow started — `buyAmount` + /// itself plus any sub-flow (`phantomEducation`, `phantomConfirm`, + /// `usdcDepositEducation`, `usdcDepositAddress`, `processing`). Used by + /// `SwapProcessingScreen`'s OK button via `dismissParentContainer`. + private var dismissBuyFlow: () -> Void { + let depth = parentDepth + return { [router] in + guard let depth else { return } + let toPop = router[.balance].count - depth + guard toPop > 0 else { return } + router.popLast(toPop, on: .balance) + } } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index cae04857..52cee248 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -93,7 +93,7 @@ struct CurrencyInfoScreen: View { marketCapController: marketCapController, onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, onShowCurrencySelection: { isShowingCurrencySelection = true }, - onBuy: { router.present(.buy(mint)) }, + onBuy: { router.push(.buyAmount(mint)) }, onGive: { Analytics.buttonTapped(name: .give) router.push(.give(mint)) @@ -135,7 +135,7 @@ struct CurrencyInfoScreen: View { await viewModel.loadMintMetadata() if showBuyOnAppear { - router.present(.buy(mint)) + router.push(.buyAmount(mint)) } } .fullScreenCover(item: Bindable(walletConnection).processing) { processing in diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index fcdacfd0..47243bb4 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -381,29 +381,6 @@ private struct RoutedSheet: View { } } } - case .buy(let mint): - NavigationStack(path: $router[.buy]) { - // TODO: surface the currency name via the deeplink payload so - // "Purchasing X" copy stays accurate for users who don't yet - // hold the mint. Today the fallback "this currency" only fires - // for that edge case; in-app entries always have the balance - // row populated. - BuyAmountScreen( - mint: mint, - currencyName: sessionContainer.session.balance(for: mint)?.name ?? "this currency", - session: sessionContainer.session, - ratesController: sessionContainer.ratesController - ) - .appRouterDestinations(container: container, sessionContainer: sessionContainer) - .navigationDestination(for: BuyFlowPath.self) { path in - BuyFlowDestinationView(path: path) - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: router.dismissSheet) - } - } - } } } } diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift index 6b282590..6d0d236d 100644 --- a/FlipcashTests/Buy/BuyAmountViewModelTests.swift +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -72,7 +72,7 @@ struct BuyAmountViewModelTests { let container = try await Self.makeContainer(usdfQuarks: 50_000_000) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() - router.present(.buy(.usdf)) + router.present(.balance) viewModel.enteredAmount = "20" await viewModel.amountEnteredAction(router: router) @@ -94,7 +94,7 @@ struct BuyAmountViewModelTests { let container = try await Self.makeContainer(usdfQuarks: usdfQuarks) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() - router.present(.buy(.usdf)) + router.present(.balance) viewModel.enteredAmount = enteredAmount await viewModel.amountEnteredAction(router: router) @@ -102,7 +102,7 @@ struct BuyAmountViewModelTests { let context = try #require(viewModel.pendingMethodSelection) #expect(context.amount.nativeAmount.value > 0) // No push fired — picker is a local sheet, not a stack destination. - #expect(router[.buy].count == 0) + #expect(router[.balance].count == 0) } @Test("Pinned amount is carried into the PurchaseMethodContext") @@ -110,7 +110,7 @@ struct BuyAmountViewModelTests { let container = try await Self.makeContainer(usdfQuarks: 0) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() - router.present(.buy(.usdf)) + router.present(.balance) viewModel.enteredAmount = "10" await viewModel.amountEnteredAction(router: router) @@ -126,13 +126,13 @@ struct BuyAmountViewModelTests { let container = try await Self.makeContainer(usdfQuarks: 50_000_000) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() - router.present(.buy(.usdf)) + router.present(.balance) viewModel.enteredAmount = "" await viewModel.amountEnteredAction(router: router) #expect(viewModel.pendingMethodSelection == nil) - #expect(router[.buy].count == 0) + #expect(router[.balance].count == 0) #expect(viewModel.dialogItem == nil) // Loading flicker on an empty submit would be a regression. #expect(viewModel.actionButtonState == .normal) diff --git a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift index b32f63a1..547dd236 100644 --- a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift @@ -9,35 +9,32 @@ import Testing import FlipcashCore @testable import Flipcash -@Suite("AppRouter buy sheet") +@Suite("AppRouter buy push") @MainActor struct AppRouterBuySheetTests { - @Test("present(.buy(mint)) sets presentedSheet to .buy with the given mint") - func presentBuySheet() { + @Test("push(.buyAmount(mint)) appends to the balance stack") + func pushBuyAmount() { let router = AppRouter() let mint = PublicKey.usdf + router.present(.balance) - router.present(.buy(mint)) + router.push(.buyAmount(mint)) - #expect(router.presentedSheet == .buy(mint)) + #expect(router[.balance].count == 1) } - @Test("present(.buy) targets the .buy stack") - func buySheetTargetsBuyStack() { - let router = AppRouter() - let mint = PublicKey.usdf - - router.present(.buy(mint)) - - #expect(router.presentedSheet?.stack == .buy) + @Test(".buyAmount destination targets the balance stack") + func buyAmountTargetsBalanceStack() { + #expect(AppRouter.Destination.buyAmount(.usdf).owningStack == .balance) } - @Test("pushAny BuyFlowPath onto the .buy stack appends the value") + @Test("pushAny BuyFlowPath onto the balance stack appends the value") func pushAnyBuyFlowPath() { let router = AppRouter() let mint = PublicKey.usdf - router.present(.buy(mint)) + router.present(.balance) + router.push(.buyAmount(mint)) // Minimal ExchangedFiat with zero on-chain amount against a fixed // USD rate, just enough to satisfy BuyFlowPath.phantomEducation. @@ -48,6 +45,7 @@ struct AppRouterBuySheetTests { ) router.pushAny(BuyFlowPath.phantomEducation(mint: mint, amount: pinned)) - #expect(router[.buy].count == 1) + // buyAmount + phantomEducation + #expect(router[.balance].count == 2) } } diff --git a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift index ca8e1ac4..7abeb414 100644 --- a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift +++ b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift @@ -59,7 +59,7 @@ struct Regression_native_amount_mismatch { vm.enteredAmount = "1" let router = AppRouter() - router.present(.buy(.usdf)) + router.present(.balance) await vm.amountEnteredAction(router: router) let context = try #require(vm.pendingMethodSelection) @@ -167,7 +167,7 @@ struct Regression_native_amount_mismatch { vm.enteredAmount = "1" let router = AppRouter() - router.present(.buy(.usdf)) + router.present(.balance) await vm.amountEnteredAction(router: router) #expect(vm.pendingMethodSelection == nil) From a8091234272da6b9f28b250b1205885f2403154e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 15:47:46 -0400 Subject: [PATCH 19/33] fix: scope dismissParentContainer to the navigation destination Setting the env value on BuyAmountScreen's body didn't reach SwapProcessingScreen because SwiftUI navigation destinations resolve in a context separate from the source view's modifier chain. The established pattern (CurrencyCreationWizardScreen et al.) is to wrap the destination view with the env modifier inside the destination closure itself. --- Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index c2ee2e8c..b71edeaa 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -50,9 +50,12 @@ struct BuyAmountScreen: View { .navigationTitle(viewModel.screenTitle) .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: BuyFlowPath.self) { path in + // Env value must be set on the destination view itself — modifiers + // on the source view don't propagate to navigation destinations + // (they live in a separate SwiftUI context). BuyFlowDestinationView(path: path) + .environment(\.dismissParentContainer, dismissBuyFlow) } - .environment(\.dismissParentContainer, dismissBuyFlow) .dialog(item: $viewModel.dialogItem) .sheet(item: $viewModel.pendingMethodSelection) { context in PurchaseMethodSheet( From cf548b77d8106ab4f30abce575485ef99b19dff2 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 12 May 2026 16:12:17 -0400 Subject: [PATCH 20/33] fix: restore currency picker on buy amount screen The amount-first refactor consolidated CurrencyBuyAmountScreen and OnrampAmountScreen into BuyAmountScreen but dropped the currencySelectionAction passed to EnterAmountView. With a nil closure the picker button is disabled and the chevron is hidden, so taps on the currency code did nothing. Restore the local sheet pattern used in CurrencySellAmountScreen so users can change the entry region. --- Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index b71edeaa..500764d5 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -12,6 +12,7 @@ import FlipcashUI struct BuyAmountScreen: View { @State private var viewModel: BuyAmountViewModel + @State private var isShowingCurrencySelection: Bool = false /// Path depth on the balance stack at the entry to the buy flow. Captured /// on first appear so the buy flow can pop back to whatever pushed it /// (typically `currencyInfo`) when the user completes or cancels, without @@ -20,6 +21,7 @@ struct BuyAmountScreen: View { @Environment(AppRouter.self) private var router @Environment(OnrampCoordinator.self) private var coordinator + @Environment(RatesController.self) private var ratesController init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { self._viewModel = State(initialValue: BuyAmountViewModel( @@ -42,7 +44,8 @@ struct BuyAmountScreen: View { actionEnabled: { _ in viewModel.canPerformAction }, action: { Task { await viewModel.amountEnteredAction(router: router) } - } + }, + currencySelectionAction: showCurrencySelection ) .foregroundStyle(.textMain) .padding(20) @@ -66,6 +69,9 @@ struct BuyAmountScreen: View { .sheet(isPresented: $coordinator.isShowingVerificationFlow) { VerifyInfoScreen(onrampCoordinator: coordinator) } + .sheet(isPresented: $isShowingCurrencySelection) { + CurrencySelectionScreen(ratesController: ratesController) + } .onChange(of: coordinator.completion) { _, newValue in guard case .buyProcessing(let swapId, let currencyName, let amount) = newValue else { return } router.pushAny(BuyFlowPath.processing( @@ -87,6 +93,10 @@ struct BuyAmountScreen: View { } } + private func showCurrencySelection() { + isShowingCurrencySelection.toggle() + } + /// Pops every screen pushed since the buy flow started — `buyAmount` /// itself plus any sub-flow (`phantomEducation`, `phantomConfirm`, /// `usdcDepositEducation`, `usdcDepositAddress`, `processing`). Used by From 8c4b86b8f009b799c63b01461a5ca4b1eedbf91e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 09:33:18 -0400 Subject: [PATCH 21/33] feat: open buy flow as nested sheet on top of currency info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teach AppRouter to stack sheets so the buy flow opens as a sheet over CurrencyInfo instead of pushing onto the balance stack. presentedSheets becomes an ordered array (root + nested), with rootSheet for the app's root binding and presentedSheet for the topmost. New presentNested(_:) appends; present(_:) replaces the root (clearing any nested above it unless the root is unchanged, which pops the nested and keeps the path). dismissSheet() always pops topmost. Each top-level sheet's content applies the new .appRouterNestedSheet() modifier — SwiftUI can't stack sibling .sheet modifiers at the root, so nested sheets mount from inside the parent sheet's content tree. Depth flows through a \\.nestedSheetDepth environment value so the modifier recurses for arbitrary nesting. The nested binding's setter guards against SwiftUI's post-dismiss confirmation callback to avoid cascading into the parent. CurrencyInfoScreen.onBuy and showBuyOnAppear route through presentNested(.buy(mint)). The Phantom and Coinbase processing fullScreenCovers are removed — both completion paths now push BuyFlowPath.processing onto the buy stack uniformly. The dead isShowingAmountEntry sheet (legacy "Phantom direct buy" entry point) and the walletConnection.dialogItem binding (which was mounting a competing sheet under .buy) are removed; BuyAmountScreen forwards wallet dialogs through session.dialogItem so they render in DialogWindow at alert level without fighting the sheet stack. interactiveDismissDisabled gates the .buy sheet during external signing or Apple Pay processing. The .buyAmount(PublicKey) destination is removed; BuyAmountScreen is now the root of the .buy nested sheet. --- CLAUDE.md | 9 +- .../Deep Links/Wallet/WalletConnection.swift | 7 + .../Navigation/AppRouter+Destination.swift | 9 +- .../AppRouter+DestinationView.swift | 9 - .../Navigation/AppRouter+NestedSheet.swift | 113 +++++++ .../AppRouter+SheetPresentation.swift | 8 +- .../Core/Navigation/AppRouter+Stack.swift | 8 + Flipcash/Core/Navigation/AppRouter.swift | 207 ++++++++++--- .../Screens/Main/Buy/BuyAmountScreen.swift | 67 ++-- .../Currency Info/CurrencyInfoScreen.swift | 56 +--- Flipcash/Core/Screens/Main/ScanScreen.swift | 11 +- Flipcash/Extensions/EnvironmentValues.swift | 7 + .../Navigation/AppRouterBuySheetTests.swift | 28 +- .../AppRouterNestedSheetTests.swift | 292 ++++++++++++++++++ 14 files changed, 669 insertions(+), 162 deletions(-) create mode 100644 Flipcash/Core/Navigation/AppRouter+NestedSheet.swift create mode 100644 FlipcashTests/Navigation/AppRouterNestedSheetTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 65206ff4..b426920a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,13 +235,15 @@ let stream = service.openMessageStream(request, callOptions: .streaming) { respo 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`, `Discover`) 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`. +Top-level sheets (`Balance`, `Settings`, `Give`, `Discover`) 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`, `BuyFlowPath`) 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. +**Nested sheets.** `presentedSheets` is an ordered stack: `.first` is the root sheet (mounted at app root) and any entries above visually stack on top. Use `router.presentNested(.x(...))` to stack a sheet on top of the current top — required for "sheet over sheet" UX like buy-from-currency-info. SwiftUI requires nested sheets to be mounted from **inside** the parent sheet's content tree (sibling `.sheet` modifiers at the root can't stack), so each top-level sheet's content applies the `.appRouterNestedSheet(...)` modifier — that's the convention. New top-level sheets must remember to apply it; the modifier handles all nested levels via env-injected `nestedSheetDepth`. The buy flow is the only nested sheet today (`.buy(mint)`); sell/give/etc. are migrating opt-in. + +**Local interaction sheets stay local.** Transient pickers (currency selection, funding selection) belong on the screen that owns them as `.sheet(...)` / `.fullScreenCover(...)` modifiers — they're interactions, not navigation. Operation-bound modals (swap/launch processing covers) similarly belong locally, *unless* they're part of a router-managed sheet's flow — in that case prefer pushing onto the sheet's stack as a `BuyFlowPath.processing` (or similar) so the sheet's dismiss tears down the whole chain. **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. +**Sheet path lifecycle.** `dismissSheet` pops the topmost sheet and leaves its `NavigationPath` populated so the closing animation runs with current contents. The path is cleared on the next `present(_:)` or `presentNested(_:)` of that same sheet value, so re-opens land at root. Sheet swaps at root (`present(.different)` without an intervening `dismissSheet`) preserve the swapped-out root's path for swap-back; nested sheets above a swapped root are always dismissed (and their paths cleared on re-open). `present(.sameRoot)` while a nested sheet is up pops the nested and keeps the root path. 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. @@ -511,6 +513,7 @@ Navigation: - 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) +- Flipcash/Core/Navigation/AppRouter+NestedSheet.swift (nested sheet modifier + root views) Session & Auth: - Flipcash/Core/Session/Session.swift diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift index c4abc829..6829712c 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift @@ -63,6 +63,13 @@ public final class WalletConnection { } } + /// True while a signing request has been sent to the external wallet + /// (Phantom) but the user has not yet returned with a signed transaction. + /// Used by the buy nested sheet to block dismissal during the deeplink + /// round-trip, so the user can't accidentally close the flow and lose + /// the in-flight signature. + var isAwaitingExternalSwap: Bool { pendingSwap != nil } + private(set) var session: ConnectedWalletSession? var publicKey: FlipcashCore.PublicKey { diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 5eb0ad0f..3da48dcb 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -16,12 +16,11 @@ extension AppRouter { // Wallet flow case currencyInfo(PublicKey) - /// Same screen as `currencyInfo` but auto-pushes the buy amount entry - /// on appear. Modelled as a sibling case rather than an + /// Same screen as `currencyInfo` but auto-presents the buy nested + /// 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 buyAmount(PublicKey) case discoverCurrencies case currencyCreationSummary case currencyCreationWizard @@ -44,7 +43,7 @@ extension AppRouter { /// navigation uses this to know which sheet to present. var owningStack: Stack { switch self { - case .currencyInfo, .currencyInfoForDeposit, .buyAmount, .discoverCurrencies, + case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .transactionHistory, .give: return .balance @@ -64,7 +63,6 @@ extension AppRouter { switch self { case .currencyInfo: "currencyInfo" case .currencyInfoForDeposit: "currencyInfoForDeposit" - case .buyAmount: "buyAmount" case .discoverCurrencies: "discoverCurrencies" case .currencyCreationSummary: "currencyCreationSummary" case .currencyCreationWizard: "currencyCreationWizard" @@ -90,7 +88,6 @@ extension AppRouter { switch self { case .currencyInfo(let mint), .currencyInfoForDeposit(let mint), - .buyAmount(let mint), .transactionHistory(let mint), .give(let mint), .deposit(let mint): diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index c7690bec..75fe8f31 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -45,15 +45,6 @@ struct DestinationView: View { ) .id(mint) - case .buyAmount(let mint): - BuyAmountScreen( - mint: mint, - currencyName: sessionContainer.session.balance(for: mint)?.name ?? "this currency", - session: sessionContainer.session, - ratesController: sessionContainer.ratesController - ) - .id(mint) - case .discoverCurrencies: CurrencyDiscoveryScreen( container: container, diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift new file mode 100644 index 00000000..7a2c23dc --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -0,0 +1,113 @@ +// +// AppRouter+NestedSheet.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +extension View { + + /// Mounts the nested sheet (one level deeper than this view's depth) when + /// `AppRouter.presentedSheets` has one. Apply on every sheet's content + /// view tree so a nested sheet at that depth can render — SwiftUI requires + /// nested sheets to be presented from within the parent sheet's content, + /// not as siblings at the app root. + /// + /// Recursive: the mounted sheet's content re-applies this modifier with + /// `nestedSheetDepth + 1`, so level 3+ also works once `presentedSheets` + /// grows that deep. v1 only nests two-deep; the recursion is mechanical + /// and untested at higher levels. + func appRouterNestedSheet(container: Container, sessionContainer: SessionContainer) -> some View { + modifier(AppRouterNestedSheetModifier(container: container, sessionContainer: sessionContainer)) + } +} + +private struct AppRouterNestedSheetModifier: ViewModifier { + + let container: Container + let sessionContainer: SessionContainer + + @Environment(AppRouter.self) private var router + @Environment(\.nestedSheetDepth) private var depth + + func body(content: Content) -> some View { + // Each level binds to its own slot in `presentedSheets`. The setter + // forwards user-driven dismissal (swipe-down) to `dismissSheet`, but + // SwiftUI ALSO calls the setter with nil after a programmatic dismiss + // completes. Without the in-bounds guard the setter would re-enter + // `dismissSheet` and pop the parent sheet, cascading the dismissal. + let myDepth = depth + 1 + let binding = Binding( + get: { + guard router.presentedSheets.indices.contains(myDepth) else { return nil } + return router.presentedSheets[myDepth] + }, + set: { newValue in + guard newValue == nil else { return } + guard router.presentedSheets.indices.contains(myDepth) else { return } + router.dismissSheet() + } + ) + // `.environment` must wrap `.appRouterNestedSheet` so the recursive + // modifier reads the bumped depth. The opposite order means the inner + // modifier reads the parent's depth and mounts the same sheet again, + // producing an infinite duplicate-sheet stack. + return content.sheet(item: binding) { nested in + nestedSheetRootView(for: nested) + .appRouterNestedSheet(container: container, sessionContainer: sessionContainer) + .environment(\.nestedSheetDepth, depth + 1) + } + } + + @ViewBuilder + private func nestedSheetRootView(for sheet: AppRouter.SheetPresentation) -> some View { + switch sheet { + case .buy(let mint): + BuySheetRoot( + mint: mint, + container: container, + sessionContainer: sessionContainer + ) + + case .balance, .settings, .give, .discover: + // Root-only sheets — they shouldn't be presented as nested. If + // they ever are, we fall through to an empty view; the warning + // in `presentNested` logs the mistake. + EmptyView() + } + } +} + +/// Root view for the `.buy(mint)` nested sheet. Owns the `NavigationStack` +/// bound to `router[.buy]`. `BuyAmountScreen` registers the +/// `.navigationDestination(for: BuyFlowPath.self)` modifier itself, so +/// sub-screens push naturally on this stack. +private struct BuySheetRoot: View { + + let mint: PublicKey + let container: Container + let sessionContainer: SessionContainer + + @Environment(AppRouter.self) private var router + + var body: some View { + @Bindable var router = router + NavigationStack(path: $router[.buy]) { + BuyAmountScreen( + mint: mint, + currencyName: sessionContainer.session.balance(for: mint)?.name ?? "this currency", + session: sessionContainer.session, + ratesController: sessionContainer.ratesController + ) + .id(mint) + // Sub-flow screens (Phantom, USDC deposit, processing) call + // `dismissParentContainer` to close the whole `.buy` sheet on + // success. BuyAmountScreen itself dismisses via the same env value + // through its toolbar close button. + .environment(\.dismissParentContainer, { router.dismissSheet() }) + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 7e2fd31e..32c09550 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -10,13 +10,15 @@ import FlipcashCore extension AppRouter { - /// Identifies the top-level modal sheet currently overlaying `ScanScreen`. - /// One sheet at a time; switching sheets dismisses the previous. + /// Identifies a top-level modal sheet. The router can present multiple at + /// once — the bottom of the stack is the root sheet (overlays `ScanScreen`) + /// and any subsequent entries are nested sheets that visually stack on top. nonisolated enum SheetPresentation: Identifiable, Hashable, Sendable, CustomStringConvertible { case balance case settings case give case discover + case buy(PublicKey) var id: Self { self } @@ -29,6 +31,7 @@ extension AppRouter { case .settings: .settings case .give: .give case .discover: .discover + case .buy: .buy } } @@ -38,6 +41,7 @@ extension AppRouter { case .settings: "settings" case .give: "give" case .discover: "discover" + case .buy: "buy" } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift index 4a64355f..026daa57 100644 --- a/Flipcash/Core/Navigation/AppRouter+Stack.swift +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -17,15 +17,22 @@ extension AppRouter { case settings case give case discover + case buy /// The sheet a stack is presented in. Cross-stack navigation uses /// this to know which top-level modal to surface. + /// + /// `.buy` is excluded — its sheet carries a mint payload that can't be + /// synthesized from the stack alone. Buy is always entered via + /// `router.presentNested(.buy(mint))` directly, never via `navigate(to:)`. var sheet: SheetPresentation { switch self { case .balance: .balance case .settings: .settings case .give: .give case .discover: .discover + case .buy: + preconditionFailure("buy sheet must be presented via router.presentNested(.buy(mint)); not reachable via Stack.sheet") } } @@ -35,6 +42,7 @@ extension AppRouter { case .settings: "settings" case .give: "give" case .discover: "discover" + case .buy: "buy" } } } diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index e2754cd2..c9d90896 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -23,6 +23,13 @@ private let logger = Logger(label: "flipcash.router") /// This avoids nested `NavigationStack`s, which crash with /// `comparisonTypeMismatch` on push/pop/push cycles. /// +/// The router supports nested sheets: `presentedSheets` is an ordered stack +/// where the bottom entry is the root sheet (mounted at app root) and any +/// entries above visually stack on top via `.appRouterNestedSheet` modifiers +/// applied inside each parent sheet's content tree. SwiftUI requires nested +/// sheets to be presented from within the parent's view tree — they cannot +/// be siblings at the root. +/// /// 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. @@ -36,16 +43,26 @@ final class AppRouter { /// mutating bill state so it enters fresh on the revealed ScanScreen. static let dismissAnimationDuration: Duration = .milliseconds(400) - private(set) var presentedSheet: SheetPresentation? + /// Stack of presented sheets, bottom-first. `.first` is the root sheet + /// (mounted at app root); `.last` is topmost (visible). Empty when no + /// sheet is presented. + private(set) var presentedSheets: [SheetPresentation] = [] + + /// Topmost (currently visible) sheet, or nil if no sheet is presented. + var presentedSheet: SheetPresentation? { presentedSheets.last } + + /// Root sheet — the one mounted at the app root. Distinct from + /// `presentedSheet` when nested sheets are present. + var rootSheet: SheetPresentation? { presentedSheets.first } 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. + /// `present(_:)` or `presentNested(_:)` 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 (per-value for cases with payload). private var dismissedSheets: Set = [] init() {} @@ -60,7 +77,10 @@ final class AppRouter { // MARK: - Stack mutators - /// Pushes onto whatever stack is currently presented (`presentedSheet?.stack`). + /// Pushes onto whatever stack is topmost (`presentedSheet?.stack`). With + /// nested sheets, that's the nested sheet's stack — pushes always land on + /// the visible NavigationStack, never on a stack hidden underneath. + /// /// No-op with a warning if no sheet is presented — pushes onto a hidden /// stack would silently corrupt that stack's path until the user later /// presents that sheet. @@ -77,11 +97,11 @@ final class AppRouter { logger.info("Push", metadata: navigationMetadata(stack: stack, destination: destination)) } - /// Pushes any Hashable value onto the currently-presented 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. No-op with a warning if no - /// sheet is presented. + /// Pushes any Hashable value onto the topmost stack. Used by sub-flows + /// whose destination types live outside `AppRouter.Destination` (e.g., + /// `WithdrawNavigationPath`, `BuyFlowPath`), so a single stack can carry + /// mixed types without nesting `NavigationStack`s. No-op with a warning + /// if no sheet is presented. func pushAny(_ value: H) { guard let stack = presentedSheet?.stack else { logger.warning("Push (sub-flow) attempted with no sheet presented", metadata: [ @@ -150,69 +170,154 @@ final class AppRouter { // MARK: - Sheet mutators - /// Presents `sheet`. Idempotent: no-op if already presenting `sheet`. + /// Presents `sheet` as the root sheet. + /// + /// Semantics: + /// - Already at this exact state (`presentedSheets == [sheet]`) → idempotent. + /// - Same root with nested sheets above → pop the nested(s), keep root. + /// `present(.balance)` while `[.balance, .buy(mint)]` is up → `[.balance]`. + /// - Different root (with or without nested above) → dismiss everything, + /// present new root. /// - /// If `sheet` was previously dismissed (sits in `dismissedSheets`), its - /// stack path is cleared synchronously *before* the sheet mounts — so a - /// re-open lands at root. A sheet swap (presenting a different sheet + /// If the new root was previously dismissed (sits in `dismissedSheets`), + /// its stack path is cleared synchronously *before* the sheet mounts — so + /// a re-open lands at root. A sheet swap (presenting a different sheet /// without going through `dismissSheet` first) leaves both paths intact, /// preserving the original "swap-and-return" behaviour. - /// - /// Doing the clear here instead of inside `dismissSheet` avoids the - /// "push back, then dismiss" animation: dismissal lets the sheet's - /// snapshot slide off-screen with its current contents intact, and the - /// clear runs only when the user actively chooses to re-open. func present(_ sheet: SheetPresentation) { - guard sheet != presentedSheet else { return } - let previous = presentedSheet + if presentedSheets == [sheet] { return } + + let previousTop = presentedSheet + + if presentedSheets.first == sheet { + // Same root, nested above → pop nested(s), keep root path intact. + for nested in presentedSheets.dropFirst() { + dismissedSheets.insert(nested) + } + presentedSheets = [sheet] + logger.info("Presented sheet (popped nested above same root)", metadata: [ + "sheet": "\(sheet)", + "previousSheet": "\(previousTop.map(String.init(describing:)) ?? "")", + ]) + return + } + + // Different root: nested sheets above (if any) are dismissed — their + // paths clear on next re-open. The replaced root is left out of + // `dismissedSheets` so a future `present(_:)` of the old root restores + // its path (swap-back semantics). + for nested in presentedSheets.dropFirst() { + dismissedSheets.insert(nested) + } + if dismissedSheets.remove(sheet) != nil { paths[sheet.stack] = NavigationPath() } - presentedSheet = sheet + + presentedSheets = [sheet] logger.info("Presented sheet", metadata: [ "sheet": "\(sheet)", - "previousSheet": "\(previous.map(String.init(describing:)) ?? "")", + "previousSheet": "\(previousTop.map(String.init(describing:)) ?? "")", + ]) + } + + /// Appends `sheet` on top of the current top, stacking visually. Requires + /// at least one sheet already presented (no-op + warning otherwise — the + /// caller should `present(_:)` a root first, not promote-implicitly). + /// + /// Semantics: + /// - Stack empty → no-op + warning. + /// - Same sheet already on top → idempotent. + /// - Same case different payload on top (e.g., `.buy(A)` → `.buy(B)`) → + /// swap the top entry. The displaced value is added to `dismissedSheets` + /// so its path is cleared on next re-open. + /// - Otherwise → append. + /// + /// Path-clear-on-reopen applies identically to nested sheets: if the + /// presented sheet sits in `dismissedSheets`, its path is cleared + /// synchronously before mount. + func presentNested(_ sheet: SheetPresentation) { + guard !presentedSheets.isEmpty else { + logger.warning("presentNested attempted with no sheet presented; call present(_:) first", metadata: [ + "sheet": "\(sheet)", + ]) + return + } + + if presentedSheets.last == sheet { return } + + if let top = presentedSheets.last, + top != sheet, + top.description == sheet.description { + // Same case kind on top with a different payload — swap. + dismissedSheets.insert(top) + if dismissedSheets.remove(sheet) != nil { + paths[sheet.stack] = NavigationPath() + } + presentedSheets[presentedSheets.count - 1] = sheet + logger.info("Presented nested sheet (swapped same-case top)", metadata: [ + "sheet": "\(sheet)", + "displaced": "\(top)", + ]) + return + } + + if dismissedSheets.remove(sheet) != nil { + paths[sheet.stack] = NavigationPath() + } + + presentedSheets.append(sheet) + logger.info("Presented nested sheet", metadata: [ + "sheet": "\(sheet)", + "depth": "\(presentedSheets.count)", ]) } - /// 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 + /// Dismisses the topmost sheet. If only the root remains, this dismisses + /// the root (same as the pre-nested behaviour). With nested sheets, only + /// the topmost is popped — the root stays presented. + /// + /// The dismissed sheet is marked "explicitly closed" so the next + /// presentation of the same value 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 + guard let dismissing = presentedSheets.popLast() else { return } dismissedSheets.insert(dismissing) - logger.info("Dismissed sheet", metadata: ["sheet": "\(dismissing)"]) + logger.info("Dismissed sheet", metadata: [ + "sheet": "\(dismissing)", + "remainingDepth": "\(presentedSheets.count)", + ]) } - /// Global navigation reset: dismisses the active sheet (if any) and - /// clears every stack's `NavigationPath`. The user lands on the - /// Scanner — the unconditional root rendered behind all sheets. + /// Global navigation reset: dismisses every presented sheet and clears + /// every stack's `NavigationPath`. The user lands on the Scanner — the + /// unconditional root rendered behind all sheets. /// /// Distinct from ``popToRoot(on:)``, which is a per-stack pop. This - /// method is the all-stacks + sheet variant, used by the + /// method is the all-sheets + all-stacks variant, used by the /// auto-return-on-background trigger when the user has been away long /// enough that any in-flight navigation should be discarded. /// - /// The active sheet's slide-down animation runs with its current contents - /// — the same behaviour as ``dismissSheet()``. Inactive stacks have no - /// on-screen UI so their paths are cleared synchronously. + /// The dismissing root sheet's slide-down animation runs with its current + /// contents — the same behaviour as ``dismissSheet()``. Inactive stacks + /// have no on-screen UI so their paths are cleared synchronously. func dismissAll() { - let dismissingSheet = presentedSheet - presentedSheet = nil - if let dismissingSheet { - dismissedSheets.insert(dismissingSheet) + let dismissing = presentedSheets + let dismissingRoot = presentedSheets.first + presentedSheets = [] + for sheet in dismissing { + dismissedSheets.insert(sheet) } - // Clear every other stack's path now. The dismissing stack keeps its - // path through the slide-off animation; the existing dismissedSheets - // mechanism clears it on the next present(_:) for that sheet. - for stack in Stack.allCases where stack != dismissingSheet?.stack { + // Clear every stack's path except the dismissing root's — that stack + // keeps its path through the slide-off animation, cleared on next + // present of that root via the dismissedSheets mechanism. + for stack in Stack.allCases where stack != dismissingRoot?.stack { paths[stack] = NavigationPath() } logger.info("Dismiss all", metadata: [ - "dismissedSheet": "\(dismissingSheet.map(String.init(describing:)) ?? "")", + "dismissedSheets": "\(dismissing.map(String.init(describing:)))", ]) } @@ -235,9 +340,10 @@ final class AppRouter { // 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. + /// Cross-stack navigation. Presents the destination's `owningStack` as the + /// root sheet (dismissing any nested sheets above) 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 @@ -253,10 +359,11 @@ final class AppRouter { var expected = NavigationPath() expected.append(destination) - let alreadyThere = presentedSheet == targetSheet + let alreadyThere = presentedSheets == [targetSheet] && paths[targetStack, default: NavigationPath()] == expected guard !alreadyThere else { return } + // present(_:) handles dismiss-nested-then-swap-root semantics. present(targetSheet) setPath([destination], on: targetStack) } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index 500764d5..58677aed 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -13,15 +13,12 @@ struct BuyAmountScreen: View { @State private var viewModel: BuyAmountViewModel @State private var isShowingCurrencySelection: Bool = false - /// Path depth on the balance stack at the entry to the buy flow. Captured - /// on first appear so the buy flow can pop back to whatever pushed it - /// (typically `currencyInfo`) when the user completes or cancels, without - /// knowing how many sub-flow screens were pushed in between. - @State private var parentDepth: Int? @Environment(AppRouter.self) private var router @Environment(OnrampCoordinator.self) private var coordinator @Environment(RatesController.self) private var ratesController + @Environment(WalletConnection.self) private var walletConnection + @Environment(Session.self) private var session init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { self._viewModel = State(initialValue: BuyAmountViewModel( @@ -32,6 +29,10 @@ struct BuyAmountScreen: View { )) } + private var isDismissBlocked: Bool { + coordinator.isProcessingPayment || walletConnection.isAwaitingExternalSwap + } + var body: some View { @Bindable var viewModel = viewModel @Bindable var coordinator = coordinator @@ -52,12 +53,29 @@ struct BuyAmountScreen: View { } .navigationTitle(viewModel.screenTitle) .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !isDismissBlocked { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(action: router.dismissSheet) + } + } + } + .interactiveDismissDisabled(isDismissBlocked) .navigationDestination(for: BuyFlowPath.self) { path in // Env value must be set on the destination view itself — modifiers // on the source view don't propagate to navigation destinations // (they live in a separate SwiftUI context). + // + // SwapProcessingScreen at the leaf observes + // `walletConnection.isProcessingCancelled` for failure flips, so + // clearing the wallet state happens on OK (here), not at push + // time. For Coinbase and direct-USDF paths walletConnection state + // is already idle, so `dismissProcessing()` is a no-op. BuyFlowDestinationView(path: path) - .environment(\.dismissParentContainer, dismissBuyFlow) + .environment(\.dismissParentContainer, { + walletConnection.dismissProcessing() + router.dismissSheet() + }) } .dialog(item: $viewModel.dialogItem) .sheet(item: $viewModel.pendingMethodSelection) { context in @@ -82,32 +100,25 @@ struct BuyAmountScreen: View { )) coordinator.completion = nil } - .onAppear { - if parentDepth == nil { - // path.count includes this view's own push (we're already on - // the stack when onAppear fires). Subtract 1 to get the depth - // of the screen that triggered the buy — what we pop back to - // when the user finishes the flow. - parentDepth = max(0, router[.balance].count - 1) - } + // Forward wallet-connection dialogs (Phantom cancel during signing, + // simulate failures, etc.) to `session.dialogItem` so they render in + // `DialogWindow` at alert level instead of trying to mount a sheet + // on `CurrencyInfoScreen` — which fights the `.buy` sheet's + // presentation queue and dismisses it. + // `DialogItem` isn't Equatable, so observe the id (UUID) and read the + // current value in the handler. + .onChange(of: walletConnection.dialogItem?.id) { _, newId in + guard newId != nil, let dialog = walletConnection.dialogItem else { return } + session.dialogItem = dialog + walletConnection.dialogItem = nil } + // The Phantom processing push is owned by `PhantomConfirmScreen` — + // observing `walletConnection.processing` here too would double-push + // when both screens are alive (PhantomConfirm still on top during the + // state transition). } private func showCurrencySelection() { isShowingCurrencySelection.toggle() } - - /// Pops every screen pushed since the buy flow started — `buyAmount` - /// itself plus any sub-flow (`phantomEducation`, `phantomConfirm`, - /// `usdcDepositEducation`, `usdcDepositAddress`, `processing`). Used by - /// `SwapProcessingScreen`'s OK button via `dismissParentContainer`. - private var dismissBuyFlow: () -> Void { - let depth = parentDepth - return { [router] in - guard let depth else { return } - let toPop = router[.balance].count - depth - guard toPop > 0 else { return } - router.popLast(toPop, on: .balance) - } - } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 52cee248..938ea9b8 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -29,7 +29,6 @@ struct CurrencyInfoScreen: View { } @Environment(WalletConnection.self) private var walletConnection - @Environment(OnrampCoordinator.self) private var onrampCoordinator private let mint: PublicKey private let container: Container @@ -93,7 +92,7 @@ struct CurrencyInfoScreen: View { marketCapController: marketCapController, onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, onShowCurrencySelection: { isShowingCurrencySelection = true }, - onBuy: { router.push(.buyAmount(mint)) }, + onBuy: { router.presentNested(.buy(mint)) }, onGive: { Analytics.buttonTapped(name: .give) router.push(.give(mint)) @@ -135,52 +134,7 @@ struct CurrencyInfoScreen: View { await viewModel.loadMintMetadata() if showBuyOnAppear { - router.push(.buyAmount(mint)) - } - } - .fullScreenCover(item: Bindable(walletConnection).processing) { processing in - NavigationStack { - SwapProcessingScreen( - swapId: processing.swapId, - swapType: .buyWithPhantom, - currencyName: processing.currencyName, - amount: processing.amount - ) - .environment(\.dismissParentContainer, { - walletConnection.dismissProcessing() - }) - } - } - .fullScreenCover(item: onrampCoordinator.buyCompletionBinding) { completion in - if case .buyProcessing(let swapId, let name, let amount) = completion { - NavigationStack { - SwapProcessingScreen( - swapId: swapId, - swapType: .buyWithCoinbase, - currencyName: name, - amount: amount - ) - .environment(\.dismissParentContainer, { - onrampCoordinator.completion = nil - }) - } - } - } - .sheet(isPresented: Bindable(walletConnection).isShowingAmountEntry) { - if let metadata = viewModel.mintMetadata { - NavigationStack { - EnterWalletAmountScreen { quarks in - try await walletConnection.requestSwap( - usdc: quarks, - token: metadata.metadata - ) - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton { walletConnection.isShowingAmountEntry = false } - } - } - } + router.presentNested(.buy(mint)) } } .sheet(item: $presentedSellViewModel) { sellViewModel in @@ -189,7 +143,11 @@ struct CurrencyInfoScreen: View { .sheet(isPresented: $isShowingCurrencySelection) { CurrencySelectionScreen(ratesController: ratesController) } - .dialog(item: Bindable(walletConnection).dialogItem) + // `walletConnection.dialogItem` is forwarded to `session.dialogItem` + // from inside the `.buy` nested sheet (see BuyAmountScreen) so it + // surfaces in `DialogWindow` rather than fighting the sheet stack + // here. Binding `.dialog(item:)` on this screen would mount a sheet + // that competes with the `.buy` sheet's presentation queue. } @ViewBuilder private func toolbarContent() -> some View { diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 47243bb4..7eb2cc8a 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -139,8 +139,10 @@ struct ScanScreen: View { // 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. + // Bound to `rootSheet` (bottom of the sheet stack) — nested sheets mount + // inside this root sheet's content via `.appRouterNestedSheet`. .sheet(item: Binding( - get: { router.presentedSheet }, + get: { router.rootSheet }, set: { newValue in if newValue == nil { router.dismissSheet() @@ -152,6 +154,7 @@ struct ScanScreen: View { container: container, sessionContainer: sessionContainer ) + .appRouterNestedSheet(container: container, sessionContainer: sessionContainer) } // Dismiss all presented sheets when a bill is about to appear. // Bills render in ScanScreen's ZStack, so any sheet on top @@ -381,6 +384,12 @@ private struct RoutedSheet: View { } } } + case .buy: + // `.buy` is a nested-only sheet — it should never be presented at + // root. `presentNested(.buy(mint))` is the intended entry point. + // Rendering EmptyView is a defensive no-op; the misuse is already + // logged by the router when the stack is empty. + EmptyView() } } } diff --git a/Flipcash/Extensions/EnvironmentValues.swift b/Flipcash/Extensions/EnvironmentValues.swift index e4d00496..cbf77f30 100644 --- a/Flipcash/Extensions/EnvironmentValues.swift +++ b/Flipcash/Extensions/EnvironmentValues.swift @@ -48,4 +48,11 @@ extension EnvironmentValues { /// In this example, `ChildView` dismisses `ParentView`, even though it is /// not the currently presented view. @Entry var dismissParentContainer: () -> Void = {} + + /// Depth of the current sheet in `AppRouter.presentedSheets`. 0 = root sheet + /// (mounted at app root), 1 = first nested, etc. Driven by + /// `.appRouterNestedSheet` so each level can read its own depth without + /// the caller having to pass it explicitly. Default 0 is safe for any + /// view not inside a router-driven sheet. + @Entry var nestedSheetDepth: Int = 0 } diff --git a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift index 547dd236..3ac07f21 100644 --- a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift @@ -9,35 +9,35 @@ import Testing import FlipcashCore @testable import Flipcash -@Suite("AppRouter buy push") +@Suite("AppRouter buy nested sheet") @MainActor struct AppRouterBuySheetTests { - @Test("push(.buyAmount(mint)) appends to the balance stack") - func pushBuyAmount() { + @Test("presentNested(.buy(mint)) stacks on .balance") + func presentNestedBuyOnBalance() { let router = AppRouter() let mint = PublicKey.usdf router.present(.balance) - router.push(.buyAmount(mint)) + router.presentNested(.buy(mint)) - #expect(router[.balance].count == 1) + #expect(router.presentedSheets == [.balance, .buy(mint)]) + #expect(router.rootSheet == .balance) + #expect(router.presentedSheet == .buy(mint)) } - @Test(".buyAmount destination targets the balance stack") - func buyAmountTargetsBalanceStack() { - #expect(AppRouter.Destination.buyAmount(.usdf).owningStack == .balance) + @Test(".buy SheetPresentation maps to .buy stack") + func buySheetMapsToBuyStack() { + #expect(AppRouter.SheetPresentation.buy(.usdf).stack == .buy) } - @Test("pushAny BuyFlowPath onto the balance stack appends the value") + @Test("pushAny BuyFlowPath onto the .buy stack appends the value") func pushAnyBuyFlowPath() { let router = AppRouter() let mint = PublicKey.usdf router.present(.balance) - router.push(.buyAmount(mint)) + router.presentNested(.buy(mint)) - // Minimal ExchangedFiat with zero on-chain amount against a fixed - // USD rate, just enough to satisfy BuyFlowPath.phantomEducation. let pinned = ExchangedFiat.compute( onChainAmount: .zero(mint: .usdf), rate: .oneToOne, @@ -45,7 +45,7 @@ struct AppRouterBuySheetTests { ) router.pushAny(BuyFlowPath.phantomEducation(mint: mint, amount: pinned)) - // buyAmount + phantomEducation - #expect(router[.balance].count == 2) + #expect(router[.buy].count == 1, "pushes target the topmost sheet's stack") + #expect(router[.balance].isEmpty, "balance stack untouched") } } diff --git a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift new file mode 100644 index 00000000..e8d8fe2a --- /dev/null +++ b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift @@ -0,0 +1,292 @@ +// +// AppRouterNestedSheetTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("AppRouter Nested Sheets") +struct AppRouterNestedSheetTests { + + private static let mintA: PublicKey = .usdc + private static let mintB: PublicKey = .usdf + + // MARK: - State model + + @Test("presentedSheets starts empty") + func presentedSheets_startsEmpty() { + let router = AppRouter() + #expect(router.presentedSheets.isEmpty) + #expect(router.presentedSheet == nil) + #expect(router.rootSheet == nil) + } + + @Test("present sets root, topmost == root when no nesting") + func present_setsRoot() { + let router = AppRouter() + router.present(.balance) + #expect(router.presentedSheets == [.balance]) + #expect(router.presentedSheet == .balance) + #expect(router.rootSheet == .balance) + } + + @Test("presentNested appends on top of root") + func presentNested_appendsOnRoot() { + let router = AppRouter() + router.present(.balance) + + router.presentNested(.buy(Self.mintA)) + + #expect(router.presentedSheets == [.balance, .buy(Self.mintA)]) + #expect(router.presentedSheet == .buy(Self.mintA)) + #expect(router.rootSheet == .balance) + } + + @Test("presentNested with empty stack is a no-op") + func presentNested_onEmpty_isNoop() { + let router = AppRouter() + router.presentNested(.buy(Self.mintA)) + #expect(router.presentedSheets.isEmpty) + } + + @Test("presentNested idempotent when same sheet already on top") + func presentNested_idempotent_onSameTop() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.presentNested(.buy(Self.mintA)) + + #expect(router.presentedSheets == [.balance, .buy(Self.mintA)]) + } + + @Test("presentNested same case different payload swaps the top") + func presentNested_sameCaseDifferentPayload_swaps() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + router.pushAny(BuyFlowPath.phantomEducation( + mint: Self.mintA, + amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) + )) + + router.presentNested(.buy(Self.mintB)) + + // Same case different payload → swap (not stack). + #expect(router.presentedSheets == [.balance, .buy(Self.mintB)]) + // Displaced sheet's path is cleared because it landed in dismissedSheets + // and the new value sits in a different SheetPresentation hash bucket. + // Either way, the new top should be at root of the buy stack via + // the dismissed-path-clear contract (verified separately). + } + + // MARK: - dismissSheet + + @Test("dismissSheet pops topmost when nested is up") + func dismissSheet_withNested_popsTopmost() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.dismissSheet() + + #expect(router.presentedSheets == [.balance]) + #expect(router.presentedSheet == .balance) + } + + @Test("dismissSheet pops root when only root remains") + func dismissSheet_onRootOnly_clearsAll() { + let router = AppRouter() + router.present(.balance) + + router.dismissSheet() + + #expect(router.presentedSheets.isEmpty) + #expect(router.presentedSheet == nil) + } + + @Test("dismissSheet sequence pops one level at a time") + func dismissSheet_sequence_popsLevels() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.dismissSheet() + #expect(router.presentedSheets == [.balance]) + + router.dismissSheet() + #expect(router.presentedSheets.isEmpty) + } + + // MARK: - present semantics with nested up + + @Test("present(.differentRoot) when nested is up clears everything and sets new root") + func present_differentRoot_clearsAll() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.present(.settings) + + #expect(router.presentedSheets == [.settings]) + } + + @Test("present(.sameRoot) when nested is up pops the nested and keeps root") + func present_sameRoot_popsNestedKeepsRoot() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.presentNested(.buy(Self.mintA)) + + router.present(.balance) + + #expect(router.presentedSheets == [.balance]) + // Root path is preserved because root wasn't dismissed. + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(Self.mintA))) + } + + @Test("present(.differentRoot) when nested is up clears the new root's stale path") + func present_differentRoot_clearsNewRootPath() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.dismissSheet() // .balance now in dismissedSheets + + router.present(.settings) + router.presentNested(.buy(Self.mintA)) + + // Now re-present .balance — should clear its stale path. + router.present(.balance) + + #expect(router.presentedSheets == [.balance]) + #expect(router[.balance].isEmpty, + "presenting a previously dismissed root after nesting still clears its path") + } + + // MARK: - Path clear on reopen at nested level + + @Test("dismissSheet + presentNested(.same) clears the nested sheet's path") + func dismissNested_thenPresentNestedSame_clearsPath() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + router.pushAny(BuyFlowPath.phantomEducation( + mint: Self.mintA, + amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) + )) + + router.dismissSheet() // Pop .buy(mintA); its path is still populated. + router.presentNested(.buy(Self.mintA)) + + #expect(router[.buy].isEmpty, + "re-opening a dismissed nested sheet must land at root") + } + + @Test("nested swipe-down + reopen still clears path") + func dismissNested_thenReopenAfterIntermediate_stillClearsPath() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + router.pushAny(BuyFlowPath.phantomEducation( + mint: Self.mintA, + amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) + )) + router.dismissSheet() // .buy dismissed + router.dismissSheet() // .balance dismissed + + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + #expect(router[.buy].isEmpty) + } + + // MARK: - dismissAll + + @Test("dismissAll clears every level") + func dismissAll_clearsEveryLevel() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.dismissAll() + + #expect(router.presentedSheets.isEmpty) + #expect(router.presentedSheet == nil) + } + + @Test("dismissAll marks every dismissed sheet for path clear on reopen") + func dismissAll_clearsPathsOnReopen() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.presentNested(.buy(Self.mintA)) + router.pushAny(BuyFlowPath.phantomEducation( + mint: Self.mintA, + amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) + )) + + router.dismissAll() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + #expect(router[.balance].isEmpty) + #expect(router[.buy].isEmpty) + } + + // MARK: - navigate with nested up + + @Test("navigate(to:) when nested is up dismisses nested and sets target root") + func navigate_dismissesNestedAndSetsRoot() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.navigate(to: .settingsApplicationLogs) + + #expect(router.presentedSheets == [.settings]) + #expect(router[.settings] == AppRouter.navigationPath(.settingsApplicationLogs)) + } + + @Test("navigate(to:) on same-root destination while nested is up pops nested") + func navigate_sameRoot_popsNested() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.presentNested(.buy(Self.mintA)) + + router.navigate(to: .currencyInfo(Self.mintB)) + + #expect(router.presentedSheets == [.balance]) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(Self.mintB))) + } + + // MARK: - Push lands on topmost + + @Test("push lands on the nested sheet's stack when nested is up") + func push_landsOnNestedStack() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.pushAny(BuyFlowPath.phantomEducation( + mint: Self.mintA, + amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) + )) + + #expect(router[.buy].count == 1, "pushes target the topmost stack") + #expect(router[.balance].isEmpty, "root stack stays clean") + } + + // MARK: - Buy sheet wiring + + @Test(".buy(mint) sheet maps to .buy stack") + func buySheet_mapsToBuyStack() { + #expect(AppRouter.SheetPresentation.buy(Self.mintA).stack == .buy) + } +} From 564b5bbf2d0936dfb4282c12645be1fb676328b2 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 09:33:28 -0400 Subject: [PATCH 22/33] fix: phantom buy flow polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions surfaced once the buy flow became a nested sheet: PurchaseMethodSheet now checks walletConnection.isConnected and pushes phantomConfirm directly when a session already exists, skipping the education screen. Previously a connected user popping back from confirm would land on education's "Connect Your Phantom Wallet" CTA because the auto-advance latch was tripped. PhantomEducationScreen switches to the async walletConnection.connect() wrapper. The non-async connectToPhantom() set pendingConnect = nil in didConnect's legacy branch, which then set walletConnection.isShowingAmountEntry = true — triggering a stale EnterWalletAmountScreen sheet that competed with the .buy sheet. With connect() the pendingConnect continuation handles success/cancel explicitly: CancellationError lands silently back on this screen, non-cancel failures surface a session.dialogItem. PhantomConfirmScreen also routes its swap-request error dialog through session.dialogItem. A local .dialog(item:) is .sheet(item:) under the hood and would tear down the .buy sheet to mount itself. --- .../Main/Buy/PhantomConfirmScreen.swift | 4 +- .../Main/Buy/PhantomEducationScreen.swift | 60 +++++++++++++------ .../Main/Buy/PurchaseMethodSheet.swift | 12 +++- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index f4bec6ab..9029f79f 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -28,7 +28,6 @@ struct PhantomConfirmScreen: View { @Environment(WalletConnection.self) private var walletConnection @Environment(Session.self) private var session - @State private var dialogItem: DialogItem? @State private var confirmTask: Task? var body: some View { @@ -57,7 +56,6 @@ struct PhantomConfirmScreen: View { } .navigationTitle("Confirmation") .navigationBarTitleDisplayMode(.inline) - .dialog(item: $dialogItem) .onDisappear { // Cancel any in-flight swap request if the user backs out before // Phantom returns. Without this, requestSwap's deeplink-out and @@ -105,7 +103,7 @@ struct PhantomConfirmScreen: View { reason: "Failed to request Phantom swap from PhantomConfirmScreen", metadata: ["mint": mint.base58] ) - dialogItem = .somethingWentWrong + session.dialogItem = .somethingWentWrong } } } diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift index d3a41b89..2b31c1cd 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift @@ -9,12 +9,13 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// "Buy With Phantom" pre-flight: explains the swap, then triggers -/// `WalletConnection.connectToPhantom()`. When Phantom returns and the -/// underlying `session` becomes non-nil, the `.onChange` observer pushes -/// `BuyFlowPath.phantomConfirm` automatically. `initial: true` also handles -/// the case where the user lands on this screen already connected (a prior -/// Phantom session persists in the keychain across launches). +private let logger = Logger(label: "flipcash.phantom-education") + +/// "Buy With Phantom" pre-flight: explains the swap, then triggers an async +/// `WalletConnection.connect()` (the wrapper that sets `pendingConnect` so +/// the legacy `isShowingAmountEntry` side-effect on the wallet controller is +/// suppressed). On success, push `phantomConfirm`. User-cancel in Phantom +/// throws `CancellationError` and lands silently back on this screen. struct PhantomEducationScreen: View { let mint: PublicKey @@ -22,11 +23,9 @@ struct PhantomEducationScreen: View { @Environment(AppRouter.self) private var router @Environment(WalletConnection.self) private var walletConnection + @Environment(Session.self) private var session - /// Single-shot advance latch. Without this, popping back from - /// `phantomConfirm` re-fires the auto-push (the user is still connected) - /// and the back button effectively does nothing. - @State private var didAutoAdvance = false + @State private var connectTask: Task? var body: some View { Background(color: .backgroundMain) { @@ -47,19 +46,46 @@ struct PhantomEducationScreen: View { } .safeAreaInset(edge: .bottom) { BubbleButton(text: "Connect Your Phantom Wallet") { - walletConnection.connectToPhantom() + connect() } .padding() } } .navigationTitle("Purchase") .navigationBarTitleDisplayMode(.inline) - .onChange(of: walletConnection.session != nil, initial: true) { _, isConnected in - // Auto-advance when Phantom returns from the connect deeplink, or - // immediately on appear if a prior session already exists. - guard isConnected, !didAutoAdvance else { return } - didAutoAdvance = true - router.pushAny(BuyFlowPath.phantomConfirm(mint: mint, amount: amount)) + .onDisappear { + connectTask?.cancel() + connectTask = nil + } + } + + private func connect() { + connectTask?.cancel() + connectTask = Task { + // If a prior session already exists, `connect()` returns + // immediately without deeplinking. Either way, advance. + do { + try await walletConnection.connect() + try Task.checkCancellation() + router.pushAny(BuyFlowPath.phantomConfirm(mint: mint, amount: amount)) + } catch is CancellationError { + return + } catch { + logger.error("Failed to connect to Phantom", metadata: [ + "error": "\(error)", + ]) + // Route through `session.dialogItem` so the alert renders in + // the dedicated DialogWindow at alert level — a local + // `.dialog(item:)` is a sheet under the hood and would + // conflict with the `.buy` nested sheet's presentation queue + // (tearing this screen down on present). + session.dialogItem = .init( + style: .destructive, + title: "Couldn't Connect", + subtitle: "We couldn't connect to your Phantom wallet. Please try again.", + dismissable: true + ) { .okay(kind: .destructive) } + } } } } diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index 2391b1eb..25dc84d0 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -121,13 +121,23 @@ private struct PhantomMethodButton: View { let onDismiss: () -> Void let router: AppRouter + @Environment(WalletConnection.self) private var walletConnection + var body: some View { Button { Analytics.buttonTapped(name: .buyWithPhantom) onDismiss() + // Skip the education screen when a Phantom session already exists + // — the user has connected before and just needs to confirm. The + // education screen's auto-advance latch breaks on pop-back from + // confirm, which would otherwise surface a stale "Connect Your + // Phantom Wallet" CTA to an already-connected user. + let nextStep: BuyFlowPath = walletConnection.isConnected + ? .phantomConfirm(mint: context.mint, amount: context.amount) + : .phantomEducation(mint: context.mint, amount: context.amount) Task { try? await Task.sleep(for: AppRouter.dismissAnimationDuration) - router.pushAny(BuyFlowPath.phantomEducation(mint: context.mint, amount: context.amount)) + router.pushAny(nextStep) } } label: { HStack(spacing: 4) { From 177291a9e998026f6e7035a49f5c7c6a86251b3f Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 10:26:27 -0400 Subject: [PATCH 23/33] refactor: extract BadgedIcon, redesign Phantom screens, share assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buy/withdraw/deposit hero icons get a reusable component. `BadgedIcon` in FlipcashUI takes a base `Image` plus an optional badge `Image` and anchors the badge to the bottom-trailing corner — replaces both the buy-specific PhantomUSDCHero (deleted) and the baked-in `solanaUSDC` asset for the withdraw/deposit hero graphics. A new `buy/` namespace under FlipcashUI's asset catalog hosts the hero artwork (Phantom, USDC, Solana, Flipcash, Checkmark) with type-safe enum cases on `Asset` so call sites use `Image.asset(.buyPhantom)` etc. rather than raw strings. Phantom screens redesigned to match the agreed mockups: - `PhantomEducationScreen`: dual icon (Phantom + plus + USDC with Solana badge), `Spacer`-bracketed layout, full-width `Button.buttonStyle(.filled)` "Connect Your Phantom Wallet" CTA, Apple-HIG layout matching `USDCDepositEducationScreen`. - `PhantomConfirmScreen`: single badged Phantom (with Checkmark badge), filled CTA reading "Confirm in your [phantom] Phantom". The inline phantom icon uses `.renderingMode(.template)` so the filled button's text color tints it (without it the original PDF renders white on white). `USDCDepositEducationScreen` and `WithdrawIntroScreen` adopt the same pattern so the dual-icon-with-badge composition lives in one place. --- .../Main/Buy/Components/PhantomUSDCHero.swift | 37 -------------- .../Main/Buy/PhantomConfirmScreen.swift | 41 +++++++++++----- .../Main/Buy/PhantomEducationScreen.swift | 46 ++++++++++++----- .../Main/Buy/USDCDepositEducationScreen.swift | 18 +++---- .../Withdraw/WithdrawIntroScreen.swift | 20 +++----- .../buy/Checkmark.imageset/Checkmark.svg | 4 ++ .../buy/Checkmark.imageset/Contents.json | 15 ++++++ .../Assets/UI.xcassets/buy/Contents.json | 9 ++++ .../buy/Flipcash.imageset/Contents.json | 15 ++++++ .../buy/Flipcash.imageset/Flipcash.svg | 6 +++ .../buy/Phantom.imageset/Contents.json | 15 ++++++ .../buy/Phantom.imageset/Phantom.svg | 4 ++ .../buy/Solana.imageset/Contents.json | 15 ++++++ .../buy/Solana.imageset/Solana.svg | 6 +++ .../buy/USDC.imageset/Contents.json | 15 ++++++ .../UI.xcassets/buy/USDC.imageset/USDC.svg | 4 ++ .../FlipcashUI/Theme/Image+Symbols.swift | 9 ++++ .../Sources/FlipcashUI/Views/BadgedIcon.swift | 49 +++++++++++++++++++ 18 files changed, 244 insertions(+), 84 deletions(-) delete mode 100644 Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Checkmark.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Flipcash.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Phantom.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Solana.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/USDC.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift diff --git a/Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift b/Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift deleted file mode 100644 index 551e72ce..00000000 --- a/Flipcash/Core/Screens/Main/Buy/Components/PhantomUSDCHero.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// PhantomUSDCHero.swift -// Flipcash -// -// Created by Raul Riera on 2026-05-12. -// - -import SwiftUI -import FlipcashUI - -/// Dual-logo hero used by both `PhantomEducationScreen` ("disconnected" state) -/// and `PhantomConfirmScreen` ("connected" state, with checkmark badge). -struct PhantomUSDCHero: View { - - let connected: Bool - - var body: some View { - HStack(spacing: -8) { - Image.asset(.phantom) - .resizable() - .frame(width: 80, height: 80) - .clipShape(Circle()) - .overlay(alignment: .bottomTrailing) { - if connected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 24)) - .symbolRenderingMode(.palette) - .foregroundStyle(Color.white, Color.green) - } - } - Image.asset(.solanaUSDC) - .resizable() - .frame(width: 80, height: 80) - .clipShape(Circle()) - } - } -} diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index 9029f79f..8d8ec079 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -32,27 +32,44 @@ struct PhantomConfirmScreen: View { var body: some View { Background(color: .backgroundMain) { - ScrollView { - VStack(spacing: 24) { - Spacer(minLength: 60) - PhantomUSDCHero(connected: true) + VStack(spacing: 24) { + Spacer() + + BadgedIcon( + icon: Image.asset(.buyPhantom), + badge: Image.asset(.buyCheckmark) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Phantom connected") + + VStack(spacing: 8) { Text("Connected") - .font(.appTitle) + .font(.appTextLarge) .foregroundStyle(Color.textMain) + Text("Confirm the transaction in Phantom to continue") .font(.appTextMedium) .foregroundStyle(Color.textSecondary) .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() + .fixedSize(horizontal: false, vertical: true) } - } - .safeAreaInset(edge: .bottom) { - BubbleButton(text: "Confirm In Phantom") { - confirmInPhantom() + .padding(.horizontal, 40) + + Spacer() + + Button(action: confirmInPhantom) { + HStack(spacing: 6) { + Text("Confirm in your") + Image.asset(.phantom) + .renderingMode(.template) + .resizable() + .frame(width: 18, height: 18) + Text("Phantom") + } } - .padding() + .buttonStyle(.filled) } + .padding(20) } .navigationTitle("Confirmation") .navigationBarTitleDisplayMode(.inline) diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift index 2b31c1cd..640d6e74 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift @@ -29,27 +29,34 @@ struct PhantomEducationScreen: View { var body: some View { Background(color: .backgroundMain) { - ScrollView { - VStack(spacing: 24) { - Spacer(minLength: 60) - PhantomUSDCHero(connected: false) + VStack(spacing: 24) { + Spacer() + + HeroGraphic() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Buy with Phantom using Solana USDC") + + VStack(spacing: 8) { Text("Buy With Phantom") - .font(.appTitle) + .font(.appTextLarge) .foregroundStyle(Color.textMain) + Text("Purchase using Solana USDC in Phantom. Simply connect your wallet and confirm the transaction.") .font(.appTextMedium) .foregroundStyle(Color.textSecondary) .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() + .fixedSize(horizontal: false, vertical: true) } - } - .safeAreaInset(edge: .bottom) { - BubbleButton(text: "Connect Your Phantom Wallet") { + .padding(.horizontal, 40) + + Spacer() + + Button("Connect Your Phantom Wallet") { connect() } - .padding() + .buttonStyle(.filled) } + .padding(20) } .navigationTitle("Purchase") .navigationBarTitleDisplayMode(.inline) @@ -89,3 +96,20 @@ struct PhantomEducationScreen: View { } } } + +private struct HeroGraphic: View { + var body: some View { + HStack(spacing: 16) { + BadgedIcon(icon: Image.asset(.buyPhantom)) + + Image(systemName: "plus") + .foregroundStyle(Color.textMain) + .font(.system(size: 20, weight: .medium)) + + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana) + ) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift index ecd8ead1..9bf1a3eb 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift @@ -59,21 +59,17 @@ struct USDCDepositEducationScreen: View { private struct ConversionGraphic: View { var body: some View { HStack(spacing: 16) { - // Mirrors `WithdrawIntroScreen.ConversionGraphic`: the solanaUSDC - // asset's viewBox is 143x145 because the Solana hex badge sits - // down-right of the main USDC circle (128.3 wide). Frame ratio - // 111x113 scales the main circle to ~100x100, matching the - // Flipcash icon. The offset re-centers the main circle. - Image.asset(.solanaUSDC) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 111, height: 113) - .offset(x: 6, y: 7) + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana), + size: 100, + badgeSize: 32 + ) Image.system(.arrowRight) .foregroundStyle(Color.textSecondary) - Image.asset(.flipcash) + Image.asset(.buyFlipcash) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift index 29ddc020..b5c0df4a 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift @@ -50,7 +50,7 @@ struct WithdrawIntroScreen: View { private struct ConversionGraphic: View { var body: some View { HStack(spacing: 16) { - Image.asset(.flipcash) + Image.asset(.buyFlipcash) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) @@ -58,18 +58,12 @@ private struct ConversionGraphic: View { Image.system(.arrowRight) .foregroundStyle(Color.textSecondary) - // The solanaUSDC asset's viewBox is 143×145 because the Solana - // hex badge sits down-right of the main USDC circle (which is - // 128.3 wide within the viewBox). Frame ratio 111×113 scales the - // viewBox so the main circle renders at ≈100×100, matching the - // Flipcash icon. The offset pulls the asset down-right so the - // main circle's center aligns with the Flipcash circle's center - // (the asset's geometric center is offset by the badge weight). - Image.asset(.solanaUSDC) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 111, height: 113) - .offset(x: 6, y: 7) + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana), + size: 100, + badgeSize: 32 + ) } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Checkmark.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Checkmark.svg new file mode 100644 index 00000000..daf4249e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Checkmark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Contents.json new file mode 100644 index 00000000..57dc0f04 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Checkmark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Contents.json new file mode 100644 index 00000000..92376f1c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Flipcash.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Flipcash.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Flipcash.svg new file mode 100644 index 00000000..d5d6431c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Flipcash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Contents.json new file mode 100644 index 00000000..be1753a0 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Phantom.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Phantom.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Phantom.svg new file mode 100644 index 00000000..3da180ab --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Phantom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Contents.json new file mode 100644 index 00000000..c0aebd1b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Solana.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Solana.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Solana.svg new file mode 100644 index 00000000..a9ede1ba --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Solana.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/Contents.json new file mode 100644 index 00000000..b36da885 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "USDC.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/USDC.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/USDC.svg new file mode 100644 index 00000000..dd457aff --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/USDC.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index cbfa2312..b6f6e002 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -136,6 +136,15 @@ public enum Asset: String, Sendable { case phantom case solanaUSDC case downloadCircle + + // Buy / Withdraw / Deposit hero icons. Namespaced under `buy/` in + // `UI.xcassets` so SVG variants stay grouped; the raw value carries the + // path so call sites can use `Image.asset(.buyPhantom)` etc. + case buyPhantom = "buy/Phantom" + case buyFlipcash = "buy/Flipcash" + case buyUSDC = "buy/USDC" + case buySolana = "buy/Solana" + case buyCheckmark = "buy/Checkmark" // Flipchat diff --git a/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift b/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift new file mode 100644 index 00000000..dc5afe06 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift @@ -0,0 +1,49 @@ +// +// BadgedIcon.swift +// FlipcashUI +// + +import SwiftUI + +/// Round/rounded-square icon with an optional small badge anchored to the +/// bottom-trailing corner. Used by buy/withdraw hero graphics where a base +/// asset (USDC, Phantom) gets paired with a network/state marker (Solana hex, +/// checkmark) without baking the badge into the parent SVG. +public struct BadgedIcon: View { + + private let icon: Image + private let badge: Image? + private let size: CGFloat + private let badgeSize: CGFloat + private let badgeOffset: CGSize + + public init( + icon: Image, + badge: Image? = nil, + size: CGFloat = 80, + badgeSize: CGFloat = 28, + badgeOffset: CGSize = CGSize(width: 4, height: 4) + ) { + self.icon = icon + self.badge = badge + self.size = size + self.badgeSize = badgeSize + self.badgeOffset = badgeOffset + } + + public var body: some View { + icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .overlay(alignment: .bottomTrailing) { + if let badge { + badge + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: badgeSize, height: badgeSize) + .offset(x: badgeOffset.width, y: badgeOffset.height) + } + } + } +} From a35c238d400761ef6c249fd604e97eec49752284 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 10:59:38 -0400 Subject: [PATCH 24/33] refactor: address review findings on the nested-sheet buy flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - Wrap WalletConnection.connect() in withTaskCancellationHandler so a cancelled connect-task (e.g. PhantomEducationScreen popped before Phantom returns) resumes the pendingConnect continuation with CancellationError instead of leaking it. - New cancelPendingSwap() is called from PhantomConfirmScreen.onDisappear so isAwaitingExternalSwap doesn't permanently gate the .buy sheet's interactiveDismissDisabled when the user backs out mid-sign. - Mark AppRouter @MainActor explicitly; default-isolation handled it but the annotation matches the rest of the codebase and Swift 6's expectations for an Observable model class. - Promote nestedSheetRootView(for:) from a @ViewBuilder func to a private struct NestedSheetRootView per the project's no-view-functions rule. Medium: - Stack.sheet is now Optional with .buy → nil (preconditionFailure from a property getter was a foot-gun). navigate(to:) gracefully handles the nil case with a warning log. - Add SheetPresentation.CaseKind and use it in presentNested's swap branch instead of comparing the stringly-typed `description`. - Factor PurchaseMethodSheet's three Task { sleep + action } blocks into a shared dismissThenDispatch helper. - Mark PurchaseMethodSheetTests .serialized — it mutates BetaFlags.shared and would race other suites that read it. - Delete AppRouterBuySheetTests; every case was already covered by AppRouterNestedSheetTests. - Add a test for presentNested(.buy(B)) while [.balance, .buy(A)] is up — the swap branch's "no prior pushed content" path. - Adapt AppRouterStressTests to compactMap nil-sheet stacks. --- .../Deep Links/Wallet/WalletConnection.swift | 35 +++++++++++-- .../Navigation/AppRouter+NestedSheet.swift | 23 +++++++-- .../AppRouter+SheetPresentation.swift | 21 ++++++++ .../Core/Navigation/AppRouter+Stack.swift | 9 ++-- Flipcash/Core/Navigation/AppRouter.swift | 14 ++++- .../Main/Buy/PhantomConfirmScreen.swift | 3 ++ .../Main/Buy/PurchaseMethodSheet.swift | 40 ++++++++++----- .../Buy/PurchaseMethodSheetTests.swift | 5 +- .../Concurrency/AppRouterStressTests.swift | 4 +- .../Navigation/AppRouterBuySheetTests.swift | 51 ------------------- .../Navigation/AppRouterCrossStackTests.swift | 5 ++ .../AppRouterNestedSheetTests.swift | 12 +++++ 12 files changed, 139 insertions(+), 83 deletions(-) delete mode 100644 FlipcashTests/Navigation/AppRouterBuySheetTests.swift diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift index 6829712c..e02d3bc2 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift @@ -507,15 +507,42 @@ public final class WalletConnection { /// connect deeplink arrives (success) or an errorCode callback does /// (throws `WalletConnectionError.connectFailed`). A second call while /// the first is in flight cancels the first waiter. + /// + /// Task cancellation is propagated through `withTaskCancellationHandler` + /// so callers that abandon the connect (e.g. `PhantomEducationScreen` + /// being popped) don't leak the underlying `CheckedContinuation`. func connect() async throws { guard !isConnected else { return } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - pendingConnect?.resume(throwing: CancellationError()) - pendingConnect = continuation - connectToPhantom() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + pendingConnect?.resume(throwing: CancellationError()) + pendingConnect = continuation + connectToPhantom() + } + } onCancel: { [weak self] in + Task { @MainActor [weak self] in + self?.cancelPendingConnect() + } } } + /// Resumes any in-flight `pendingConnect` with a CancellationError and + /// clears the slot. Safe to call when nothing is pending. + private func cancelPendingConnect() { + guard let continuation = pendingConnect else { return } + pendingConnect = nil + continuation.resume(throwing: CancellationError()) + } + + /// Discards an in-flight swap request context. Called by + /// `PhantomConfirmScreen.onDisappear` so the screen popping while + /// waiting for Phantom's signed-transaction callback frees the gate + /// (`isAwaitingExternalSwap`) and prevents a future unrelated deeplink + /// from completing a stale swap. + func cancelPendingSwap() { + pendingSwap = nil + } + func connectToPhantom() { let nonce = UUID().uuidString diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 7a2c23dc..60673332 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -56,14 +56,27 @@ private struct AppRouterNestedSheetModifier: ViewModifier { // modifier reads the parent's depth and mounts the same sheet again, // producing an infinite duplicate-sheet stack. return content.sheet(item: binding) { nested in - nestedSheetRootView(for: nested) - .appRouterNestedSheet(container: container, sessionContainer: sessionContainer) - .environment(\.nestedSheetDepth, depth + 1) + NestedSheetRootView( + sheet: nested, + container: container, + sessionContainer: sessionContainer + ) + .appRouterNestedSheet(container: container, sessionContainer: sessionContainer) + .environment(\.nestedSheetDepth, depth + 1) } } +} + +/// Dispatches the active nested `SheetPresentation` to its root view. Lives +/// as a `View` (not a `@ViewBuilder` function) so SwiftUI tracks identity +/// per case — per CLAUDE.md "no view functions" rule. +private struct NestedSheetRootView: View { - @ViewBuilder - private func nestedSheetRootView(for sheet: AppRouter.SheetPresentation) -> some View { + let sheet: AppRouter.SheetPresentation + let container: Container + let sessionContainer: SessionContainer + + var body: some View { switch sheet { case .buy(let mint): BuySheetRoot( diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 32c09550..ff3986d5 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -35,6 +35,27 @@ extension AppRouter { } } + /// Payload-free case discriminator. Used by `presentNested` to detect + /// "same case, different payload" (e.g. `.buy(A)` → `.buy(B)`) without + /// comparing the stringly-typed `description`. + var caseKind: CaseKind { + switch self { + case .balance: .balance + case .settings: .settings + case .give: .give + case .discover: .discover + case .buy: .buy + } + } + + enum CaseKind: Hashable, Sendable { + case balance + case settings + case give + case discover + case buy + } + var description: String { switch self { case .balance: "balance" diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift index 026daa57..b2184648 100644 --- a/Flipcash/Core/Navigation/AppRouter+Stack.swift +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -22,17 +22,16 @@ extension AppRouter { /// The sheet a stack is presented in. Cross-stack navigation uses /// this to know which top-level modal to surface. /// - /// `.buy` is excluded — its sheet carries a mint payload that can't be - /// synthesized from the stack alone. Buy is always entered via + /// `.buy` returns `nil` — its sheet carries a mint payload that can't + /// be synthesized from the stack alone. Buy is always entered via /// `router.presentNested(.buy(mint))` directly, never via `navigate(to:)`. - var sheet: SheetPresentation { + var sheet: SheetPresentation? { switch self { case .balance: .balance case .settings: .settings case .give: .give case .discover: .discover - case .buy: - preconditionFailure("buy sheet must be presented via router.presentNested(.buy(mint)); not reachable via Stack.sheet") + case .buy: nil } } diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index c9d90896..570a02ad 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -34,6 +34,7 @@ private let logger = Logger(label: "flipcash.router") /// funnels SwiftUI's automatic writes (e.g., swipe-back) through `setPath`, /// so every observable state change produces exactly one log line. @Observable +@MainActor final class AppRouter { /// Approximate duration of a SwiftUI `.sheet` / `.fullScreenCover` dismiss @@ -248,7 +249,7 @@ final class AppRouter { if let top = presentedSheets.last, top != sheet, - top.description == sheet.description { + top.caseKind == sheet.caseKind { // Same case kind on top with a different payload — swap. dismissedSheets.insert(top) if dismissedSheets.remove(sheet) != nil { @@ -355,7 +356,16 @@ final class AppRouter { /// > view's view model. func navigate(to destination: Destination) { let targetStack = destination.owningStack - let targetSheet = targetStack.sheet + // `Destination.owningStack` only ever names a root stack + // (balance/settings/give/discover) — `.buy` is nested-only and never + // an owning stack — so the optional `Stack.sheet` is never nil here. + guard let targetSheet = targetStack.sheet else { + logger.warning("navigate(to:) hit a nested-only stack — destination is misrouted", metadata: [ + "stack": "\(targetStack)", + "destination": "\(destination)", + ]) + return + } var expected = NavigationPath() expected.append(destination) diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index 8d8ec079..808cea3f 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -79,6 +79,9 @@ struct PhantomConfirmScreen: View { // pendingSwap mutation can fire against a popped screen. confirmTask?.cancel() confirmTask = nil + // Clear the swap context too so `isAwaitingExternalSwap` doesn't + // remain true and permanently block the .buy sheet's dismissal. + walletConnection.cancelPendingSwap() } .onChange(of: walletConnection.state) { _, newState in // Push the processing screen the moment the swap context appears. diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index 25dc84d0..83f230d4 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -88,6 +88,22 @@ private struct MethodButton: View { } } +/// Dismisses the sheet, then waits for the system's dismiss animation +/// before invoking `action`. Without the wait, pushing onto the buy +/// stack while the sheet is still mid-dismiss racing causes SwiftUI to +/// drop the push. +@MainActor +private func dismissThenDispatch( + onDismiss: () -> Void, + action: @escaping @MainActor @Sendable () -> Void +) { + onDismiss() + Task { @MainActor in + try? await Task.sleep(for: AppRouter.dismissAnimationDuration) + action() + } +} + private struct ApplePayMethodButton: View { let context: PurchaseMethodContext let onDismiss: () -> Void @@ -97,13 +113,11 @@ private struct ApplePayMethodButton: View { var body: some View { Button { Analytics.buttonTapped(name: .buyWithCoinbase) - onDismiss() - Task { - try? await Task.sleep(for: AppRouter.dismissAnimationDuration) - coordinator.start( - .buy(mint: context.mint, displayName: context.currencyName), - amount: context.amount - ) + let mint = context.mint + let displayName = context.currencyName + let amount = context.amount + dismissThenDispatch(onDismiss: onDismiss) { [coordinator] in + coordinator.start(.buy(mint: mint, displayName: displayName), amount: amount) } } label: { HStack(spacing: 4) { @@ -126,7 +140,6 @@ private struct PhantomMethodButton: View { var body: some View { Button { Analytics.buttonTapped(name: .buyWithPhantom) - onDismiss() // Skip the education screen when a Phantom session already exists // — the user has connected before and just needs to confirm. The // education screen's auto-advance latch breaks on pop-back from @@ -135,8 +148,7 @@ private struct PhantomMethodButton: View { let nextStep: BuyFlowPath = walletConnection.isConnected ? .phantomConfirm(mint: context.mint, amount: context.amount) : .phantomEducation(mint: context.mint, amount: context.amount) - Task { - try? await Task.sleep(for: AppRouter.dismissAnimationDuration) + dismissThenDispatch(onDismiss: onDismiss) { [router] in router.pushAny(nextStep) } } label: { @@ -159,10 +171,10 @@ private struct OtherWalletMethodButton: View { var body: some View { Button("Other Wallet") { - onDismiss() - Task { - try? await Task.sleep(for: AppRouter.dismissAnimationDuration) - router.pushAny(BuyFlowPath.usdcDepositEducation(mint: context.mint, amount: context.amount)) + let mint = context.mint + let amount = context.amount + dismissThenDispatch(onDismiss: onDismiss) { [router] in + router.pushAny(BuyFlowPath.usdcDepositEducation(mint: mint, amount: amount)) } } .buttonStyle(.filled) diff --git a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift index f0a23048..09583d77 100644 --- a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift +++ b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift @@ -10,7 +10,10 @@ import Testing @testable import FlipcashCore @testable import Flipcash -@Suite("PurchaseMethodSheet — visibility") +/// Runs serialized — both test cases mutate the `BetaFlags.shared` +/// singleton (UserDefaults-backed), so parallel execution with any other +/// suite that reads `enableCoinbase` would race on the global flag. +@Suite("PurchaseMethodSheet — visibility", .serialized) @MainActor struct PurchaseMethodSheetTests { diff --git a/FlipcashTests/Concurrency/AppRouterStressTests.swift b/FlipcashTests/Concurrency/AppRouterStressTests.swift index 1ebedc61..12bc666b 100644 --- a/FlipcashTests/Concurrency/AppRouterStressTests.swift +++ b/FlipcashTests/Concurrency/AppRouterStressTests.swift @@ -50,7 +50,9 @@ struct AppRouterStressTests { @Test("100 rounds across all sheet cases converge on empty state") func cyclingAllSheets_convergesOnEmptyState() { let router = AppRouter() - let sheets = AppRouter.Stack.allCases.map(\.sheet) + // `compactMap` skips nested-only stacks (`.buy`) — they can't be + // a root sheet, so they're outside this stress test's scope. + let sheets = AppRouter.Stack.allCases.compactMap(\.sheet) for i in 0..<100 { let sheet = sheets[i % sheets.count] diff --git a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift b/FlipcashTests/Navigation/AppRouterBuySheetTests.swift deleted file mode 100644 index 3ac07f21..00000000 --- a/FlipcashTests/Navigation/AppRouterBuySheetTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AppRouterBuySheetTests.swift -// FlipcashTests -// -// Created by Raul Riera on 2026-05-12. -// - -import Testing -import FlipcashCore -@testable import Flipcash - -@Suite("AppRouter buy nested sheet") -@MainActor -struct AppRouterBuySheetTests { - - @Test("presentNested(.buy(mint)) stacks on .balance") - func presentNestedBuyOnBalance() { - let router = AppRouter() - let mint = PublicKey.usdf - router.present(.balance) - - router.presentNested(.buy(mint)) - - #expect(router.presentedSheets == [.balance, .buy(mint)]) - #expect(router.rootSheet == .balance) - #expect(router.presentedSheet == .buy(mint)) - } - - @Test(".buy SheetPresentation maps to .buy stack") - func buySheetMapsToBuyStack() { - #expect(AppRouter.SheetPresentation.buy(.usdf).stack == .buy) - } - - @Test("pushAny BuyFlowPath onto the .buy stack appends the value") - func pushAnyBuyFlowPath() { - let router = AppRouter() - let mint = PublicKey.usdf - router.present(.balance) - router.presentNested(.buy(mint)) - - let pinned = ExchangedFiat.compute( - onChainAmount: .zero(mint: .usdf), - rate: .oneToOne, - supplyQuarks: nil - ) - router.pushAny(BuyFlowPath.phantomEducation(mint: mint, amount: pinned)) - - #expect(router[.buy].count == 1, "pushes target the topmost sheet's stack") - #expect(router[.balance].isEmpty, "balance stack untouched") - } -} diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index 8de2e96f..f6a9f6e6 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -122,4 +122,9 @@ struct AppRouterCrossStackTests { func stack_mapsToSheet(_ stack: AppRouter.Stack, expected: AppRouter.SheetPresentation) { #expect(stack.sheet == expected) } + + @Test(".buy stack has no owning root sheet — it's nested-only") + func buyStack_sheet_isNil() { + #expect(AppRouter.Stack.buy.sheet == nil) + } } diff --git a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift index e8d8fe2a..e5b5c1de 100644 --- a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift @@ -86,6 +86,18 @@ struct AppRouterNestedSheetTests { // the dismissed-path-clear contract (verified separately). } + @Test("presentNested same case different payload — no prior pushed content — swaps cleanly") + func presentNested_sameCaseDifferentPayload_noPriorPath_swaps() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.presentNested(.buy(Self.mintB)) + + #expect(router.presentedSheets == [.balance, .buy(Self.mintB)]) + #expect(router[.buy].isEmpty, "no path was set; the new top should sit at root of the buy stack") + } + // MARK: - dismissSheet @Test("dismissSheet pops topmost when nested is up") From aed65a4c930257c9315db90ab93c9e68f441b1f7 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 12:08:00 -0400 Subject: [PATCH 25/33] test: rewrite buy flow XCUITests for the nested-sheet UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy CurrencyBuyRegressionTests / WalletCallbackRegressionTests that were deleted alongside the old buy/onramp screens. New coverage: - BuyReservesRegressionTests — full flow with sufficient USDF: enter the minimum, tap Buy, wait for processing, OK lands back on CurrencyInfo (regression guard for the cascading-dismiss bug we just fixed). - BuyPhantomRegressionTests — new account (no saved Phantom session): enter an above-balance amount, picker appears, tap Phantom, education screen appears with the Connect CTA hittable. Stops at the Connect tap since driving Phantom itself isn't realistic on the local simulator. - BuyDepositRegressionTests — Other Wallet path: picker → deposit education → Next → address screen with Copy Address hittable. New page objects for PurchaseMethodSheet, PhantomEducationScreen, USDCDepositEducationScreen, and USDCDepositAddressScreen. AmountEntryScreen gets an enterPickerTriggeringAmount helper that taps "999" — well inside the per-transaction limit but above any plausible test-account USDF balance. --- .../BuyDepositRegressionTests.swift | 61 ++++++++++++++++++ .../BuyPhantomRegressionTests.swift | 60 ++++++++++++++++++ .../BuyReservesRegressionTests.swift | 54 ++++++++++++++++ .../Support/Screens/AmountEntryScreen.swift | 12 ++++ .../Screens/PhantomEducationScreen.swift | 40 ++++++++++++ .../Support/Screens/PurchaseMethodSheet.swift | 63 +++++++++++++++++++ .../Screens/USDCDepositAddressScreen.swift | 44 +++++++++++++ .../Screens/USDCDepositEducationScreen.swift | 45 +++++++++++++ 8 files changed, 379 insertions(+) create mode 100644 FlipcashUITests/Regression/BuyDepositRegressionTests.swift create mode 100644 FlipcashUITests/Regression/BuyPhantomRegressionTests.swift create mode 100644 FlipcashUITests/Regression/BuyReservesRegressionTests.swift create mode 100644 FlipcashUITests/Support/Screens/PhantomEducationScreen.swift create mode 100644 FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift create mode 100644 FlipcashUITests/Support/Screens/USDCDepositAddressScreen.swift create mode 100644 FlipcashUITests/Support/Screens/USDCDepositEducationScreen.swift diff --git a/FlipcashUITests/Regression/BuyDepositRegressionTests.swift b/FlipcashUITests/Regression/BuyDepositRegressionTests.swift new file mode 100644 index 00000000..8e19a10a --- /dev/null +++ b/FlipcashUITests/Regression/BuyDepositRegressionTests.swift @@ -0,0 +1,61 @@ +// +// BuyDepositRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for the "Other Wallet" (direct USDC deposit) funding +/// path. Exercises the in-app flow end-to-end: +/// +/// - The buy nested sheet opens on top of CurrencyInfoScreen. +/// - An amount above the USDF balance routes to `PurchaseMethodSheet`. +/// - Selecting "Other Wallet" pushes `USDCDepositEducationScreen`. +/// - Tapping Next pushes `USDCDepositAddressScreen` with the Copy Address +/// button hittable. The address itself is derived from the session's +/// owner key — its exact value isn't asserted, just that the CTA renders. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The test account must have at least one non-USDF currency visible in +/// Wallet (the first row is used as the buy target). +final class BuyDepositRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testDepositFlow_otherWallet_showsAddressScreen() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let purchaseMethods = PurchaseMethodSheet(app: app) + let depositEducation = USDCDepositEducationScreen(app: app) + let depositAddress = USDCDepositAddressScreen(app: app) + + assertMainScreenReached() + + // Navigate: Main → Wallet → first currency → CurrencyInfoScreen → Buy + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertReached() + waitAndTap(currencyInfo.buyButton) + + // Enter a high amount so the USDF gate fails and the picker shows. + amountEntry.enterPickerTriggeringAmount() + waitUntilHittableAndTap(amountEntry.buyActionButton) + + // Picker → Other Wallet → USDCDepositEducationScreen → Next. + purchaseMethods.assertReached() + purchaseMethods.selectOtherWallet(from: self) + + depositEducation.assertReached() + depositEducation.tapNext(from: self) + + // Address screen renders with the Copy Address CTA. The address + // value depends on the per-user PDA derivation and is not asserted. + depositAddress.assertReached() + XCTAssertTrue( + depositAddress.copyAddressButton.isHittable, + "Expected the Copy Address CTA to be hittable" + ) + } +} diff --git a/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift new file mode 100644 index 00000000..f40b8231 --- /dev/null +++ b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift @@ -0,0 +1,60 @@ +// +// BuyPhantomRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for the Phantom funding path. Exercises the in-app flow +/// as far as can be tested without a real Phantom install: +/// +/// - The buy nested sheet opens on top of CurrencyInfoScreen. +/// - An amount above the USDF balance routes to `PurchaseMethodSheet`. +/// - Selecting Phantom on an account with no saved Phantom session pushes +/// `PhantomEducationScreen` (rather than skipping directly to the +/// Confirm screen — the no-session branch in `PurchaseMethodSheet`). +/// - The Connect CTA is hittable. +/// +/// The test stops at the Connect tap. Driving the actual Phantom callback +/// is out of scope — `WalletCallbackRegressionTests` covers the deeplink +/// re-entry surface separately. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The test account must NOT have a saved Phantom session in Keychain +/// (a fresh test account is fine; a previously-Phantom-connected account +/// would skip past `PhantomEducationScreen`). +final class BuyPhantomRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testPhantomFlow_newAccount_showsEducationScreen() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let purchaseMethods = PurchaseMethodSheet(app: app) + let phantomEducation = PhantomEducationScreen(app: app) + + assertMainScreenReached() + + // Navigate: Main → Wallet → first currency → CurrencyInfoScreen → Buy + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertReached() + waitAndTap(currencyInfo.buyButton) + + // Enter a high amount so the USDF gate fails and the picker shows. + amountEntry.enterPickerTriggeringAmount() + waitUntilHittableAndTap(amountEntry.buyActionButton) + + // Picker → Phantom → PhantomEducationScreen (no saved session). + purchaseMethods.assertReached() + purchaseMethods.selectPhantom(from: self) + + phantomEducation.assertReached() + XCTAssertTrue( + phantomEducation.connectButton.isHittable, + "Expected the Connect CTA to be hittable on the education screen" + ) + } +} diff --git a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift new file mode 100644 index 00000000..65c14191 --- /dev/null +++ b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift @@ -0,0 +1,54 @@ +// +// BuyReservesRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for the full buy flow using the user's USDF reserve as +/// the funding source. Asserts that: +/// +/// - The buy nested sheet opens on top of CurrencyInfoScreen. +/// - A sub-cent entry the USDF balance can cover routes straight to the +/// swap-processing screen (no PurchaseMethodSheet appears). +/// - After OK on the processing screen, the user lands back on +/// CurrencyInfoScreen — not the Wallet root, not the Scanner. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The test account must have non-zero USDF reserves +/// - The test account must have at least one non-USDF currency visible in +/// Wallet (the first row is used as the buy target) +final class BuyReservesRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testBuyCurrency_fullFlowWithReserves() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let processing = SwapProcessingUIScreen(app: app) + + assertMainScreenReached() + + // Navigate: Main → Wallet → first currency → CurrencyInfoScreen + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertReached() + + // Buy → enter $0.01 → submit. The amount is well below any plausible + // USDF balance, so the USDF gate routes straight to the swap. + waitAndTap(currencyInfo.buyButton) + amountEntry.enterMinimumAmount() + waitUntilHittableAndTap(amountEntry.buyActionButton) + + // Wait for the swap to settle and dismiss via OK. + processing.waitForCompletionAndDismiss() + + // OK on the processing screen pops the entire .buy nested sheet, + // revealing CurrencyInfoScreen underneath. A regression here means + // OK dismissed the wallet too (the cascading-dismiss bug fixed by + // guarding the nested binding's setter against post-dismiss nil). + currencyInfo.assertReached() + } +} diff --git a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift index e2579bcb..c325d4cb 100644 --- a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift +++ b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift @@ -42,4 +42,16 @@ struct AmountEntryScreen { keypadButton("0").tap() keypadButton("1").tap() } + + /// Enters an amount near the per-transaction cap so the USDF gate routes + /// to the funding picker regardless of the test account's USDF balance. + /// The single-transaction limit is $1,000.00; entering "999" stays inside + /// it while exceeding any plausible test-account reserve. + func enterPickerTriggeringAmount() { + XCTAssertTrue(keypadZero.waitForExistence(timeout: 5), "Expected keypad to be visible") + + keypadButton("9").tap() + keypadButton("9").tap() + keypadButton("9").tap() + } } diff --git a/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift b/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift new file mode 100644 index 00000000..766631d4 --- /dev/null +++ b/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift @@ -0,0 +1,40 @@ +// +// PhantomEducationScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the PhantomEducationScreen — the pre-flight shown when +/// the user picks Phantom from `PurchaseMethodSheet` without a saved Phantom +/// session. Tapping `connectButton` triggers `walletConnection.connect()` +/// which deeplinks out to Phantom (tests can't reasonably continue past that +/// point on the local simulator without a real Phantom install). +@MainActor +struct PhantomEducationScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var connectButton: XCUIElement { + app.buttons["Connect Your Phantom Wallet"] + } + + var title: XCUIElement { + app.staticTexts["Buy With Phantom"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + connectButton.waitForExistence(timeout: timeout), + "Expected PhantomEducationScreen with 'Connect Your Phantom Wallet' CTA" + ) + } +} diff --git a/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift b/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift new file mode 100644 index 00000000..37708793 --- /dev/null +++ b/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift @@ -0,0 +1,63 @@ +// +// PurchaseMethodSheet.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the PurchaseMethodSheet shown when the user's USDF +/// reserve doesn't cover the entered buy amount. Lists Apple Pay (conditional +/// on the Coinbase onramp gate), Phantom, and Other Wallet, plus a Dismiss +/// row. +@MainActor +struct PurchaseMethodSheet { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + /// Apple Pay row. Label contains the U+F8FF "Pay" glyph; matched by + /// "Debit Card with" prefix so it's robust to glyph-rendering differences. + var applePayButton: XCUIElement { + app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Debit Card with'")).firstMatch + } + + /// Phantom row. The button's label is just "Phantom" since the inline + /// icon is template-rendered and contributes no accessibility text. + var phantomButton: XCUIElement { + app.buttons.matching(NSPredicate(format: "label == 'Phantom'")).firstMatch + } + + /// Other Wallet row. + var otherWalletButton: XCUIElement { + app.buttons["Other Wallet"] + } + + /// Dismiss row at the bottom of the sheet. + var dismissButton: XCUIElement { + app.buttons["Dismiss"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + phantomButton.waitForExistence(timeout: timeout), + "Expected PurchaseMethodSheet with the Phantom row" + ) + } + + // MARK: - Actions + + func selectPhantom(from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(phantomButton) + } + + func selectOtherWallet(from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(otherWalletButton) + } +} diff --git a/FlipcashUITests/Support/Screens/USDCDepositAddressScreen.swift b/FlipcashUITests/Support/Screens/USDCDepositAddressScreen.swift new file mode 100644 index 00000000..201f876a --- /dev/null +++ b/FlipcashUITests/Support/Screens/USDCDepositAddressScreen.swift @@ -0,0 +1,44 @@ +// +// USDCDepositAddressScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the USDCDepositAddressScreen — the leaf of the +/// "Other Wallet" buy path. Shows the per-user USDC deposit address and +/// a Copy Address button. The address is derived from the session's owner +/// key, so its presence (rather than its exact value) is what the regression +/// test asserts. +@MainActor +struct USDCDepositAddressScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + /// "Copy Address" CTA at the bottom of the screen. Flips its label to + /// "Copied" after a successful tap. + var copyAddressButton: XCUIElement { + app.buttons["Copy Address"] + } + + /// "Copied" success-state label that replaces "Copy Address" briefly + /// after a successful copy. + var copiedButton: XCUIElement { + app.buttons["Copied"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + copyAddressButton.waitForExistence(timeout: timeout), + "Expected USDCDepositAddressScreen with 'Copy Address' CTA" + ) + } +} diff --git a/FlipcashUITests/Support/Screens/USDCDepositEducationScreen.swift b/FlipcashUITests/Support/Screens/USDCDepositEducationScreen.swift new file mode 100644 index 00000000..07106488 --- /dev/null +++ b/FlipcashUITests/Support/Screens/USDCDepositEducationScreen.swift @@ -0,0 +1,45 @@ +// +// USDCDepositEducationScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the USDCDepositEducationScreen — the pre-flight shown +/// when the user picks "Other Wallet" from `PurchaseMethodSheet`. Explains +/// the USDC→USDF auto-conversion and hands off to the address screen via +/// the Next button. +@MainActor +struct USDCDepositEducationScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var title: XCUIElement { + app.staticTexts["Deposit USDC"] + } + + var nextButton: XCUIElement { + app.buttons["Next"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + title.waitForExistence(timeout: timeout), + "Expected USDCDepositEducationScreen with 'Deposit USDC' title" + ) + } + + // MARK: - Actions + + func tapNext(from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(nextButton) + } +} From 512cebe84659db8b2bfc4d0ec495c470ad4f946e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 13:42:48 -0400 Subject: [PATCH 26/33] fix: block swipe-dismiss on the buy processing screen `interactiveDismissDisabled(true)` on `SwapProcessingScreen` was being suppressed by two issues in the nested-sheet plumbing: the recursive `.appRouterNestedSheet(...)` call attached a dormant inner `.sheet(item:)` that swallowed preferences from descendants, and `BuyAmountScreen`'s source-level `.interactiveDismissDisabled(false)` overrode the destination's `true`. Drop both, gate Phantom signing via a destination modifier on `PhantomConfirmScreen`, and add a regression test that swipes on the processing screen and asserts it stays. --- .../Core/Navigation/AppRouter+NestedSheet.swift | 16 +++++++--------- .../Core/Screens/Main/Buy/BuyAmountScreen.swift | 1 - .../Screens/Main/Buy/PhantomConfirmScreen.swift | 1 + .../Regression/BuyReservesRegressionTests.swift | 13 +++++++++++++ .../Support/Screens/SwapProcessingScreen.swift | 13 +++++++++++++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 60673332..046f4ccc 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -16,10 +16,13 @@ extension View { /// nested sheets to be presented from within the parent sheet's content, /// not as siblings at the app root. /// - /// Recursive: the mounted sheet's content re-applies this modifier with - /// `nestedSheetDepth + 1`, so level 3+ also works once `presentedSheets` - /// grows that deep. v1 only nests two-deep; the recursion is mechanical - /// and untested at higher levels. + /// Currently supports one level of nesting only. A previous version + /// recursed inside the mounted sheet's content to support depth-3+, but a + /// dormant inner `.sheet(item:)` swallows `interactiveDismissDisabled` + /// from descendants before it reaches the actual depth-1 sheet host, + /// re-enabling swipe-dismiss on screens that opt out. Until the recursion + /// is replaced with a presentedSheets-aware conditional mount, depth-3+ + /// would need the caller to apply this modifier explicitly. func appRouterNestedSheet(container: Container, sessionContainer: SessionContainer) -> some View { modifier(AppRouterNestedSheetModifier(container: container, sessionContainer: sessionContainer)) } @@ -51,17 +54,12 @@ private struct AppRouterNestedSheetModifier: ViewModifier { router.dismissSheet() } ) - // `.environment` must wrap `.appRouterNestedSheet` so the recursive - // modifier reads the bumped depth. The opposite order means the inner - // modifier reads the parent's depth and mounts the same sheet again, - // producing an infinite duplicate-sheet stack. return content.sheet(item: binding) { nested in NestedSheetRootView( sheet: nested, container: container, sessionContainer: sessionContainer ) - .appRouterNestedSheet(container: container, sessionContainer: sessionContainer) .environment(\.nestedSheetDepth, depth + 1) } } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index 58677aed..8a65040c 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -60,7 +60,6 @@ struct BuyAmountScreen: View { } } } - .interactiveDismissDisabled(isDismissBlocked) .navigationDestination(for: BuyFlowPath.self) { path in // Env value must be set on the destination view itself — modifiers // on the source view don't propagate to navigation destinations diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index 808cea3f..54c1d14c 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -73,6 +73,7 @@ struct PhantomConfirmScreen: View { } .navigationTitle("Confirmation") .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(walletConnection.isAwaitingExternalSwap) .onDisappear { // Cancel any in-flight swap request if the user backs out before // Phantom returns. Without this, requestSwap's deeplink-out and diff --git a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift index 65c14191..f97dd6c0 100644 --- a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift +++ b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift @@ -42,6 +42,19 @@ final class BuyReservesRegressionTests: BaseUITestCase { amountEntry.enterMinimumAmount() waitUntilHittableAndTap(amountEntry.buyActionButton) + processing.assertReached() + + // Swipe-down on the processing screen must NOT dismiss the .buy sheet. + // Two known regressions break this: + // 1) The recursive `.appRouterNestedSheet(...)` call inside the + // depth-1 sheet content swallows `interactiveDismissDisabled` + // preferences from descendants. + // 2) A source-level `.interactiveDismissDisabled(false)` on + // BuyAmountScreen overrides the destination's `true`. + // After the swipe, the processing title must still be visible. + app.swipeDown() + processing.assertReached(timeout: 5) + // Wait for the swap to settle and dismiss via OK. processing.waitForCompletionAndDismiss() diff --git a/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift b/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift index 03974503..4cf21ece 100644 --- a/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift +++ b/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift @@ -19,8 +19,21 @@ struct SwapProcessingUIScreen { var okButton: XCUIElement { app.buttons["OK"] } + /// Static title rendered during the processing state ("This Will Take a Minute"). + /// Useful as a stable anchor while the swap is in-flight — the success/failed + /// states swap it for "Transaction Complete" / "Something Went Wrong". + var processingTitle: XCUIElement { app.staticTexts["This Will Take a Minute"] } + // MARK: - Actions + /// Waits for the processing screen to become visible. + func assertReached(timeout: TimeInterval = 30) { + XCTAssertTrue( + processingTitle.waitForExistence(timeout: timeout), + "Expected processing screen to be visible within \(timeout)s" + ) + } + /// Waits for the swap to complete (up to 2 minutes) and taps OK. func waitForCompletionAndDismiss() { XCTAssertTrue( From 7ff016531045741239658e73871483332eaf412e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 14:36:50 -0400 Subject: [PATCH 27/33] =?UTF-8?q?fix:=20buy=20flow=20polish=20=E2=80=94=20?= =?UTF-8?q?swipe=20lock,=20button=20spinner,=20Apple=20Pay=20gate,=20coord?= =?UTF-8?q?inator=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block swipe-dismiss whenever the buy stack has anything pushed (Phantom signing, USDC deposit address, swap processing) by folding the path-empty check into `BuyAmountScreen`'s `isDismissBlocked`. Surface `coordinator.isProcessingPayment` as `.loading` on the Buy button so the picker → Apple Pay handoff doesn't look frozen. Bind `coordinator.dialogItem` on `BuyAmountScreen` so Coinbase/Apple Pay failures actually surface instead of silently flickering. Gate the Apple Pay button on a $5 USDF minimum and present the limit localized to the user's display currency. --- .../Screens/Main/Buy/BuyAmountScreen.swift | 20 ++++++++++++-- .../Main/Buy/PhantomConfirmScreen.swift | 1 - .../Main/Buy/PurchaseMethodSheet.swift | 11 ++++++++ Flipcash/UI/DialogItem+Buy.swift | 26 +++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 Flipcash/UI/DialogItem+Buy.swift diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index 8a65040c..f91ed5cd 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -30,7 +30,12 @@ struct BuyAmountScreen: View { } private var isDismissBlocked: Bool { - coordinator.isProcessingPayment || walletConnection.isAwaitingExternalSwap + // Any pushed sub-flow screen (Phantom confirm, USDC deposit address, + // processing) — destination-level `interactiveDismissDisabled(true)` + // does NOT propagate through the nested-sheet binding, so gate at + // the NavigationStack root by checking the path is non-empty. + if !router[.buy].isEmpty { return true } + return coordinator.isProcessingPayment || walletConnection.isAwaitingExternalSwap } var body: some View { @@ -41,7 +46,13 @@ struct BuyAmountScreen: View { mode: .buy, enteredAmount: $viewModel.enteredAmount, subtitle: .singleTransactionLimit, - actionState: $viewModel.actionButtonState, + // Surface coordinator's in-flight Apple Pay/Coinbase setup as + // a spinner on the Buy button — the picker dismisses fast and + // the Apple Pay sheet can take a beat to open. + actionState: Binding( + get: { coordinator.isProcessingPayment ? .loading : viewModel.actionButtonState }, + set: { viewModel.actionButtonState = $0 } + ), actionEnabled: { _ in viewModel.canPerformAction }, action: { Task { await viewModel.amountEnteredAction(router: router) } @@ -60,6 +71,7 @@ struct BuyAmountScreen: View { } } } + .interactiveDismissDisabled(isDismissBlocked) .navigationDestination(for: BuyFlowPath.self) { path in // Env value must be set on the destination view itself — modifiers // on the source view don't propagate to navigation destinations @@ -77,6 +89,10 @@ struct BuyAmountScreen: View { }) } .dialog(item: $viewModel.dialogItem) + // Coordinator surfaces Coinbase/Apple Pay failures (e.g. order + // creation rejected, swap-amount mismatch) via its own dialog item; + // bind it here so the user sees the error instead of a silent flicker. + .dialog(item: $coordinator.dialogItem) .sheet(item: $viewModel.pendingMethodSelection) { context in PurchaseMethodSheet( context: context, diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index 54c1d14c..808cea3f 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -73,7 +73,6 @@ struct PhantomConfirmScreen: View { } .navigationTitle("Confirmation") .navigationBarTitleDisplayMode(.inline) - .interactiveDismissDisabled(walletConnection.isAwaitingExternalSwap) .onDisappear { // Cancel any in-flight swap request if the user backs out before // Phantom returns. Without this, requestSwap's deeplink-out and diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index 83f230d4..b314a9f7 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -109,9 +109,20 @@ private struct ApplePayMethodButton: View { let onDismiss: () -> Void @Environment(OnrampCoordinator.self) private var coordinator + @Environment(Session.self) private var session var body: some View { Button { + // Coinbase Onramp rejects USD purchases under $5 — gate before + // the Apple Pay sheet round-trip. Use the USDF (1:1 USD) value + // since `nativeAmount` is in the user's display currency. + guard context.amount.usdfValue.value >= 5 else { + let minimum = FiatAmount.usd(5) + .converting(to: context.amount.currencyRate) + .formatted() + session.dialogItem = .applePayMinimumPurchase(minimum: minimum) + return + } Analytics.buttonTapped(name: .buyWithCoinbase) let mint = context.mint let displayName = context.currencyName diff --git a/Flipcash/UI/DialogItem+Buy.swift b/Flipcash/UI/DialogItem+Buy.swift new file mode 100644 index 00000000..7b26782a --- /dev/null +++ b/Flipcash/UI/DialogItem+Buy.swift @@ -0,0 +1,26 @@ +// +// DialogItem+Buy.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-13. +// + +import FlipcashUI + +extension DialogItem { + + /// Coinbase Onramp rejects orders below $5 USD with a generic error; + /// surface the constraint up-front instead of letting the user round-trip + /// to Apple Pay. `minimum` is the $5 USD equivalent already formatted in + /// the user's selected display currency. + static func applePayMinimumPurchase(minimum: String) -> DialogItem { + .init( + style: .destructive, + title: "\(minimum) Minimum Purchase", + subtitle: "Please enter an amount of \(minimum) or higher", + dismissable: true + ) { + .okay(kind: .destructive) + } + } +} From 0da61c46cd454633152b20500dc77a9e8bbc9ef6 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 14:36:59 -0400 Subject: [PATCH 28/33] fix: rebuild swap amount from Coinbase's recorded purchase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coinbase applies its own USD→USDF exchange rate (~0.9994) when recording an order, so the USDF quarks they'll fund differ from what we requested by 1-2 quarks. The server requires the swap amount to exactly equal the funding-side purchase, otherwise it denies with "coinbase order purchase amount does not match swap amount". Parse `response.order.purchaseAmount` and rebuild the `ExchangedFiat` from it before initiating the swap, so the quarks line up with what Coinbase delivers. --- .../Onramp/OnrampCoordinator.swift | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift index 022e26df..ea542077 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift @@ -667,7 +667,7 @@ final class OnrampCoordinator { } Analytics.onrampInvokePayment(amount: amount.usdfValue) - + let id = UUID() let userRef = session.ownerKeyPair.publicKey.base58 let orderRef = "\(userRef):\(id)" @@ -698,16 +698,39 @@ final class OnrampCoordinator { )) logger.info("Coinbase order created", metadata: [ - "order_id": "\(response.id)" + "order_id": "\(response.id)", + "recorded_purchase": "\(response.order.purchaseAmount ?? "")", ]) + // Coinbase applies its own USD→USDF rate (~0.9994) when recording + // the order, so the USDF quarks they'll fund differ from what we + // requested by 1-2 quarks. The server requires the swap amount to + // exactly equal the Coinbase-recorded purchase, otherwise it + // denies with "purchase amount does not match swap amount". + // Rebuild the ExchangedFiat from Coinbase's reported amount so + // the swap quarks line up with the funding side. + guard let recorded = response.order.purchaseAmount, + let recordedDecimal = Decimal(string: recorded) else { + logger.error("Coinbase response missing purchaseAmount", metadata: [ + "order_id": "\(response.id)", + ]) + clearPendingState() + showBuyFailedDialog() + return + } + let fundedAmount = ExchangedFiat.compute( + onChainAmount: TokenAmount(wholeTokens: recordedDecimal, mint: .usdf), + rate: amount.currencyRate, + supplyQuarks: nil + ) + // Server must be watching the Coinbase order before Apple Pay // commits — otherwise the user pays into a swap the backend // hasn't registered. do { pendingSwapId = try await initiateCoinbaseOnrampSwap( for: operation, - amount: amount, + amount: fundedAmount, orderId: response.id ) } catch { From 69dc27280951e7023730c2e23eeb1d2f3d57e83f Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 14:51:37 -0400 Subject: [PATCH 29/33] chore: address /simplify findings on buy flow Consolidate the Apple Pay minimum dialog into `DialogItem+CashFlows.swift` and drop the dedicated `DialogItem+Buy.swift`. Extract the $5 Coinbase minimum to `OnrampCoordinator.minimumPurchaseUSD` so the guard and the formatted dialog share one source. Tighten the `appRouterNestedSheet` doc-comment now that the trade-off is well established. --- .../Onramp/OnrampCoordinator.swift | 4 +++ .../Navigation/AppRouter+NestedSheet.swift | 11 +++----- .../Main/Buy/PurchaseMethodSheet.swift | 11 ++++---- Flipcash/UI/DialogItem+Buy.swift | 26 ------------------- Flipcash/UI/DialogItem+CashFlows.swift | 15 +++++++++++ 5 files changed, 29 insertions(+), 38 deletions(-) delete mode 100644 Flipcash/UI/DialogItem+Buy.swift diff --git a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift index ea542077..99a73969 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift @@ -13,6 +13,10 @@ private let logger = Logger(label: "flipcash.onramp-coordinator") @Observable final class OnrampCoordinator { + /// Coinbase Onramp's USD floor — orders below this amount fail with a + /// generic error, so the buy flow gates ahead of the Apple Pay sheet. + static let minimumPurchaseUSD: Decimal = 5 + // MARK: - State - /// Apple Pay order — drives the invisible WebView overlay hosted at root. diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 046f4ccc..ffe19830 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -16,13 +16,10 @@ extension View { /// nested sheets to be presented from within the parent sheet's content, /// not as siblings at the app root. /// - /// Currently supports one level of nesting only. A previous version - /// recursed inside the mounted sheet's content to support depth-3+, but a - /// dormant inner `.sheet(item:)` swallows `interactiveDismissDisabled` - /// from descendants before it reaches the actual depth-1 sheet host, - /// re-enabling swipe-dismiss on screens that opt out. Until the recursion - /// is replaced with a presentedSheets-aware conditional mount, depth-3+ - /// would need the caller to apply this modifier explicitly. + /// Supports one level of nesting; depth-3+ would need a presentedSheets- + /// aware conditional mount. A previous recursive version mounted a dormant + /// inner `.sheet(item:)` that swallowed `interactiveDismissDisabled` from + /// descendants, re-enabling swipe-dismiss on screens that opted out. func appRouterNestedSheet(container: Container, sessionContainer: SessionContainer) -> some View { modifier(AppRouterNestedSheetModifier(container: container, sessionContainer: sessionContainer)) } diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index b314a9f7..737855a6 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -113,11 +113,12 @@ private struct ApplePayMethodButton: View { var body: some View { Button { - // Coinbase Onramp rejects USD purchases under $5 — gate before - // the Apple Pay sheet round-trip. Use the USDF (1:1 USD) value - // since `nativeAmount` is in the user's display currency. - guard context.amount.usdfValue.value >= 5 else { - let minimum = FiatAmount.usd(5) + // Coinbase Onramp rejects USD purchases under the minimum — gate + // before the Apple Pay sheet round-trip. Use the USDF (1:1 USD) + // value since `nativeAmount` is in the user's display currency. + let minimumUSD = OnrampCoordinator.minimumPurchaseUSD + guard context.amount.usdfValue.value >= minimumUSD else { + let minimum = FiatAmount.usd(minimumUSD) .converting(to: context.amount.currencyRate) .formatted() session.dialogItem = .applePayMinimumPurchase(minimum: minimum) diff --git a/Flipcash/UI/DialogItem+Buy.swift b/Flipcash/UI/DialogItem+Buy.swift deleted file mode 100644 index 7b26782a..00000000 --- a/Flipcash/UI/DialogItem+Buy.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// DialogItem+Buy.swift -// Flipcash -// -// Created by Raul Riera on 2026-05-13. -// - -import FlipcashUI - -extension DialogItem { - - /// Coinbase Onramp rejects orders below $5 USD with a generic error; - /// surface the constraint up-front instead of letting the user round-trip - /// to Apple Pay. `minimum` is the $5 USD equivalent already formatted in - /// the user's selected display currency. - static func applePayMinimumPurchase(minimum: String) -> DialogItem { - .init( - style: .destructive, - title: "\(minimum) Minimum Purchase", - subtitle: "Please enter an amount of \(minimum) or higher", - dismissable: true - ) { - .okay(kind: .destructive) - } - } -} diff --git a/Flipcash/UI/DialogItem+CashFlows.swift b/Flipcash/UI/DialogItem+CashFlows.swift index 61a594f8..0492aa7b 100644 --- a/Flipcash/UI/DialogItem+CashFlows.swift +++ b/Flipcash/UI/DialogItem+CashFlows.swift @@ -79,4 +79,19 @@ extension DialogItem { .cancel() } } + + /// Coinbase Onramp rejects orders below `OnrampCoordinator.minimumPurchaseUSD` + /// with a generic error; surface the constraint up-front instead of letting + /// the user round-trip to Apple Pay. `minimum` is the USD floor already + /// formatted in the user's selected display currency. + static func applePayMinimumPurchase(minimum: String) -> DialogItem { + .init( + style: .destructive, + title: "\(minimum) Minimum Purchase", + subtitle: "Please enter an amount of \(minimum) or higher", + dismissable: true + ) { + .okay(kind: .destructive) + } + } } From 9bbaa3fa1d47d3459da298d5b02e3cee988087a6 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 16:48:06 -0400 Subject: [PATCH 30/33] feat: USDF Currency Info Deposit/Withdraw and normalize balance row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface fast paths into deposit and withdraw on the USDF info screen so users don't have to discover them through settings. Treat USDF as a regular row in the wallet — same look, same tap target — to match how every other balance is shown. Align the manual USDC deposit address with Coinbase's destination so both onramps land at the same on-chain location. Drop the "Debit Card with" prefix on the Apple Pay button. --- .../Navigation/AppRouter+Destination.swift | 20 ++++- .../AppRouter+DestinationView.swift | 13 +++ .../Navigation/AppRouter+NestedSheet.swift | 5 ++ .../Core/Screens/Main/BalanceScreen.swift | 40 ++++----- .../Main/Buy/BuyFlowDestinationView.swift | 4 - .../Core/Screens/Main/Buy/BuyFlowPath.swift | 2 - .../Main/Buy/PurchaseMethodSheet.swift | 14 +--- .../Main/Buy/USDCDepositAddressScreen.swift | 14 ++-- .../Main/Buy/USDCDepositEducationScreen.swift | 13 ++- .../Core/Screens/Main/CashReservesRow.swift | 41 --------- .../Currency Info/CurrencyInfoScreen.swift | 29 +++++-- .../Screens/Main/SelectCurrencyScreen.swift | 17 ++-- .../Withdraw/PreselectedWithdrawRoot.swift | 41 +++++++++ .../Settings/Withdraw/WithdrawScreen.swift | 83 ++++++++++++------- .../Buy/PurchaseMethodSheetTests.swift | 12 +-- .../Navigation/AppRouterCrossStackTests.swift | 21 +++++ .../AppRouterNestedSheetTests.swift | 13 +++ FlipcashTests/SessionTests.swift | 24 ++++++ FlipcashTests/WithdrawViewModelTests.swift | 12 +++ .../Support/Screens/WalletScreen.swift | 13 ++- 20 files changed, 278 insertions(+), 153 deletions(-) delete mode 100644 Flipcash/Core/Screens/Main/CashReservesRow.swift create mode 100644 Flipcash/Core/Screens/Settings/Withdraw/PreselectedWithdrawRoot.swift diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 3da48dcb..50bfd945 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -26,6 +26,18 @@ extension AppRouter { case currencyCreationWizard case transactionHistory(PublicKey) case give(PublicKey) + /// Skips the picker step in the withdraw flow and lands the user on + /// `WithdrawIntroScreen` with the currency pre-selected. Pushed from + /// USDF Currency Info on the Wallet sheet. + case withdrawCurrency(PublicKey) + /// USDC → USDF deposit education screen. Reached from USDF Currency + /// Info on the Wallet sheet and from the buy flow's Other Wallet path + /// on the `.buy` sheet — same screen, different entry points. + case usdcDepositEducation + /// USDC → USDF deposit address screen (shows the per-user timelock + /// swap PDA's USDC ATA). Reached as the next step after + /// `.usdcDepositEducation`. + case usdcDepositAddress // Settings flow case settingsMyAccount @@ -45,7 +57,8 @@ extension AppRouter { switch self { case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, - .transactionHistory, .give: + .transactionHistory, .give, .withdrawCurrency, + .usdcDepositEducation, .usdcDepositAddress: return .balance case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, @@ -68,6 +81,9 @@ extension AppRouter { case .currencyCreationWizard: "currencyCreationWizard" case .transactionHistory: "transactionHistory" case .give: "give" + case .withdrawCurrency: "withdrawCurrency" + case .usdcDepositEducation: "usdcDepositEducation" + case .usdcDepositAddress: "usdcDepositAddress" case .settingsMyAccount: "settingsMyAccount" case .settingsAdvancedFeatures: "settingsAdvancedFeatures" case .settingsAppSettings: "settingsAppSettings" @@ -90,9 +106,11 @@ extension AppRouter { .currencyInfoForDeposit(let mint), .transactionHistory(let mint), .give(let mint), + .withdrawCurrency(let mint), .deposit(let mint): return mint.base58 case .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, + .usdcDepositEducation, .usdcDepositAddress, .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs, .accessKey, .depositCurrencyList, .withdraw: diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 75fe8f31..0f15f158 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -138,6 +138,19 @@ struct DestinationView: View { container: container, sessionContainer: sessionContainer ) + + case .withdrawCurrency(let mint): + PreselectedWithdrawRoot( + mint: mint, + container: container, + sessionContainer: sessionContainer + ) + + case .usdcDepositEducation: + USDCDepositEducationScreen() + + case .usdcDepositAddress: + USDCDepositAddressScreen() } } } diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index ffe19830..c5e29717 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -116,6 +116,11 @@ private struct BuySheetRoot: View { // success. BuyAmountScreen itself dismisses via the same env value // through its toolbar close button. .environment(\.dismissParentContainer, { router.dismissSheet() }) + // Top-level `AppRouter.Destination` cases (e.g. `.usdcDepositEducation`, + // `.usdcDepositAddress`) are pushed from the Other Wallet path. They + // share the same screens reached from the Wallet sheet, so register + // the app-wide destination map here too. + .appRouterDestinations(container: container, sessionContainer: sessionContainer) } } } diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift index 6064bea6..386a60f2 100644 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ b/Flipcash/Core/Screens/Main/BalanceScreen.swift @@ -28,19 +28,8 @@ struct BalanceScreen: View { /// positions instead of popping when the sort order shuffles. @Namespace private var balanceRowNamespace - /// USDF is surfaced separately via `reservesBalance`. - private var currencyBalances: [ExchangedBalance] { - sortedBalances.filter { $0.stored.mint != .usdf } - } - - /// `nil` for a zero balance so the reserves row is hidden when there's - /// nothing to show. - private var reservesBalance: ExchangedBalance? { - sortedBalances.first { $0.stored.mint == .usdf && $0.stored.quarks > 0 } - } - private var hasBalances: Bool { - !currencyBalances.isEmpty || reservesBalance != nil + !sortedBalances.isEmpty } private var balance: ExchangedFiat { @@ -55,7 +44,7 @@ struct BalanceScreen: View { } /// Takes balances by parameter so callers can pass a cached snapshot and - /// avoid re-filtering `currencyBalances` on every body evaluation. + /// avoid re-iterating `sortedBalances` on every body evaluation. private func computeAppreciation(for balances: [ExchangedBalance]) -> (amount: FiatAmount, isPositive: Bool) { var totalAppreciation: Decimal = 0 @@ -133,7 +122,7 @@ struct BalanceScreen: View { } @ViewBuilder private func list() -> some View { - let appreciation = computeAppreciation(for: currencyBalances) + let appreciation = computeAppreciation(for: sortedBalances) // ScrollView ignores the bottom safe area so the section footer pins to // the very bottom of the screen — the gradient can then fade out @@ -153,24 +142,17 @@ struct BalanceScreen: View { .padding(.vertical, 30) if hasBalances { - ForEach(currencyBalances) { balance in - CurrencyBalanceRow(exchangedBalance: balance) { + ForEach(sortedBalances) { balance in + CurrencyBalanceRow( + exchangedBalance: balance, + accessibilityIdentifier: balance.stored.mint == .usdf ? "currency-row-usdf" : "currency-row" + ) { Analytics.tokenInfoOpened(from: .openedFromWallet, mint: balance.stored.mint) router.push(.currencyInfo(balance.stored.mint)) } .vSeparator(color: .rowSeparator) .matchedGeometryEffect(id: balance.id, in: balanceRowNamespace) } - - if let reservesBalance, reservesBalance.exchangedFiat.hasDisplayableValue() { - CashReservesRow( - reservesBalance: reservesBalance, - showTopDivider: currencyBalances.isEmpty, - onTap: { - router.push(.currencyInfo(reservesBalance.stored.mint)) - } - ) - } } else { emptyState() } @@ -220,6 +202,12 @@ struct ExchangedBalance: Identifiable, Hashable { } } +extension StoredBalance { + func exchanged(with rate: Rate) -> ExchangedBalance { + ExchangedBalance(stored: self, exchangedFiat: computeExchangedValue(with: rate)) + } +} + private struct BalanceHeaderButton: View { let balance: ExchangedFiat diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift index dcf5739f..9deab3c9 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift @@ -20,10 +20,6 @@ struct BuyFlowDestinationView: View { PhantomEducationScreen(mint: mint, amount: amount) case .phantomConfirm(let mint, let amount): PhantomConfirmScreen(mint: mint, amount: amount) - case .usdcDepositEducation(let mint, let amount): - USDCDepositEducationScreen(mint: mint, amount: amount) - case .usdcDepositAddress(let mint, let amount): - USDCDepositAddressScreen(mint: mint, amount: amount) case .processing(let swapId, let currencyName, let amount, let swapType): SwapProcessingScreen( swapId: swapId, diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift index 1ca19f41..0dfc8a86 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -20,7 +20,5 @@ import FlipcashCore enum BuyFlowPath: Hashable, Sendable { case phantomEducation(mint: PublicKey, amount: ExchangedFiat) case phantomConfirm(mint: PublicKey, amount: ExchangedFiat) - case usdcDepositEducation(mint: PublicKey, amount: ExchangedFiat) - case usdcDepositAddress(mint: PublicKey, amount: ExchangedFiat) case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat, swapType: SwapType) } diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index 737855a6..6bd37a5a 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -83,7 +83,7 @@ private struct MethodButton: View { case .phantom: PhantomMethodButton(context: context, onDismiss: onDismiss, router: router) case .otherWallet: - OtherWalletMethodButton(context: context, onDismiss: onDismiss, router: router) + OtherWalletMethodButton(onDismiss: onDismiss, router: router) } } } @@ -132,11 +132,8 @@ private struct ApplePayMethodButton: View { coordinator.start(.buy(mint: mint, displayName: displayName), amount: amount) } } label: { - HStack(spacing: 4) { - Text("Debit Card with") - Text("\u{F8FF}Pay") - .font(.body.bold()) - } + Text("\u{F8FF}Pay") + .font(.body.bold()) } .buttonStyle(.filled) } @@ -177,16 +174,13 @@ private struct PhantomMethodButton: View { } private struct OtherWalletMethodButton: View { - let context: PurchaseMethodContext let onDismiss: () -> Void let router: AppRouter var body: some View { Button("Other Wallet") { - let mint = context.mint - let amount = context.amount dismissThenDispatch(onDismiss: onDismiss) { [router] in - router.pushAny(BuyFlowPath.usdcDepositEducation(mint: mint, amount: amount)) + router.push(.usdcDepositEducation) } } .buttonStyle(.filled) diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift index 831f1dd7..6cc96a1c 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift @@ -9,15 +9,13 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// Displays the per-user USDC deposit address — the USDF swap PDA's USDC ATA. -/// Anything received here is auto-converted 1:1 to USDF on receipt by the -/// server-side watcher. Mirrors the established settings `DepositScreen` -/// pattern (`ImmutableField` + `CodeButton` with `.successText("Copied")`). +/// Displays the per-user USDC deposit address — the USDF swap PDA itself, +/// matching the address Coinbase Onramp sends to. Anything received here is +/// auto-converted 1:1 to USDF on receipt by the server-side watcher. Mirrors +/// the established settings `DepositScreen` pattern (`ImmutableField` + +/// `CodeButton` with `.successText("Copied")`). struct USDCDepositAddressScreen: View { - let mint: PublicKey - let amount: ExchangedFiat - @Environment(Session.self) private var session @State private var buttonState: ButtonState = .normal @State private var depositAddress: String? @@ -66,7 +64,7 @@ struct USDCDepositAddressScreen: View { // so `.onAppear` is the right tool — `.task` would be misleading. depositAddress = MintMetadata.usdf .timelockSwapAccounts(owner: session.owner.authorityPublicKey)? - .ata.publicKey.base58 + .pda.publicKey.base58 } } diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift index 9bf1a3eb..4ff0b029 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift @@ -9,15 +9,12 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// Pre-flight for the "Other Wallet" path: explains that incoming Solana USDC -/// is auto-converted 1:1 to USDF on receipt. Tapping Next pushes -/// `BuyFlowPath.usdcDepositAddress` so the user can copy the destination -/// address. +/// Pre-flight for the USDC → USDF conversion: explains that incoming Solana +/// USDC is auto-converted 1:1 to USDF on receipt. Reached from the buy flow's +/// "Other Wallet" path and from the USDF Currency Info screen's "Deposit" +/// button — same screen, same destination address regardless of entry point. struct USDCDepositEducationScreen: View { - let mint: PublicKey - let amount: ExchangedFiat - @Environment(AppRouter.self) private var router var body: some View { @@ -45,7 +42,7 @@ struct USDCDepositEducationScreen: View { Spacer() Button("Next") { - router.pushAny(BuyFlowPath.usdcDepositAddress(mint: mint, amount: amount)) + router.push(.usdcDepositAddress) } .buttonStyle(.filled) } diff --git a/Flipcash/Core/Screens/Main/CashReservesRow.swift b/Flipcash/Core/Screens/Main/CashReservesRow.swift deleted file mode 100644 index b5dbc50a..00000000 --- a/Flipcash/Core/Screens/Main/CashReservesRow.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// CashReservesRow.swift -// Flipcash -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -struct CashReservesRow: View { - let reservesBalance: ExchangedBalance - let showTopDivider: Bool - let onTap: () -> Void - - var body: some View { - Button { - Analytics.tokenInfoOpened(from: .openedFromWallet, mint: reservesBalance.stored.mint) - onTap() - } label: { - HStack(spacing: 8) { - Text("USDF") - .font(.appBarButton) - .foregroundStyle(Color.textSecondary) - - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.textSecondary) - - Spacer() - - Text(reservesBalance.exchangedFiat.nativeAmount.formatted()) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .contentTransition(.numericText()) - .animation(.default, value: reservesBalance.exchangedFiat.nativeAmount) - } - } - .padding(20) - .vSeparator(color: .rowSeparator, position: showTopDivider ? [.top, .bottom] : .bottom) - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 938ea9b8..17016a3b 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -104,7 +104,9 @@ struct CurrencyInfoScreen: View { session: session, ratesController: ratesController ) - } + }, + onDeposit: { router.push(.usdcDepositEducation) }, + onWithdraw: { router.push(.withdrawCurrency(mint)) } ) case .error(let error): CurrencyInfoErrorView(error: error) { @@ -189,6 +191,8 @@ private struct LoadedContent: View { let onBuy: () -> Void let onGive: () -> Void let onSell: () -> Void + let onDeposit: () -> Void + let onWithdraw: () -> Void private var isUSDF: Bool { metadata.mint == .usdf @@ -248,16 +252,29 @@ private struct LoadedContent: View { currencyCode: ratesController.balanceCurrency, marketCapController: marketCapController ) - - Color - .clear - .padding(.bottom, 100) } + + // Reserve space so the floating footer doesn't overlap + // scrolled content. + Color + .clear + .padding(.bottom, 100) } } // Floating Footer - if !isUSDF { + if isUSDF { + CurrencyInfoFooter { + Button("Deposit") { + onDeposit() + } + .buttonStyle(.filled) + + CodeButton(style: .filledSecondary, title: "Withdraw") { + onWithdraw() + } + } + } else { CurrencyInfoFooter { Button("Buy") { onBuy() diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index 45ba77a6..6e927448 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -92,17 +92,24 @@ extension SelectCurrencyScreen { } struct CurrencyBalanceRow: View { - + let exchangedBalance: ExchangedBalance + let accessibilityIdentifier: String let action: (() -> Void)? let showSelected: Bool? - - init(exchangedBalance: ExchangedBalance, showSelected: Bool? = nil, action: (() -> Void)? = nil) { + + init( + exchangedBalance: ExchangedBalance, + accessibilityIdentifier: String = "currency-row", + showSelected: Bool? = nil, + action: (() -> Void)? = nil + ) { self.exchangedBalance = exchangedBalance + self.accessibilityIdentifier = accessibilityIdentifier self.action = action self.showSelected = showSelected } - + var body: some View { Button { action?() @@ -114,7 +121,7 @@ struct CurrencyBalanceRow: View { isSelected: showSelected, ) } - .accessibilityIdentifier("currency-row") + .accessibilityIdentifier(accessibilityIdentifier) .disabled(action == nil) .listRowBackground(Color.clear) .padding(.horizontal, 20) diff --git a/Flipcash/Core/Screens/Settings/Withdraw/PreselectedWithdrawRoot.swift b/Flipcash/Core/Screens/Settings/Withdraw/PreselectedWithdrawRoot.swift new file mode 100644 index 00000000..b4182600 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/Withdraw/PreselectedWithdrawRoot.swift @@ -0,0 +1,41 @@ +// +// PreselectedWithdrawRoot.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-13. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// Withdraw flow entry point that skips the "Select Currency" picker. +/// Configures `WithdrawViewModel` with the mint before `@State` boxes it, +/// so the intro screen renders with `kind` already populated. +struct PreselectedWithdrawRoot: View { + + @Environment(AppRouter.self) private var router + @State private var viewModel: WithdrawViewModel + + init(mint: PublicKey, container: Container, sessionContainer: SessionContainer) { + let vm = WithdrawViewModel(container: container, sessionContainer: sessionContainer) + if let stored = sessionContainer.session.balance(for: mint) { + let rate = sessionContainer.ratesController.rateForBalanceCurrency() + vm.selectCurrency(stored.exchanged(with: rate)) + } + self._viewModel = State(wrappedValue: vm) + } + + var body: some View { + WithdrawIntroScreen() + .withdrawSubstepDestinations(viewModel: viewModel) + .onAppear { + viewModel.pushSubstep = { step in + router.pushAny(step) + } + viewModel.onComplete = { + router.popToRoot(on: .balance) + } + } + } +} diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift index d87e47f2..c85173a0 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift @@ -56,37 +56,7 @@ struct WithdrawScreen: View { } .navigationTitle("Select Currency") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: WithdrawNavigationPath.self) { path in - switch path { - case .intro: - WithdrawIntroScreen() - .dialog(item: $viewModel.dialogItem) - case .enterAmount: - WithdrawAmountScreen( - title: "Withdraw", - enteredAmount: $viewModel.enteredAmount, - subtitle: viewModel.amountSubtitle, - canProceed: viewModel.canProceedToAddress, - onProceed: viewModel.amountEnteredAction, - showsCurrencySelection: true - ) - .dialog(item: $viewModel.dialogItem) - case .enterAddress: - WithdrawAddressScreen( - promptCurrencyName: viewModel.kind?.destinationCurrencyName ?? "funds", - enteredAddress: $viewModel.enteredAddress, - destinationMetadata: viewModel.destinationMetadata, - acceptsTokenAccount: viewModel.kind?.acceptsTokenAccount ?? true, - canCompleteWithdrawal: viewModel.canCompleteWithdrawal, - onPasteFromClipboard: viewModel.pasteFromClipboardAction, - onNext: viewModel.addressEnteredAction - ) - .dialog(item: $viewModel.dialogItem) - case .confirmation: - WithdrawSummaryScreen(viewModel: viewModel) - .dialog(item: $viewModel.dialogItem) - } - } + .withdrawSubstepDestinations(viewModel: viewModel) .onAppear { viewModel.pushSubstep = { step in router.pushAny(step) @@ -100,3 +70,54 @@ struct WithdrawScreen: View { } } } + +extension View { + + /// Registers the four `WithdrawNavigationPath` substeps on the enclosing + /// `NavigationStack`. Shared between `WithdrawScreen` (Settings entry — + /// picker first) and `PreselectedWithdrawRoot` (Wallet entry — picker + /// skipped), so both code paths render identical substep screens. + func withdrawSubstepDestinations(viewModel: WithdrawViewModel) -> some View { + navigationDestination(for: WithdrawNavigationPath.self) { path in + WithdrawSubstepDestination(path: path, viewModel: viewModel) + } + } +} + +private struct WithdrawSubstepDestination: View { + + let path: WithdrawNavigationPath + @Bindable var viewModel: WithdrawViewModel + + var body: some View { + switch path { + case .intro: + WithdrawIntroScreen() + .dialog(item: $viewModel.dialogItem) + case .enterAmount: + WithdrawAmountScreen( + title: "Withdraw", + enteredAmount: $viewModel.enteredAmount, + subtitle: viewModel.amountSubtitle, + canProceed: viewModel.canProceedToAddress, + onProceed: viewModel.amountEnteredAction, + showsCurrencySelection: true + ) + .dialog(item: $viewModel.dialogItem) + case .enterAddress: + WithdrawAddressScreen( + promptCurrencyName: viewModel.kind?.destinationCurrencyName ?? "funds", + enteredAddress: $viewModel.enteredAddress, + destinationMetadata: viewModel.destinationMetadata, + acceptsTokenAccount: viewModel.kind?.acceptsTokenAccount ?? true, + canCompleteWithdrawal: viewModel.canCompleteWithdrawal, + onPasteFromClipboard: viewModel.pasteFromClipboardAction, + onNext: viewModel.addressEnteredAction + ) + .dialog(item: $viewModel.dialogItem) + case .confirmation: + WithdrawSummaryScreen(viewModel: viewModel) + .dialog(item: $viewModel.dialogItem) + } + } +} diff --git a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift index 09583d77..1bd28dde 100644 --- a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift +++ b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift @@ -46,7 +46,7 @@ struct PurchaseMethodSheetTests { return container } - @Test("Apple Pay row hidden when hasCoinbaseOnramp is false") + @Test("Apple Pay row hidden when hasCoinbaseOnramp is false; remaining order is Phantom then Other Wallet") func applePayHiddenWhenNoCoinbase() throws { try Self.withCoinbaseBetaFlag(enabled: false) { let container = try SessionContainer.makeTest(holdings: []) @@ -54,22 +54,18 @@ struct PurchaseMethodSheetTests { let methods = PurchaseMethodSheet.methods(forSession: container.session) - #expect(!methods.contains(.applePay)) - #expect(methods.contains(.phantom)) - #expect(methods.contains(.otherWallet)) + #expect(methods == [.phantom, .otherWallet]) } } - @Test("Apple Pay row visible and ordered first when hasCoinbaseOnramp is true") + @Test("Apple Pay row visible and ordered first when hasCoinbaseOnramp is true; full order is Apple Pay → Phantom → Other Wallet") func applePayVisibleAndFirst() throws { try Self.withCoinbaseBetaFlag(enabled: false) { let container = try Self.makeContainerWithCoinbase() let methods = PurchaseMethodSheet.methods(forSession: container.session) - #expect(methods.first == .applePay) - #expect(methods.contains(.phantom)) - #expect(methods.contains(.otherWallet)) + #expect(methods == [.applePay, .phantom, .otherWallet]) } } } diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index f6a9f6e6..871ff420 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -92,6 +92,9 @@ struct AppRouterCrossStackTests { (AppRouter.Destination.currencyCreationWizard, AppRouter.Stack.balance), (AppRouter.Destination.transactionHistory(.usdc), AppRouter.Stack.balance), (AppRouter.Destination.give(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.withdrawCurrency(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.usdcDepositEducation, AppRouter.Stack.balance), + (AppRouter.Destination.usdcDepositAddress, AppRouter.Stack.balance), (AppRouter.Destination.settingsMyAccount, AppRouter.Stack.settings), (AppRouter.Destination.settingsAdvancedFeatures, AppRouter.Stack.settings), (AppRouter.Destination.settingsAppSettings, AppRouter.Stack.settings), @@ -111,6 +114,24 @@ struct AppRouterCrossStackTests { #expect(destination.owningStack == expected) } + @Test( + "Destination.payload exposes the mint for mint-bearing cases", + arguments: [ + (AppRouter.Destination.withdrawCurrency(.usdc), PublicKey.usdc.base58 as String?), + (AppRouter.Destination.deposit(.usdc), PublicKey.usdc.base58 as String?), + (AppRouter.Destination.currencyInfo(.usdf), PublicKey.usdf.base58 as String?), + (AppRouter.Destination.usdcDepositEducation, nil), + (AppRouter.Destination.usdcDepositAddress, nil), + (AppRouter.Destination.withdraw, nil), + ] + ) + func destination_payloadIsCorrect( + _ destination: AppRouter.Destination, + expected: String? + ) { + #expect(destination.payload == expected) + } + @Test( "Stack maps to its sheet presentation", arguments: [ diff --git a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift index e5b5c1de..1b731cff 100644 --- a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift @@ -295,6 +295,19 @@ struct AppRouterNestedSheetTests { #expect(router[.balance].isEmpty, "root stack stays clean") } + @Test("top-level Destination push while .buy is nested lands on .buy stack") + func push_topLevelDestination_whileBuyNested_landsOnBuyStack() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.push(.usdcDepositEducation) + router.push(.usdcDepositAddress) + + #expect(router[.buy] == AppRouter.navigationPath(.usdcDepositEducation, .usdcDepositAddress)) + #expect(router[.balance].isEmpty, "balance stack stays clean") + } + // MARK: - Buy sheet wiring @Test(".buy(mint) sheet maps to .buy stack") diff --git a/FlipcashTests/SessionTests.swift b/FlipcashTests/SessionTests.swift index cc70c4c8..df6c5b3b 100644 --- a/FlipcashTests/SessionTests.swift +++ b/FlipcashTests/SessionTests.swift @@ -554,6 +554,30 @@ struct SessionSellVerifiedStateTests { // MARK: - +@MainActor +@Suite("Session.balances(for:) USDF inclusion") +struct SessionBalancesUSDFInclusionTests { + + @Test("USDF appears in balances(for:) even with zero quarks — pins the BalanceScreen normalization invariant") + func balances_includesUSDF_atZero() throws { + let container = try SessionContainer.makeTest(holdings: [ + .init(mint: .usdf, quarks: 0), + .init( + mint: .makeLaunchpad( + address: .jeffy, + supplyFromBonding: 1_000_000 * 10_000_000_000 + ), + quarks: 10 * 10_000_000_000 + ), + ]) + + let listed = container.session.balances(for: .oneToOne) + let mints = listed.map(\.stored.mint) + #expect(mints.contains(.usdf), "USDF must appear regardless of zero quarks") + #expect(mints.contains(.jeffy), "non-USDF balances continue to appear") + } +} + @MainActor @Suite("Session.withdraw verified state") struct SessionWithdrawVerifiedStateTests { diff --git a/FlipcashTests/WithdrawViewModelTests.swift b/FlipcashTests/WithdrawViewModelTests.swift index 8390e215..34301db2 100644 --- a/FlipcashTests/WithdrawViewModelTests.swift +++ b/FlipcashTests/WithdrawViewModelTests.swift @@ -412,6 +412,18 @@ struct WithdrawViewModelTests { #expect(pushed == [.enterAmount]) } + @Test("selectCurrency before pushSubstep is wired sets kind without pushing — PreselectedWithdrawRoot contract") + func selectCurrency_withoutPushSubstepWired_setsKindWithoutPushing() { + let viewModel = WithdrawViewModelTestHelpers.createViewModel() + // Intentionally do NOT wire pushSubstep — mirrors PreselectedWithdrawRoot.init + // calling selectCurrency before .onAppear wires the closure. + let balance = WithdrawViewModelTestHelpers.createExchangedBalance(mint: .usdf) + + viewModel.selectCurrency(balance) + + #expect(viewModel.kind == .usdfToUsdc(balance)) + } + // MARK: - prepareSubmission pin-at-compute @Test("prepareSubmission (USDF→USDC) computes quarks from the PINNED rate, not the live cache") diff --git a/FlipcashUITests/Support/Screens/WalletScreen.swift b/FlipcashUITests/Support/Screens/WalletScreen.swift index f360caf9..0334d868 100644 --- a/FlipcashUITests/Support/Screens/WalletScreen.swift +++ b/FlipcashUITests/Support/Screens/WalletScreen.swift @@ -18,13 +18,20 @@ struct WalletScreen { // MARK: - Elements - /// The first currency row. Identified by the row's accessibility identifier - /// rather than `app.cells`, since the wallet uses a `ScrollView` + `LazyVStack` - /// rather than a `List`. + /// The first non-USDF currency row. Identified by the row's accessibility + /// identifier rather than `app.cells`, since the wallet uses a `ScrollView` + /// + `LazyVStack` rather than a `List`. The USDF row carries the + /// distinct identifier "currency-row-usdf" so this selector reliably + /// targets an investable token regardless of where USDF sorts. var firstCurrencyRow: XCUIElement { app.buttons.matching(identifier: "currency-row").firstMatch } + /// The USDF row in the wallet. Always present once balances have synced. + var usdfRow: XCUIElement { + app.buttons["currency-row-usdf"] + } + /// The balance header button that shows the flag + total amount + chevron. var balanceHeader: XCUIElement { app.buttons["balance-header"] } From 08288cd4c2694f9b7158b357f1451ce2d528e469 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Thu, 14 May 2026 10:13:52 -0400 Subject: [PATCH 31/33] refactor: BadgedIcon uses a fixed size across all call sites Bake the size, badge size, and offset into BadgedIcon so paired icons look identical wherever they appear. Drop the per-call-site sizing parameters at the two existing usages. --- .../Main/Buy/USDCDepositEducationScreen.swift | 4 +--- .../Withdraw/WithdrawIntroScreen.swift | 4 +--- .../Sources/FlipcashUI/Views/BadgedIcon.swift | 24 ++++++++++--------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift index 4ff0b029..139ff2b7 100644 --- a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift @@ -58,9 +58,7 @@ private struct ConversionGraphic: View { HStack(spacing: 16) { BadgedIcon( icon: Image.asset(.buyUSDC), - badge: Image.asset(.buySolana), - size: 100, - badgeSize: 32 + badge: Image.asset(.buySolana) ) Image.system(.arrowRight) diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift index b5c0df4a..30c1d2d1 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift @@ -60,9 +60,7 @@ private struct ConversionGraphic: View { BadgedIcon( icon: Image.asset(.buyUSDC), - badge: Image.asset(.buySolana), - size: 100, - badgeSize: 32 + badge: Image.asset(.buySolana) ) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift b/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift index dc5afe06..8a17684f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift @@ -10,25 +10,18 @@ import SwiftUI /// asset (USDC, Phantom) gets paired with a network/state marker (Solana hex, /// checkmark) without baking the badge into the parent SVG. public struct BadgedIcon: View { - private let icon: Image private let badge: Image? - private let size: CGFloat - private let badgeSize: CGFloat - private let badgeOffset: CGSize + private let size: CGFloat = 100 + private let badgeSize: CGFloat = 38 + private let badgeOffset: CGPoint = CGPoint(x: 8, y: 8) public init( icon: Image, badge: Image? = nil, - size: CGFloat = 80, - badgeSize: CGFloat = 28, - badgeOffset: CGSize = CGSize(width: 4, height: 4) ) { self.icon = icon self.badge = badge - self.size = size - self.badgeSize = badgeSize - self.badgeOffset = badgeOffset } public var body: some View { @@ -42,8 +35,17 @@ public struct BadgedIcon: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: badgeSize, height: badgeSize) - .offset(x: badgeOffset.width, y: badgeOffset.height) + .offset(x: badgeOffset.x, y: badgeOffset.y) } } } } + +#Preview { + BadgedIcon(icon: Image.asset(.buyPhantom)) + + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana) + ) +} From 12a913d3d66a6337763d121ef0426f4ebb0f5c83 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Thu, 14 May 2026 10:17:37 -0400 Subject: [PATCH 32/33] fix: keep empty state on the wallet when only USDF is held USDF is now always present in `session.balances(for:)` after sync, so a brand-new user without any other tokens used to land on a list with a single $0 USDF row. Gate the wallet on non-USDF holdings so those users still see the "Buy your first currency to get started" CTA. --- Flipcash/Core/Screens/Main/BalanceScreen.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift index 386a60f2..1f616d32 100644 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ b/Flipcash/Core/Screens/Main/BalanceScreen.swift @@ -28,8 +28,12 @@ struct BalanceScreen: View { /// positions instead of popping when the sort order shuffles. @Namespace private var balanceRowNamespace + /// USDF is always present in `session.balances(for:)` after sync, so an + /// account with no purchased currencies still has USDF in the list. Gate + /// on a non-USDF holding so the empty state ("Buy your first currency to + /// get started") still appears for that brand-new user. private var hasBalances: Bool { - !sortedBalances.isEmpty + sortedBalances.contains { $0.stored.mint != .usdf } } private var balance: ExchangedFiat { From 2e0435a974c4c5015fdc19ac6901feb5eed619ba Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Thu, 14 May 2026 17:01:56 -0400 Subject: [PATCH 33/33] refactor: extract PhantomCoordinator and unify funding picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Phantom operation lifecycle, state machine, and signed-tx handling out of WalletConnection into a dedicated PhantomCoordinator. WalletConnection becomes a session+signing service that yields deeplink events through an AsyncStream; the coordinator subscribes and runs simulation, server-notify, and chain-submit against its own pending-swap context. Generalize PurchaseMethodSheet around a shared PaymentOperation enum so the currency-creation wizard reuses the same picker as buy. USDF gate fires ahead of the picker for both flows; FundingSelectionSheet is deleted. Always-connect: tapping "Connect Your Phantom Wallet" on the education screen always deeplinks Phantom for connect, even when a Keychain session exists — Phantom auto-approves for live sessions and prompts otherwise. Avoids "first transaction silently fails" when a user revoked the dApp between flows. User-cancel during connect or sign now surfaces a dialog and restores the screen to a retry-ready state. --- .../Deep Links/Wallet/WalletConnection.swift | 453 +++----------- .../Phantom/PhantomCoordinator.swift | 559 ++++++++++++++++++ Flipcash/Core/Models/PaymentOperation.swift | 78 +++ .../Navigation/AppRouter+Destination.swift | 14 +- .../AppRouter+DestinationView.swift | 6 + .../Screens/Main/Buy/BuyAmountScreen.swift | 37 +- .../Screens/Main/Buy/BuyAmountViewModel.swift | 6 +- .../Main/Buy/BuyFlowDestinationView.swift | 4 - .../Core/Screens/Main/Buy/BuyFlowPath.swift | 2 - .../Main/Buy/PhantomConfirmScreen.swift | 107 +--- .../Main/Buy/PhantomEducationScreen.swift | 88 +-- .../Main/Buy/PurchaseMethodContext.swift | 21 - .../Main/Buy/PurchaseMethodSheet.swift | 126 ++-- .../CurrencyCreationWizardScreen.swift | 157 ++--- .../Currency Info/FundingSelectionSheet.swift | 71 --- .../Currency Swap/SwapProcessingScreen.swift | 4 +- .../Core/Session/SessionAuthenticator.swift | 11 +- .../Buy/BuyAmountViewModelTests.swift | 35 +- .../Buy/PurchaseMethodSheetTests.swift | 28 +- .../AppRouterNestedSheetTests.swift | 25 +- .../Phantom/PhantomCoordinatorTests.swift | 172 ++++++ .../Regression_native_amount_mismatch.swift | 14 +- FlipcashTests/TestSupport/Mocks.swift | 1 + .../SessionContainer+TestSupport.swift | 1 + .../WalletConnectionStateTests.swift | 382 ------------ .../BuyPhantomRegressionTests.swift | 15 +- .../Screens/PhantomEducationScreen.swift | 8 +- .../Support/Screens/PurchaseMethodSheet.swift | 14 +- 28 files changed, 1257 insertions(+), 1182 deletions(-) create mode 100644 Flipcash/Core/Controllers/Phantom/PhantomCoordinator.swift create mode 100644 Flipcash/Core/Models/PaymentOperation.swift delete mode 100644 Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift delete mode 100644 Flipcash/Core/Screens/Main/Currency Info/FundingSelectionSheet.swift create mode 100644 FlipcashTests/Phantom/PhantomCoordinatorTests.swift delete mode 100644 FlipcashTests/WalletConnectionStateTests.swift diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift index e02d3bc2..13d162a0 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift @@ -18,86 +18,41 @@ public final class WalletConnection { var isShowingAmountEntry: Bool = false + /// General wallet-related dialogs (connect failures, key restoration). + /// Swap-flow dialogs are owned by `PhantomCoordinator`. var dialogItem: DialogItem? - /// Single source of truth for external-wallet processing state. Invalid - /// combinations — "cancelled flag set with no active context", "both - /// buy-existing and launch contexts populated simultaneously" — are - /// unrepresentable by construction. - var state: WalletProcessingState = .idle - - /// Buy-existing context, exposed for `.fullScreenCover(item:)`. Writing - /// `nil` (SwiftUI dismiss) transitions to `.idle` only if currently buying. - var processing: ExternalSwapProcessing? { - get { - if case .buying(let p, _) = state { return p } - return nil - } - set { - if newValue == nil, case .buying = state { - state = .idle - } - } - } - - /// Launch context, exposed for `.fullScreenCover(item:)`. Writing `nil` - /// (SwiftUI dismiss) transitions to `.idle` only if currently launching. - var launchProcessing: ExternalLaunchProcessing? { - get { - if case .launching(let l, _) = state { return l } - return nil - } - set { - if newValue == nil, case .launching = state { - state = .idle - } - } - } - - /// True iff the current context is marked failed. `SwapProcessingScreen` - /// observes this via `.onChange(of:)` to flip its display to cancelled. - var isProcessingCancelled: Bool { - switch state { - case .idle: return false - case .buying(_, let isFailed), .launching(_, let isFailed): return isFailed - } - } - - /// True while a signing request has been sent to the external wallet - /// (Phantom) but the user has not yet returned with a signed transaction. - /// Used by the buy nested sheet to block dismissal during the deeplink - /// round-trip, so the user can't accidentally close the flow and lose - /// the in-flight signature. - var isAwaitingExternalSwap: Bool { pendingSwap != nil } - private(set) var session: ConnectedWalletSession? var publicKey: FlipcashCore.PublicKey { box.publicKey } - private let box: Box - private let owner: AccountCluster - private let client: Client + /// Stream of deeplink events from Phantom — signed transactions and + /// errors. Consumed by `PhantomCoordinator` (constructed once per + /// session); finishes in `deinit` so the consumer Task exits cleanly. + let deeplinkEvents: AsyncStream + private let deeplinkContinuation: AsyncStream.Continuation + + /// Event yielded onto `deeplinkEvents` for each deeplink return. + enum DeeplinkEvent: Sendable { + /// Phantom returned with a signed transaction (base58-encoded). + case signed(String) + /// Phantom returned with code 4001 (user-cancel). + case userCancelled + /// Phantom returned with a non-cancel error code. + case failed(code: String) + } + private let box: Box + let owner: AccountCluster private let rpc: any SolanaRPC - /// Pending swap info to use when Phantom returns with signed transaction - private var pendingSwap: PendingSwap? - - /// Awaiting continuation for `connect()`. Resumed by `didConnect` on - /// success or by the errorCode branch of `didReceiveURL` on failure. + /// Awaiting continuation for `connect()` / `handshake()`. Resumed by + /// `didConnect` on success or by the errorCode branch of `didReceiveURL` + /// on failure. private var pendingConnect: CheckedContinuation? - /// What the signed-transaction handler should do after the user returns - struct PendingSwap { - /// Funding-leg swap id (USDC→USDF) — used in the Phantom memo. - let fundingSwapId: SwapId - let amount: ExchangedFiat - let displayName: String - let onCompleted: @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult - } - /// Whether the user has previously linked a Phantom wallet via /// `connectToPhantom`. Once true it stays true for the lifetime of the /// keychain entry; users can clear by uninstalling Phantom or wiping app data. @@ -107,11 +62,14 @@ public final class WalletConnection { // MARK: - Init - - init(owner: AccountCluster, client: Client, rpc: any SolanaRPC = SolanaJSONRPCClient()) { + init(owner: AccountCluster, rpc: any SolanaRPC = SolanaJSONRPCClient()) { self.owner = owner - self.client = client self.rpc = rpc + var continuation: AsyncStream.Continuation! + self.deeplinkEvents = AsyncStream { continuation = $0 } + self.deeplinkContinuation = continuation + if let connectedWalletSession = Keychain.connectedWalletSession { self.session = connectedWalletSession self.box = try! Box(secretKey: connectedWalletSession.secretKey) @@ -121,13 +79,18 @@ public final class WalletConnection { logger.info("New encryption box", metadata: ["publicKey": "\(box.publicKey.base58)"]) } } + + deinit { + // Closing the stream lets the coordinator's consumer Task exit cleanly + // when the SessionContainer (and thus this connection) tears down. + deeplinkContinuation.finish() + } // MARK: - Receive - func didReceiveURL(url: URL) { if let code = url.queryItemValue(for: "errorCode") { Analytics.track(event: Analytics.WalletEvent.cancel) - pendingSwap = nil let isUserCancel = code == "4001" if !isUserCancel { @@ -146,34 +109,27 @@ public final class WalletConnection { // If the error came from the connect deeplink and an async // awaiter is present, propagate the failure to them instead of - // showing the transaction-oriented dialog. 4001 (user-cancel) - // becomes a CancellationError so callers can silently abort; - // every other code becomes `connectFailed` so callers can - // surface an error. + // yielding a swap event. 4001 (user-cancel) becomes a + // CancellationError so callers can silently abort; every other + // code becomes `connectFailed` so callers can surface an error. if url.pathComponents.last == "walletConnected", let continuation = pendingConnect { pendingConnect = nil if isUserCancel { - continuation.resume(throwing: CancellationError()) + // Distinct from CancellationError so the coordinator can + // surface a "Connection Cancelled" dialog. CancellationError + // would route through the silent local-cancel path. + continuation.resume(throwing: WalletConnectionError.userCancelledConnect) } else { continuation.resume(throwing: WalletConnectionError.connectFailed(code: code)) } return } - if case .idle = state { - dialogItem = .init( - style: .destructive, - title: isUserCancel ? "Transaction Cancelled" : "Transaction Failed", - subtitle: isUserCancel - ? "The transaction was cancelled in your wallet" - : "Your wallet returned an error. Please try again.", - dismissable: true - ) { - .okay(kind: .destructive) - } - } else { - markCurrentStateFailed() - } + // Swap-flow error. Forward to the coordinator via the stream; + // the coordinator decides whether to mark the active context + // failed or surface a dialog (e.g., user tapped Phantom but + // pending state had cleared). + deeplinkContinuation.yield(isUserCancel ? .userCancelled : .failed(code: code)) return } @@ -258,248 +214,18 @@ public final class WalletConnection { } } + /// Yields the signed transaction to the deeplink stream. The + /// `PhantomCoordinator` consumes the stream and runs simulation + + /// server-notify + chain submission against its `pendingSwap` context. private func didSignTransaction(_ signedTx: String) { - let pending = pendingSwap - pendingSwap = nil - completeSwap(signedTx: signedTx, pending: pending) + deeplinkContinuation.yield(.signed(signedTx)) } - /// `pending` is passed explicitly (rather than read inside the Task) so - /// callers read+clear `self.pendingSwap` synchronously before the async - /// work begins — avoids a race if a second deep-link callback arrives - /// while the first is mid-flight. - @discardableResult - func completeSwap( - signedTx: String, - pending: PendingSwap? - ) -> Task { - Task { [rpc, weak self] in - guard let pending, let self else { - logger.warning("Received signed transaction but no pending swap context") - return - } - - let swapMetadata: [String: String] = [ - "swapId": pending.fundingSwapId.publicKey.base58, - "amount": pending.amount.nativeAmount.formatted(), - "name": pending.displayName, - ] - - let rawData = Data(Base58.toBytes(signedTx)) - guard let tx = SolanaTransaction(data: rawData) else { - logger.error("Failed to decode signed transaction") - ErrorReporting.captureError(Error.invalidURL, reason: "Failed to decode signed transaction", metadata: swapMetadata) - return - } - - // Preflight before server-notify + chain-submit so a tx that can't - // land (stale account state, unpayable fee) surfaces a user dialog - // instead of burning through the full flow. - let txBase64 = rawData.base64EncodedString() - switch await self.simulateSignedTransaction(txBase64, swapMetadata: swapMetadata) { - case .proceed: - break - case .blocked(let dialog): - self.dialogItem = dialog - return - } - - // Present the generic processing screen. The server callback below - // transitions to a launch context when the caller is launching a - // new currency; early-exit failures transition back to `.idle` so no - // cover presents for a swap the server never recorded. - self.state = .buying( - ExternalSwapProcessing( - swapId: pending.fundingSwapId, - currencyName: pending.displayName, - amount: pending.amount - ), - isFailed: false - ) - - // Notify server before submitting to chain — if the server rejects, - // skip chain submission entirely so no USDC is spent without a swap state. - do { - let result = try await pending.onCompleted(tx.identifier, pending.amount) - switch result { - case .buyExisting(let swapId): - if swapId != pending.fundingSwapId { - self.state = .buying( - ExternalSwapProcessing( - swapId: swapId, - currencyName: pending.displayName, - amount: pending.amount - ), - isFailed: false - ) - } - case .launch(let swapId, let mint): - self.state = .launching( - ExternalLaunchProcessing( - swapId: swapId, - launchedMint: mint, - currencyName: pending.displayName, - amount: pending.amount - ), - isFailed: false - ) - } - logger.info("Server notified of swap funding") - } catch { - logger.error("Server notification failed", metadata: ["error": "\(error)"]) - ErrorReporting.captureError(error, reason: "Server notification failed", metadata: swapMetadata) - self.state = .idle - return - } - - // Server accepted — submit transaction to chain. The swap id is - // server-recorded at this point, so on failure we keep the context - // and flip `isFailed` so the processing screen surfaces the error. - do { - let signature = try await rpc.sendTransaction( - txBase64, - configuration: SolanaSendTransactionConfig() - ) - logger.info("Transaction sent", metadata: ["signature": "\(signature.base58)"]) - Analytics.track(event: Analytics.WalletEvent.transactionsSubmitted) - } catch { - logger.error("Chain submission failed", metadata: ["error": "\(error)"]) - ErrorReporting.captureError(error, reason: "Chain submission failed", metadata: swapMetadata) - self.markCurrentStateFailed() - } - } - } - /// Opens the external wallet via deep link. private func openExternalWallet(_ url: URL) { url.openWithApplication() } - /// Transport failures (URL errors, decode errors) pass through as - /// `.proceed` — a flaky RPC blip must not block a user with valid funds. - /// Only explicit RPC rejections block. - func simulateSignedTransaction( - _ txBase64: String, - swapMetadata: [String: String] - ) async -> SimulationOutcome { - do { - _ = try await rpc.simulateTransaction( - txBase64, - configuration: SolanaSimulateTransactionConfig( - commitment: .confirmed, - encoding: .base64, - replaceRecentBlockhash: true - ) - ) - return .proceed - } catch SolanaRPCError.transactionSimulationError(let logs) { - return blockedOutcome( - reason: "Phantom signed transaction failed simulation", - logs: logs, - extraMetadata: ["kind": "simulationErr"], - swapMetadata: swapMetadata - ) - } catch SolanaRPCError.responseError(let response) { - var extra: [String: String] = ["kind": "preflightRejection"] - if let code = response.code { extra["code"] = "\(code)" } - if let message = response.message { extra["message"] = message } - return blockedOutcome( - reason: "Phantom signed transaction rejected at preflight", - logs: response.data?.logs ?? [], - extraMetadata: extra, - swapMetadata: swapMetadata - ) - } catch { - logger.warning("Simulation RPC failed, proceeding to submit", metadata: [ - "error": "\(error)", - ]) - return .proceed - } - } - - private func blockedOutcome( - reason: String, - logs: [String], - extraMetadata: [String: String], - swapMetadata: [String: String] - ) -> SimulationOutcome { - logger.error("Blocking signed transaction after RPC rejection", metadata: [ - "kind": "\(extraMetadata["kind"] ?? "unknown")", - "code": "\(extraMetadata["code"] ?? "")", - "message": "\(extraMetadata["message"] ?? "")", - "logs": "\(logs.suffix(5).joined(separator: " | "))", - ]) - ErrorReporting.captureError( - Error.simulationFailed(logs: logs), - reason: reason, - metadata: swapMetadata.merging(extraMetadata) { current, _ in current } - ) - return .blocked(.init( - style: .destructive, - title: "Transaction Failed", - subtitle: "The Solana network wouldn't accept this transaction from your wallet. No funds were moved. Please try again.", - dismissable: true - ) { - .okay(kind: .destructive) - }) - } - - enum SimulationOutcome { - case proceed - case blocked(DialogItem) - } - - /// Requests an external swap to fund a buy of an existing launchpad currency. - /// The processing screen is deferred until the user returns with a signed transaction. - func requestSwap(usdc: FlipcashCore.TokenAmount, token: MintMetadata) async throws { - let fundingSwapId = SwapId.generate() - try await requestUsdcToUsdfSwap( - fundingSwapId: fundingSwapId, - usdc: usdc, - displayName: token.name, - onCompleted: { [client, owner] signature, amount in - try await client.buyWithExternalFunding( - swapId: fundingSwapId, - amount: amount, - of: token, - owner: owner, - transactionSignature: signature - ) - return .buyExisting(swapId: fundingSwapId) - } - ) - isShowingAmountEntry = false - } - - /// Requests an external USDC→USDF swap for the currency-creation launch flow. - /// `onCompleted` runs after Phantom signs and must execute - /// `Session.launchCurrency` + `Session.buyNewCurrencyWithExternalFunding`, - /// returning the swap id from the buy so the processing screen polls the - /// right swap state (the funding swap id is unrelated). - func requestSwapForLaunch( - usdc: FlipcashCore.TokenAmount, - displayName: String, - onCompleted: @escaping @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult - ) async throws { - try await requestUsdcToUsdfSwap( - fundingSwapId: SwapId.generate(), - usdc: usdc, - displayName: displayName, - onCompleted: onCompleted - ) - } - - /// Dismisses the processing screen and clears any pending wallet dialogs. - func dismissProcessing() { - dialogItem = nil - state = .idle - } - - /// Flips the current active context's `isFailed` flag. No-op for `.idle`. - private func markCurrentStateFailed() { - state = state.markedFailed() - } - // MARK: - Actions - /// Async wrapper around `connectToPhantom()`. Returns immediately if @@ -513,6 +239,25 @@ public final class WalletConnection { /// being popped) don't leak the underlying `CheckedContinuation`. func connect() async throws { guard !isConnected else { return } + try await openConnectDeeplink() + } + + /// Always deeplinks Phantom for connect, even when a Keychain session + /// already exists. Used by `PhantomCoordinator.start(_:)` to verify the + /// session is live before requesting a signature — Phantom auto-approves + /// when it still trusts our `dapp_encryption_public_key` (~sub-second + /// round-trip), and shows the connect prompt when it doesn't. + /// + /// Skipping this verification leaves us vulnerable to "first transaction + /// silently fails" when the user revoked us in Phantom between flows. + func handshake() async throws { + try await openConnectDeeplink() + } + + /// Shared deeplink-and-await primitive. Suspends until `didConnect` or an + /// errorCode callback resolves the continuation. Cancellation propagates + /// through `withTaskCancellationHandler`. + private func openConnectDeeplink() async throws { try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in pendingConnect?.resume(throwing: CancellationError()) @@ -534,15 +279,6 @@ public final class WalletConnection { continuation.resume(throwing: CancellationError()) } - /// Discards an in-flight swap request context. Called by - /// `PhantomConfirmScreen.onDisappear` so the screen popping while - /// waiting for Phantom's signed-transaction callback frees the gate - /// (`isAwaitingExternalSwap`) and prevents a future unrelated deeplink - /// from completing a stale swap. - func cancelPendingSwap() { - pendingSwap = nil - } - func connectToPhantom() { let nonce = UUID().uuidString @@ -559,14 +295,15 @@ public final class WalletConnection { openExternalWallet(c.url!) } - /// Builds + sends a USDC→USDF transaction to Phantom for signing. Stashes - /// the supplied `onCompleted` closure so `didSignTransaction` can run it - /// once the signed transaction comes back via deeplink. - private func requestUsdcToUsdfSwap( - fundingSwapId: SwapId, + /// Builds + sends a USDC→USDF transaction to Phantom for signing. The + /// signed transaction comes back via `didReceiveURL` → `deeplinkEvents`, + /// where `PhantomCoordinator` matches it against its own `pendingSwap` + /// context. This method has no pending-state side effects of its own — + /// it's a pure "send sign request" service. + func sendUsdcToUsdfSignRequest( usdc: FlipcashCore.TokenAmount, - displayName: String, - onCompleted: @escaping @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult + fundingSwapId: SwapId, + displayName: String ) async throws { guard let connectedSession = Keychain.connectedWalletSession else { throw Error.noSession @@ -597,13 +334,6 @@ public final class WalletConnection { instructions: instructions ) - pendingSwap = PendingSwap( - fundingSwapId: fundingSwapId, - amount: amount, - displayName: displayName, - onCompleted: onCompleted - ) - let txEncoded = Base58.fromBytes(Array(transaction.encode())) let payload: [String: Any] = [ @@ -626,7 +356,6 @@ public final class WalletConnection { ] guard let url = c.url else { - pendingSwap = nil logger.error("Failed to construct signTransaction URL") throw Error.invalidURL } @@ -758,6 +487,10 @@ public enum WalletConnectionError: Error, LocalizedError { case jsonDecodingFailed(underlying: Error) case noSession case connectFailed(code: String) + /// User cancelled the connect prompt in Phantom (Phantom code 4001 on the + /// `walletConnected` deeplink). Distinct from `CancellationError`, which + /// signals a local Task cancellation (view dismissed, coordinator reset). + case userCancelledConnect public var errorDescription: String? { switch self { @@ -766,6 +499,7 @@ public enum WalletConnectionError: Error, LocalizedError { case .jsonDecodingFailed(let e): return "Failed to decode JSON: \(e.localizedDescription)" case .noSession: return "No connected wallet session." case .connectFailed(let code): return "Wallet connection failed (code: \(code))." + case .userCancelledConnect: return "You cancelled the connection in your wallet." } } } @@ -779,29 +513,6 @@ extension WalletConnection { } } -/// External-wallet processing state. Variants differ by flow (buy-existing vs -/// currency launch) and each carries an `isFailed` flag that drives the -/// processing screen's "cancelled" display without requiring a separate flag -/// that could drift out of sync with the active context. -enum WalletProcessingState: Hashable { - case idle - case buying(ExternalSwapProcessing, isFailed: Bool) - case launching(ExternalLaunchProcessing, isFailed: Bool) - - /// Returns the state with the active context's `isFailed` flipped to true. - /// `.idle` is a fixed point. Idempotent on already-failed states. - func markedFailed() -> WalletProcessingState { - switch self { - case .idle: - return .idle - case .buying(let context, _): - return .buying(context, isFailed: true) - case .launching(let context, _): - return .launching(context, isFailed: true) - } - } -} - // MARK: - SealedData - public struct SealedData { @@ -854,5 +565,5 @@ private extension FlipcashCore.Keychain { // MARK: - Mock - extension WalletConnection { - static let mock = WalletConnection(owner: .mock, client: .mock) + static let mock = WalletConnection(owner: .mock) } diff --git a/Flipcash/Core/Controllers/Phantom/PhantomCoordinator.swift b/Flipcash/Core/Controllers/Phantom/PhantomCoordinator.swift new file mode 100644 index 00000000..3dec0de8 --- /dev/null +++ b/Flipcash/Core/Controllers/Phantom/PhantomCoordinator.swift @@ -0,0 +1,559 @@ +// +// PhantomCoordinator.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-14. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.phantom-coordinator") + +/// Owns the Phantom-funded operation lifecycle from picker tap through to +/// post-signing chain submission. Mirrors `OnrampCoordinator` so funding +/// paths follow the same shape (operation + state + completion). +/// +/// Always-connect policy: `start(_:)` unconditionally invokes the Phantom +/// connect handshake. We never trust a cached Keychain session — the user +/// could have revoked us in Phantom since last time. A warm session returns +/// inside ~1 second; a cold session prompts the user. Either way we know we +/// have a live session before requesting a signature. +@Observable +@MainActor +final class PhantomCoordinator { + + // MARK: - Public state + + /// The operation being funded. Set by `start(_:)`, cleared by `cancel()` + /// or when the operation completes. + private(set) var operation: PaymentOperation? + + /// Drives picker / education / confirm UI. + private(set) var state: State = .idle + + /// Single source of truth for external-wallet processing state. Invalid + /// combinations — "cancelled flag set with no active context", "both + /// buy-existing and launch contexts populated simultaneously" — are + /// unrepresentable by construction. + private(set) var processingState: WalletProcessingState = .idle + + /// Buy-existing context, exposed for `.fullScreenCover(item:)`. Writing + /// `nil` (SwiftUI dismiss) transitions back to `.idle` only if currently + /// buying. + var processing: ExternalSwapProcessing? { + get { + if case .buying(let p, _) = processingState { return p } + return nil + } + set { + if newValue == nil, case .buying = processingState { + processingState = .idle + } + } + } + + /// Launch context, exposed for `.fullScreenCover(item:)`. + var launchProcessing: ExternalLaunchProcessing? { + get { + if case .launching(let l, _) = processingState { return l } + return nil + } + set { + if newValue == nil, case .launching = processingState { + processingState = .idle + } + } + } + + /// True iff the active processing context has been marked failed. + /// `SwapProcessingScreen` observes this via `.onChange(of:initial:)`. + var isProcessingCancelled: Bool { + switch processingState { + case .idle: return false + case .buying(_, let isFailed), .launching(_, let isFailed): return isFailed + } + } + + /// True while a signing request has been sent and the user hasn't yet + /// returned with a signed transaction. Used by the buy nested sheet to + /// block swipe-dismissal so the user can't accidentally lose the + /// in-flight signature. + var isAwaitingExternalSwap: Bool { pendingSwap != nil } + + /// Launch flows assign a handler before opening the picker. Coordinator + /// invokes it after Phantom signs to chain `launchCurrency` + + /// `buyNewCurrencyWithExternalFunding` and return a `SignedSwapResult`. + /// Buy flows don't need a handler — buy completion is built in. + var launchHandler: (@MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult)? + + enum State: Equatable { + /// No operation in flight. + case idle + /// Handshake deeplink sent; waiting for Phantom return. Always runs, + /// even if a Keychain session exists. + case connecting + /// Phantom is connected. Waiting for the user to tap Confirm on the + /// `PhantomConfirmScreen`. + case awaitingConfirm + /// Signing deeplink sent; waiting for Phantom return. + case signing + /// Connect or signing failed. Surfaces a dialog; user can retry. + case failed(reason: String) + } + + // MARK: - Dependencies + + private let walletConnection: WalletConnection + private let session: Session + private let client: Client + private let rpc: any SolanaRPC + + // MARK: - Init + + init(walletConnection: WalletConnection, session: Session, client: Client, rpc: any SolanaRPC = SolanaJSONRPCClient()) { + self.walletConnection = walletConnection + self.session = session + self.client = client + self.rpc = rpc + startEventStreamConsumer() + } + + // No `deinit` task cancellation: Swift 6 makes `deinit` nonisolated and + // it can't touch `eventTask` (main-actor isolated). The consumer Task + // exits when `WalletConnection.deinit` calls `deeplinkContinuation + // .finish()`, which ends the `for await` loop cleanly. Both objects are + // session-scoped on `SessionContainer`, so the stream and the task + // share a lifetime. + + // MARK: - Lifecycle + + /// Entry point — picker calls this when the user taps Phantom. + /// Unconditionally deeplinks Phantom for connect. + func start(_ operation: PaymentOperation) { + cancelInternalTasks() + pendingSwap = nil + // Drop any stale launch handler when starting a buy. The wizard sets + // `launchHandler` right before pushing into the picker for `.launch`; + // a `.buy` start must not inherit a leftover handler from a prior + // wizard attempt that didn't run to completion. + if case .buy = operation { + launchHandler = nil + } + self.operation = operation + state = .connecting + connectTask = Task { [weak self] in + guard let self else { return } + await self.runHandshake() + } + } + + /// Called from `PhantomConfirmScreen` when the user taps Confirm. + func confirm() { + guard state == .awaitingConfirm, let operation else { return } + cancelInternalTasks() + state = .signing + signTask = Task { [weak self] in + guard let self else { return } + await self.runSwapRequest(for: operation) + } + } + + /// Discards in-flight pre-signing state. Safe to call from any + /// `.onDisappear` — no-ops when nothing is pending. Active processing + /// contexts (`processingState != .idle`) are owned by the cover and + /// untouched here; only the pre-sign lifecycle is reset. + func cancel() { + cancelInternalTasks() + pendingSwap = nil + operation = nil + launchHandler = nil + state = .idle + } + + /// Dismisses the processing cover. Callers (BuyAmountScreen, wizard) + /// invoke this from the cover's `dismissParentContainer` env value. + func dismissProcessing() { + processingState = .idle + operation = nil + launchHandler = nil + state = .idle + } + + // MARK: - Pending swap state + + /// Context for resolving a signed transaction. Set in `runSwapRequest`, + /// consumed in `handleSignedTransaction`. + private var pendingSwap: PendingSwap? + + /// Captures everything `handleSignedTransaction` needs to dispatch the + /// post-sign chain submission. The `onCompleted` closure is set by the + /// coordinator itself (buy uses a built-in `client.buyWithExternalFunding` + /// call; launch defers to `launchHandler`). + struct PendingSwap { + let fundingSwapId: SwapId + let amount: ExchangedFiat + let displayName: String + let onCompleted: @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult + } + + // MARK: - Internal tasks + + private var connectTask: Task? + private var signTask: Task? + private var eventTask: Task? + + private func cancelInternalTasks() { + connectTask?.cancel() + connectTask = nil + signTask?.cancel() + signTask = nil + } + + /// Subscribes to `WalletConnection.deeplinkEvents` for the lifetime of + /// this coordinator. Exits when the stream finishes (in + /// `WalletConnection.deinit`). + private func startEventStreamConsumer() { + let stream = walletConnection.deeplinkEvents + eventTask = Task { [weak self] in + for await event in stream { + await self?.handle(event) + } + } + } + + // MARK: - Handshake + sign request + + private func runHandshake() async { + do { + try await walletConnection.handshake() + try Task.checkCancellation() + state = .awaitingConfirm + } catch is CancellationError { + // Local cancel — view dismissed, coordinator reset, etc. + // Silent reset is correct here; the user didn't act, the app did. + await reset() + } catch WalletConnectionError.userCancelledConnect { + // User dismissed the connect prompt in Phantom. Surface a dialog + // so the education screen shows feedback; user can tap Connect + // again to retry. + state = .failed(reason: "You cancelled the connection in your wallet. Tap Connect again to retry.") + } catch { + logger.error("Phantom handshake failed", metadata: ["error": "\(error)"]) + state = .failed(reason: "We couldn't connect to your Phantom wallet. Please try again.") + } + } + + private func runSwapRequest(for operation: PaymentOperation) async { + let fundingSwapId = SwapId.generate() + let onCompleted: @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult + + switch operation { + case .buy(let payload): + let token: MintMetadata + do { + token = try await session.fetchMintMetadata(mint: payload.mint).metadata + try Task.checkCancellation() + } catch is CancellationError { + await reset() + return + } catch { + logger.error("Failed to load mint metadata for Phantom buy", metadata: ["error": "\(error)"]) + state = .failed(reason: "We couldn't load the currency. Please try again.") + return + } + let client = self.client + let owner = walletConnection.owner + onCompleted = { signature, amount in + try await client.buyWithExternalFunding( + swapId: fundingSwapId, + amount: amount, + of: token, + owner: owner, + transactionSignature: signature + ) + return .buyExisting(swapId: fundingSwapId) + } + + case .launch: + guard let launchHandler else { + logger.error("Launch confirm reached without a registered launchHandler") + state = .failed(reason: "We couldn't start the launch. Please try again.") + return + } + // The caller-provided handler chains launchCurrency + + // buyNewCurrencyWithExternalFunding and returns the buy swap id. + onCompleted = launchHandler + } + + let usdc: FlipcashCore.TokenAmount = operation.displayAmount.onChainAmount + pendingSwap = PendingSwap( + fundingSwapId: fundingSwapId, + amount: operation.displayAmount, + displayName: operation.currencyName, + onCompleted: onCompleted + ) + + do { + try await walletConnection.sendUsdcToUsdfSignRequest( + usdc: usdc, + fundingSwapId: fundingSwapId, + displayName: operation.currencyName + ) + } catch is CancellationError { + pendingSwap = nil + await reset() + } catch { + pendingSwap = nil + logger.error("Phantom sign request failed", metadata: ["error": "\(error)"]) + state = .failed(reason: "We couldn't initiate the transaction. Please try again.") + } + } + + // MARK: - Deeplink event handling + + private func handle(_ event: WalletConnection.DeeplinkEvent) async { + switch event { + case .signed(let signedTx): + let pending = pendingSwap + pendingSwap = nil + await handleSignedTransaction(signedTx: signedTx, pending: pending) + case .userCancelled: + pendingSwap = nil + // Cancel happened during the sign step (pre-`buying`/`launching`) + // → re-enable the confirm button so the user can retry. If we'd + // already entered processing, flip the active context to failed + // so the processing screen surfaces the error. + if case .idle = processingState { + if operation != nil { + state = .awaitingConfirm + } + session.dialogItem = .init( + style: .destructive, + title: "Transaction Cancelled", + subtitle: "The transaction was cancelled in your wallet", + dismissable: true + ) { .okay(kind: .destructive) } + } else { + processingState = processingState.markedFailed() + } + case .failed: + pendingSwap = nil + if case .idle = processingState { + if operation != nil { + state = .awaitingConfirm + } + session.dialogItem = .init( + style: .destructive, + title: "Transaction Failed", + subtitle: "Your wallet returned an error. Please try again.", + dismissable: true + ) { .okay(kind: .destructive) } + } else { + processingState = processingState.markedFailed() + } + } + } + + /// Decodes the signed transaction, runs preflight simulation, + /// notifies the server, transitions to the processing context, and + /// submits to the chain. Server-notify is intentionally before chain + /// submit: a server rejection skips submission so no USDC moves without + /// a recorded swap. + private func handleSignedTransaction(signedTx: String, pending: PendingSwap?) async { + guard let pending else { + logger.warning("Received signed transaction but no pending swap context") + return + } + + let swapMetadata: [String: String] = [ + "swapId": pending.fundingSwapId.publicKey.base58, + "amount": pending.amount.nativeAmount.formatted(), + "name": pending.displayName, + ] + + let rawData = Data(Base58.toBytes(signedTx)) + guard let tx = SolanaTransaction(data: rawData) else { + logger.error("Failed to decode signed transaction") + ErrorReporting.captureError(WalletConnection.Error.invalidURL, reason: "Failed to decode signed transaction", metadata: swapMetadata) + return + } + + let txBase64 = rawData.base64EncodedString() + switch await simulateSignedTransaction(txBase64, swapMetadata: swapMetadata) { + case .proceed: + break + case .blocked(let dialog): + session.dialogItem = dialog + return + } + + // Present the generic processing screen via the .buying context. + // The server callback may transition this to .launching when the + // caller is launching a new currency; early-exit failures transition + // back to .idle so no cover presents for a swap the server never + // recorded. + processingState = .buying( + ExternalSwapProcessing( + swapId: pending.fundingSwapId, + currencyName: pending.displayName, + amount: pending.amount + ), + isFailed: false + ) + + // Notify server before submitting to chain — if the server rejects, + // skip chain submission so no USDC is spent without a swap state. + do { + let result = try await pending.onCompleted(tx.identifier, pending.amount) + switch result { + case .buyExisting(let swapId): + if swapId != pending.fundingSwapId { + processingState = .buying( + ExternalSwapProcessing( + swapId: swapId, + currencyName: pending.displayName, + amount: pending.amount + ), + isFailed: false + ) + } + case .launch(let swapId, let mint): + processingState = .launching( + ExternalLaunchProcessing( + swapId: swapId, + launchedMint: mint, + currencyName: pending.displayName, + amount: pending.amount + ), + isFailed: false + ) + } + logger.info("Server notified of swap funding") + } catch { + logger.error("Server notification failed", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Server notification failed", metadata: swapMetadata) + processingState = .idle + return + } + + // Server accepted — submit to chain. Failure here keeps the context + // and flips `isFailed` so the processing screen surfaces the error. + do { + let signature = try await rpc.sendTransaction( + txBase64, + configuration: SolanaSendTransactionConfig() + ) + logger.info("Transaction sent", metadata: ["signature": "\(signature.base58)"]) + Analytics.track(event: Analytics.WalletEvent.transactionsSubmitted) + } catch { + logger.error("Chain submission failed", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Chain submission failed", metadata: swapMetadata) + processingState = processingState.markedFailed() + } + } + + // MARK: - Simulation + + private enum SimulationOutcome { + case proceed + case blocked(DialogItem) + } + + /// Transport failures (URL errors, decode errors) pass through as + /// `.proceed` — a flaky RPC blip must not block a user with valid funds. + /// Only explicit RPC rejections block. + private func simulateSignedTransaction( + _ txBase64: String, + swapMetadata: [String: String] + ) async -> SimulationOutcome { + do { + _ = try await rpc.simulateTransaction( + txBase64, + configuration: SolanaSimulateTransactionConfig( + commitment: .confirmed, + encoding: .base64, + replaceRecentBlockhash: true + ) + ) + return .proceed + } catch SolanaRPCError.transactionSimulationError(let logs) { + return blockedOutcome( + reason: "Phantom signed transaction failed simulation", + logs: logs, + extraMetadata: ["kind": "simulationErr"], + swapMetadata: swapMetadata + ) + } catch SolanaRPCError.responseError(let response) { + var extra: [String: String] = ["kind": "preflightRejection"] + if let code = response.code { extra["code"] = "\(code)" } + if let message = response.message { extra["message"] = message } + return blockedOutcome( + reason: "Phantom signed transaction rejected at preflight", + logs: response.data?.logs ?? [], + extraMetadata: extra, + swapMetadata: swapMetadata + ) + } catch { + logger.warning("Simulation RPC failed, proceeding to submit", metadata: ["error": "\(error)"]) + return .proceed + } + } + + private func blockedOutcome( + reason: String, + logs: [String], + extraMetadata: [String: String], + swapMetadata: [String: String] + ) -> SimulationOutcome { + logger.error("Blocking signed transaction after RPC rejection", metadata: [ + "kind": "\(extraMetadata["kind"] ?? "unknown")", + "code": "\(extraMetadata["code"] ?? "")", + "message": "\(extraMetadata["message"] ?? "")", + "logs": "\(logs.suffix(5).joined(separator: " | "))", + ]) + ErrorReporting.captureError( + WalletConnection.Error.simulationFailed(logs: logs), + reason: reason, + metadata: swapMetadata.merging(extraMetadata) { current, _ in current } + ) + return .blocked(.init( + style: .destructive, + title: "Transaction Failed", + subtitle: "The Solana network wouldn't accept this transaction from your wallet. No funds were moved. Please try again.", + dismissable: true + ) { + .okay(kind: .destructive) + }) + } + + private func reset() async { + operation = nil + launchHandler = nil + pendingSwap = nil + state = .idle + } +} + +// MARK: - WalletProcessingState + +/// External-wallet processing state. Variants differ by flow (buy-existing vs +/// currency launch) and each carries an `isFailed` flag that drives the +/// processing screen's "cancelled" display without requiring a separate flag +/// that could drift out of sync with the active context. +enum WalletProcessingState: Hashable { + case idle + case buying(ExternalSwapProcessing, isFailed: Bool) + case launching(ExternalLaunchProcessing, isFailed: Bool) + + /// Returns a new state with the same context but `isFailed = true`. + /// No-op for `.idle`. + func markedFailed() -> WalletProcessingState { + switch self { + case .idle: return .idle + case .buying(let context, _): return .buying(context, isFailed: true) + case .launching(let context, _): return .launching(context, isFailed: true) + } + } +} diff --git a/Flipcash/Core/Models/PaymentOperation.swift b/Flipcash/Core/Models/PaymentOperation.swift new file mode 100644 index 00000000..58d8b296 --- /dev/null +++ b/Flipcash/Core/Models/PaymentOperation.swift @@ -0,0 +1,78 @@ +// +// PaymentOperation.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-14. +// + +import Foundation +import FlipcashCore + +/// Funding-time payload shared by buy-existing and launch-new-currency flows. +/// `PurchaseMethodSheet` and the per-funding-path coordinators (`PhantomCoordinator`, +/// `OnrampCoordinator`) accept this so the picker UI is identical regardless +/// of what the user is funding. +/// +/// Marked `nonisolated` so the type and its computed-property unwraps are +/// reachable from `AppRouter.Destination` (which is itself `nonisolated` for +/// cross-actor logging metadata). +nonisolated enum PaymentOperation: Hashable, Sendable, Identifiable { + + case buy(BuyPayload) + case launch(LaunchPayload) + + struct BuyPayload: Hashable, Sendable { + let id: UUID + let mint: PublicKey + let currencyName: String + let amount: ExchangedFiat + let verifiedState: VerifiedState + + init(mint: PublicKey, currencyName: String, amount: ExchangedFiat, verifiedState: VerifiedState) { + self.id = UUID() + self.mint = mint + self.currencyName = currencyName + self.amount = amount + self.verifiedState = verifiedState + } + } + + struct LaunchPayload: Hashable, Sendable { + let id: UUID + let currencyName: String + let total: ExchangedFiat + let launchAmount: ExchangedFiat + let launchFee: ExchangedFiat + + init(currencyName: String, total: ExchangedFiat, launchAmount: ExchangedFiat, launchFee: ExchangedFiat) { + self.id = UUID() + self.currencyName = currencyName + self.total = total + self.launchAmount = launchAmount + self.launchFee = launchFee + } + } + + var id: UUID { + switch self { + case .buy(let payload): return payload.id + case .launch(let payload): return payload.id + } + } + + /// Amount displayed on the picker / funding screens. For buy this is the + /// purchase amount; for launch this is the total (purchase + fee). + var displayAmount: ExchangedFiat { + switch self { + case .buy(let payload): return payload.amount + case .launch(let payload): return payload.total + } + } + + var currencyName: String { + switch self { + case .buy(let payload): return payload.currencyName + case .launch(let payload): return payload.currencyName + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 50bfd945..b8750c8c 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -38,6 +38,13 @@ extension AppRouter { /// swap PDA's USDC ATA). Reached as the next step after /// `.usdcDepositEducation`. case usdcDepositAddress + /// Phantom education screen. Shared by buy + launch via the carried + /// `PaymentOperation`. The `PhantomCoordinator` already owns the + /// in-flight state; the payload here keeps the screen renderable + /// against a stale-coordinator nil-operation race on back-stack reuse. + case phantomEducation(PaymentOperation) + /// Phantom confirm screen, paired with `.phantomEducation`. + case phantomConfirm(PaymentOperation) // Settings flow case settingsMyAccount @@ -58,7 +65,8 @@ extension AppRouter { case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .transactionHistory, .give, .withdrawCurrency, - .usdcDepositEducation, .usdcDepositAddress: + .usdcDepositEducation, .usdcDepositAddress, + .phantomEducation, .phantomConfirm: return .balance case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, @@ -84,6 +92,8 @@ extension AppRouter { case .withdrawCurrency: "withdrawCurrency" case .usdcDepositEducation: "usdcDepositEducation" case .usdcDepositAddress: "usdcDepositAddress" + case .phantomEducation: "phantomEducation" + case .phantomConfirm: "phantomConfirm" case .settingsMyAccount: "settingsMyAccount" case .settingsAdvancedFeatures: "settingsAdvancedFeatures" case .settingsAppSettings: "settingsAppSettings" @@ -109,6 +119,8 @@ extension AppRouter { .withdrawCurrency(let mint), .deposit(let mint): return mint.base58 + case .phantomEducation(let operation), .phantomConfirm(let operation): + return operation.currencyName case .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .usdcDepositEducation, .usdcDepositAddress, .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 0f15f158..b55e3113 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -151,6 +151,12 @@ struct DestinationView: View { case .usdcDepositAddress: USDCDepositAddressScreen() + + case .phantomEducation(let operation): + PhantomEducationScreen(operation: operation) + + case .phantomConfirm(let operation): + PhantomConfirmScreen(operation: operation) } } } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index f91ed5cd..9c56366a 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -16,6 +16,7 @@ struct BuyAmountScreen: View { @Environment(AppRouter.self) private var router @Environment(OnrampCoordinator.self) private var coordinator + @Environment(PhantomCoordinator.self) private var phantomCoordinator @Environment(RatesController.self) private var ratesController @Environment(WalletConnection.self) private var walletConnection @Environment(Session.self) private var session @@ -35,7 +36,7 @@ struct BuyAmountScreen: View { // does NOT propagate through the nested-sheet binding, so gate at // the NavigationStack root by checking the path is non-empty. if !router[.buy].isEmpty { return true } - return coordinator.isProcessingPayment || walletConnection.isAwaitingExternalSwap + return coordinator.isProcessingPayment || phantomCoordinator.isAwaitingExternalSwap } var body: some View { @@ -84,7 +85,7 @@ struct BuyAmountScreen: View { // is already idle, so `dismissProcessing()` is a no-op. BuyFlowDestinationView(path: path) .environment(\.dismissParentContainer, { - walletConnection.dismissProcessing() + phantomCoordinator.dismissProcessing() router.dismissSheet() }) } @@ -93,10 +94,12 @@ struct BuyAmountScreen: View { // creation rejected, swap-amount mismatch) via its own dialog item; // bind it here so the user sees the error instead of a silent flicker. .dialog(item: $coordinator.dialogItem) - .sheet(item: $viewModel.pendingMethodSelection) { context in + .sheet(item: $viewModel.pendingOperation) { operation in PurchaseMethodSheet( - context: context, - onDismiss: { viewModel.pendingMethodSelection = nil } + operation: operation, + sources: [.applePay, .phantom, .otherWallet], + applePayAction: nil, + onDismiss: { viewModel.pendingOperation = nil } ) } .sheet(isPresented: $coordinator.isShowingVerificationFlow) { @@ -115,6 +118,19 @@ struct BuyAmountScreen: View { )) coordinator.completion = nil } + // Phantom-funded buy: when the signed tx returns and the coordinator + // transitions to .buying(...), push the processing screen onto the + // `.buy` stack. Gated on nil → non-nil so we don't double-push when + // the server callback reassigns the buying context to a new swap id. + .onChange(of: phantomCoordinator.processing) { oldValue, newValue in + guard oldValue == nil, let newValue else { return } + router.pushAny(BuyFlowPath.processing( + swapId: newValue.swapId, + currencyName: newValue.currencyName, + amount: newValue.amount, + swapType: .buyWithPhantom + )) + } // Forward wallet-connection dialogs (Phantom cancel during signing, // simulate failures, etc.) to `session.dialogItem` so they render in // `DialogWindow` at alert level instead of trying to mount a sheet @@ -125,12 +141,13 @@ struct BuyAmountScreen: View { .onChange(of: walletConnection.dialogItem?.id) { _, newId in guard newId != nil, let dialog = walletConnection.dialogItem else { return } session.dialogItem = dialog - walletConnection.dialogItem = nil + // Defer the clear past the current observation tick so we don't + // mutate the observed value in the same update cycle that fired + // this handler. + Task { @MainActor in + walletConnection.dialogItem = nil + } } - // The Phantom processing push is owned by `PhantomConfirmScreen` — - // observing `walletConnection.processing` here too would double-push - // when both screens are alive (PhantomConfirm still on top during the - // state transition). } private func showCurrencySelection() { diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 2db404e4..62e434ba 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -17,7 +17,7 @@ final class BuyAmountViewModel: Identifiable { var actionButtonState: ButtonState = .normal var enteredAmount: String = "" var dialogItem: DialogItem? - var pendingMethodSelection: PurchaseMethodContext? + var pendingOperation: PaymentOperation? @ObservationIgnored let mint: PublicKey @ObservationIgnored let currencyName: String @@ -97,12 +97,12 @@ final class BuyAmountViewModel: Identifiable { } private func routeToPicker(amount: ExchangedFiat, pin: VerifiedState) { - pendingMethodSelection = PurchaseMethodContext( + pendingOperation = .buy(.init( mint: mint, currencyName: currencyName, amount: amount, verifiedState: pin - ) + )) } private func usdfBalanceCovers(_ amount: ExchangedFiat) -> Bool { diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift index 9deab3c9..35b07708 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift @@ -16,10 +16,6 @@ struct BuyFlowDestinationView: View { var body: some View { switch path { - case .phantomEducation(let mint, let amount): - PhantomEducationScreen(mint: mint, amount: amount) - case .phantomConfirm(let mint, let amount): - PhantomConfirmScreen(mint: mint, amount: amount) case .processing(let swapId, let currencyName, let amount, let swapType): SwapProcessingScreen( swapId: swapId, diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift index 0dfc8a86..e48993e1 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -18,7 +18,5 @@ import FlipcashCore /// Hashable + Sendable. Keeping these out of `Destination` matches the /// `WithdrawNavigationPath` pattern. enum BuyFlowPath: Hashable, Sendable { - case phantomEducation(mint: PublicKey, amount: ExchangedFiat) - case phantomConfirm(mint: PublicKey, amount: ExchangedFiat) case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat, swapType: SwapType) } diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift index 808cea3f..0e268132 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -9,26 +9,23 @@ import SwiftUI import FlipcashCore import FlipcashUI -private let logger = Logger(label: "flipcash.phantom-confirm") - -/// Post-Phantom-auth confirmation screen. The user taps "Confirm In Phantom" to -/// trigger `WalletConnection.requestSwap(...)`, which deep-links into Phantom -/// for transaction signing. When `walletConnection.state` transitions to -/// `.buying(ExternalSwapProcessing, isFailed: false)`, the `.onChange` -/// observer pushes `BuyFlowPath.processing` onto the buy stack with the swap -/// id and Phantom swap type. From there `SwapProcessingScreen` owns the -/// remaining lifecycle (success / cancel display) via its own -/// `walletConnection.isProcessingCancelled` observer. +/// Post-handshake confirmation. Tapping Confirm calls +/// `PhantomCoordinator.confirm()`, which dispatches the right +/// `WalletConnection.request*` for the carried operation kind. +/// +/// The completion routing (push processing for buy, fullScreenCover for +/// launch) is observed by the picker's caller — `BuyAmountScreen` watches +/// `coordinator.processing`, the wizard watches `coordinator.launchProcessing`. +/// This screen just kicks off the sign request. struct PhantomConfirmScreen: View { - let mint: PublicKey - let amount: ExchangedFiat + let operation: PaymentOperation - @Environment(AppRouter.self) private var router - @Environment(WalletConnection.self) private var walletConnection - @Environment(Session.self) private var session + @Environment(PhantomCoordinator.self) private var coordinator - @State private var confirmTask: Task? + private var isSigning: Bool { + coordinator.state == .signing + } var body: some View { Background(color: .backgroundMain) { @@ -57,74 +54,34 @@ struct PhantomConfirmScreen: View { Spacer() - Button(action: confirmInPhantom) { - HStack(spacing: 6) { - Text("Confirm in your") - Image.asset(.phantom) - .renderingMode(.template) - .resizable() - .frame(width: 18, height: 18) - Text("Phantom") + Button(action: coordinator.confirm) { + if isSigning { + HStack(spacing: 8) { + ProgressView().progressViewStyle(.circular) + Text("Waiting for Phantom…") + } + } else { + HStack(spacing: 6) { + Text("Confirm in your") + Image.asset(.phantom) + .renderingMode(.template) + .resizable() + .frame(width: 18, height: 18) + Text("Phantom") + } } } .buttonStyle(.filled) + .disabled(isSigning) } .padding(20) } .navigationTitle("Confirmation") .navigationBarTitleDisplayMode(.inline) .onDisappear { - // Cancel any in-flight swap request if the user backs out before - // Phantom returns. Without this, requestSwap's deeplink-out and - // pendingSwap mutation can fire against a popped screen. - confirmTask?.cancel() - confirmTask = nil - // Clear the swap context too so `isAwaitingExternalSwap` doesn't - // remain true and permanently block the .buy sheet's dismissal. - walletConnection.cancelPendingSwap() - } - .onChange(of: walletConnection.state) { _, newState in - // Push the processing screen the moment the swap context appears. - // `state` flips to `.buying(..., isFailed: false)` immediately - // after Phantom returns a signed transaction (see - // `WalletConnection.completeSwap`). A later chain-submission - // failure flips `isFailed` to true; `SwapProcessingScreen` - // observes that via `walletConnection.isProcessingCancelled`. - guard case .buying(let processing, false) = newState else { return } - router.pushAny(BuyFlowPath.processing( - swapId: processing.swapId, - currencyName: processing.currencyName, - amount: amount, - swapType: .buyWithPhantom - )) - } - } - - private func confirmInPhantom() { - confirmTask?.cancel() - confirmTask = Task { - do { - let metadata = try await session.fetchMintMetadata(mint: mint) - try Task.checkCancellation() - try await walletConnection.requestSwap( - usdc: amount.onChainAmount, - token: metadata.metadata - ) - } catch is CancellationError { - return - } catch { - logger.error("Failed to request Phantom swap", metadata: [ - "mint": "\(mint.base58)", - "amount": "\(amount.nativeAmount.formatted())", - "error": "\(error)", - ]) - ErrorReporting.captureError( - error, - reason: "Failed to request Phantom swap from PhantomConfirmScreen", - metadata: ["mint": mint.base58] - ) - session.dialogItem = .somethingWentWrong - } + // Backing out before Phantom returns cancels the pending swap so + // a future unrelated deeplink doesn't complete a stale operation. + coordinator.cancel() } } } diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift index 640d6e74..7614f137 100644 --- a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift @@ -9,23 +9,28 @@ import SwiftUI import FlipcashCore import FlipcashUI -private let logger = Logger(label: "flipcash.phantom-education") - -/// "Buy With Phantom" pre-flight: explains the swap, then triggers an async -/// `WalletConnection.connect()` (the wrapper that sets `pendingConnect` so -/// the legacy `isShowingAmountEntry` side-effect on the wallet controller is -/// suppressed). On success, push `phantomConfirm`. User-cancel in Phantom -/// throws `CancellationError` and lands silently back on this screen. +/// "Buy / Launch With Phantom" pre-flight. The connect deeplink fires when +/// the user taps the CTA on this screen — not when Phantom is selected from +/// the picker — so the user sees the education copy before being kicked over +/// to Phantom. On return, `coordinator.state` flips to `.awaitingConfirm` and +/// we push `.phantomConfirm`. struct PhantomEducationScreen: View { - let mint: PublicKey - let amount: ExchangedFiat + let operation: PaymentOperation @Environment(AppRouter.self) private var router - @Environment(WalletConnection.self) private var walletConnection + @Environment(PhantomCoordinator.self) private var coordinator @Environment(Session.self) private var session - @State private var connectTask: Task? + /// Tracks whether `.phantomConfirm` has been pushed for this view's + /// lifetime. Necessary because `coordinator.state` re-enters + /// `.awaitingConfirm` after a sign-cancel (so the user can retry), which + /// would otherwise stack a second confirm screen via `.onChange`. + @State private var hasPushedConfirm = false + + private var isConnecting: Bool { + coordinator.state == .connecting + } var body: some View { Background(color: .backgroundMain) { @@ -51,47 +56,50 @@ struct PhantomEducationScreen: View { Spacer() - Button("Connect Your Phantom Wallet") { - connect() + Button { + coordinator.start(operation) + } label: { + if isConnecting { + HStack(spacing: 8) { + ProgressView().progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect Your Phantom Wallet") + } } .buttonStyle(.filled) + .disabled(isConnecting) } .padding(20) } .navigationTitle("Purchase") .navigationBarTitleDisplayMode(.inline) - .onDisappear { - connectTask?.cancel() - connectTask = nil - } - } - - private func connect() { - connectTask?.cancel() - connectTask = Task { - // If a prior session already exists, `connect()` returns - // immediately without deeplinking. Either way, advance. - do { - try await walletConnection.connect() - try Task.checkCancellation() - router.pushAny(BuyFlowPath.phantomConfirm(mint: mint, amount: amount)) - } catch is CancellationError { - return - } catch { - logger.error("Failed to connect to Phantom", metadata: [ - "error": "\(error)", - ]) - // Route through `session.dialogItem` so the alert renders in - // the dedicated DialogWindow at alert level — a local - // `.dialog(item:)` is a sheet under the hood and would - // conflict with the `.buy` nested sheet's presentation queue - // (tearing this screen down on present). + .onChange(of: coordinator.state) { _, newState in + switch newState { + case .awaitingConfirm where !hasPushedConfirm: + hasPushedConfirm = true + router.push(.phantomConfirm(operation)) + case .failed(let reason): + // Surface the failure as a destructive dialog so the user knows + // why the connect didn't go through. They can dismiss and tap + // "Connect Your Phantom Wallet" again to retry. session.dialogItem = .init( style: .destructive, title: "Couldn't Connect", - subtitle: "We couldn't connect to your Phantom wallet. Please try again.", + subtitle: reason, dismissable: true ) { .okay(kind: .destructive) } + case .awaitingConfirm, .idle, .connecting, .signing: + break + } + } + .onDisappear { + // Backing out while the connect deeplink is in flight cancels the + // coordinator so a stale Phantom return doesn't push confirm onto + // a stack the user just dismissed. + if coordinator.state == .connecting { + coordinator.cancel() } } } diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift deleted file mode 100644 index 5b71a228..00000000 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodContext.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PurchaseMethodContext.swift -// Flipcash -// -// Created by Raul Riera on 2026-05-12. -// - -import Foundation -import FlipcashCore - -/// Snapshot of pinned submission state that travels from the amount-entry -/// screen into whichever funding branch the user picks (Apple Pay, Phantom, -/// Other Wallet). Carrying the pin avoids re-fetching the verified state at -/// each step and preserves the pin-at-compute invariant. -struct PurchaseMethodContext: Identifiable, Sendable { - let id = UUID() - let mint: PublicKey - let currencyName: String - let amount: ExchangedFiat - let verifiedState: VerifiedState -} diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift index 6bd37a5a..a89544b9 100644 --- a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -9,13 +9,18 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// Half-sheet picker shown when a buy intent cannot be filled from the USDF -/// reserve alone. Lists the funding methods available to the current user -/// (Apple Pay via Coinbase, Phantom, generic Other Wallet) and routes each -/// selection into the corresponding sub-flow on the buy stack. +/// Half-sheet picker shown when a funding intent cannot be filled from the +/// USDF reserve alone. Shared by buy-existing and currency-launch flows via +/// `PaymentOperation`. Callers compose the `sources` array to control which +/// methods render — buy passes all three, launch omits `.otherWallet`. struct PurchaseMethodSheet: View { - let context: PurchaseMethodContext + let operation: PaymentOperation + let sources: [Method] + /// Callers whose Apple Pay flow needs preflight work before invoking + /// `OnrampCoordinator.start(_:amount:)` provide this override. The buy + /// flow passes `nil` so the picker dispatches directly. + let applePayAction: (() -> Void)? let onDismiss: () -> Void @Environment(AppRouter.self) private var router @@ -27,18 +32,6 @@ struct PurchaseMethodSheet: View { case otherWallet } - /// Source of truth for which rows render. Pure function so visibility can - /// be unit-tested without instantiating SwiftUI views. - static func methods(forSession session: Session) -> [Method] { - var result: [Method] = [] - if session.hasCoinbaseOnramp { - result.append(.applePay) - } - result.append(.phantom) - result.append(.otherWallet) - return result - } - var body: some View { PartialSheet { VStack(spacing: 12) { @@ -50,12 +43,14 @@ struct PurchaseMethodSheet: View { } .padding(.vertical, 20) - ForEach(Self.methods(forSession: session), id: \.self) { method in + // Apple Pay is hidden if the caller didn't request it OR the + // session can't actually use Coinbase. + ForEach(visibleSources, id: \.self) { method in MethodButton( method: method, - context: context, - onDismiss: onDismiss, - router: router + operation: operation, + applePayAction: applePayAction, + onDismiss: onDismiss ) } @@ -65,31 +60,51 @@ struct PurchaseMethodSheet: View { .padding() } } + + private var visibleSources: [Method] { + Self.visibleSources(from: sources, session: session) + } + + /// Pure function exposing the visibility filter so it can be unit-tested + /// without instantiating a SwiftUI view. Apple Pay drops out when the + /// session can't actually use Coinbase, regardless of whether the caller + /// requested it. + static func visibleSources(from sources: [Method], session: Session) -> [Method] { + sources.filter { method in + switch method { + case .applePay: + return session.hasCoinbaseOnramp + case .phantom, .otherWallet: + return true + } + } + } } -/// Dispatch wrapper so the parent body can `ForEach` over `methods(forSession:)` -/// and have a single source of truth for which rows render. The concrete row -/// structs below own the visual + side-effect details. private struct MethodButton: View { let method: PurchaseMethodSheet.Method - let context: PurchaseMethodContext + let operation: PaymentOperation + let applePayAction: (() -> Void)? let onDismiss: () -> Void - let router: AppRouter var body: some View { switch method { case .applePay: - ApplePayMethodButton(context: context, onDismiss: onDismiss) + ApplePayMethodButton( + operation: operation, + applePayAction: applePayAction, + onDismiss: onDismiss + ) case .phantom: - PhantomMethodButton(context: context, onDismiss: onDismiss, router: router) + PhantomMethodButton(operation: operation, onDismiss: onDismiss) case .otherWallet: - OtherWalletMethodButton(onDismiss: onDismiss, router: router) + OtherWalletMethodButton(onDismiss: onDismiss) } } } /// Dismisses the sheet, then waits for the system's dismiss animation -/// before invoking `action`. Without the wait, pushing onto the buy +/// before invoking `action`. Without the wait, pushing onto a navigation /// stack while the sheet is still mid-dismiss racing causes SwiftUI to /// drop the push. @MainActor @@ -105,7 +120,8 @@ private func dismissThenDispatch( } private struct ApplePayMethodButton: View { - let context: PurchaseMethodContext + let operation: PaymentOperation + let applePayAction: (() -> Void)? let onDismiss: () -> Void @Environment(OnrampCoordinator.self) private var coordinator @@ -117,17 +133,31 @@ private struct ApplePayMethodButton: View { // before the Apple Pay sheet round-trip. Use the USDF (1:1 USD) // value since `nativeAmount` is in the user's display currency. let minimumUSD = OnrampCoordinator.minimumPurchaseUSD - guard context.amount.usdfValue.value >= minimumUSD else { + guard operation.displayAmount.usdfValue.value >= minimumUSD else { let minimum = FiatAmount.usd(minimumUSD) - .converting(to: context.amount.currencyRate) + .converting(to: operation.displayAmount.currencyRate) .formatted() session.dialogItem = .applePayMinimumPurchase(minimum: minimum) return } Analytics.buttonTapped(name: .buyWithCoinbase) - let mint = context.mint - let displayName = context.currencyName - let amount = context.amount + if let applePayAction { + // Caller-provided dispatch — used by the launch flow which + // needs to run preflight (launchCurrency) before starting + // the coordinator. + dismissThenDispatch(onDismiss: onDismiss) { + applePayAction() + } + return + } + // Default buy dispatch. + guard case .buy(let payload) = operation else { + // Defensive — launch should always pass applePayAction. + return + } + let amount = payload.amount + let mint = payload.mint + let displayName = payload.currencyName dismissThenDispatch(onDismiss: onDismiss) { [coordinator] in coordinator.start(.buy(mint: mint, displayName: displayName), amount: amount) } @@ -136,29 +166,26 @@ private struct ApplePayMethodButton: View { .font(.body.bold()) } .buttonStyle(.filled) + .accessibilityIdentifier("apple-pay-method-button") } } private struct PhantomMethodButton: View { - let context: PurchaseMethodContext + let operation: PaymentOperation let onDismiss: () -> Void - let router: AppRouter - @Environment(WalletConnection.self) private var walletConnection + @Environment(AppRouter.self) private var router var body: some View { Button { Analytics.buttonTapped(name: .buyWithPhantom) - // Skip the education screen when a Phantom session already exists - // — the user has connected before and just needs to confirm. The - // education screen's auto-advance latch breaks on pop-back from - // confirm, which would otherwise surface a stale "Connect Your - // Phantom Wallet" CTA to an already-connected user. - let nextStep: BuyFlowPath = walletConnection.isConnected - ? .phantomConfirm(mint: context.mint, amount: context.amount) - : .phantomEducation(mint: context.mint, amount: context.amount) + let operation = self.operation + // Just push the education destination — the Phantom connect + // deeplink fires from the education screen's "Connect Your + // Phantom Wallet" button, not here. This keeps the connect + // prompt off-screen until the user has read the education copy. dismissThenDispatch(onDismiss: onDismiss) { [router] in - router.pushAny(nextStep) + router.push(.phantomEducation(operation)) } } label: { HStack(spacing: 4) { @@ -175,7 +202,8 @@ private struct PhantomMethodButton: View { private struct OtherWalletMethodButton: View { let onDismiss: () -> Void - let router: AppRouter + + @Environment(AppRouter.self) private var router var body: some View { Button("Other Wallet") { diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index 510c1e66..152da2a2 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -30,13 +30,16 @@ struct CurrencyCreationWizardScreen: View { @State private var compressTask: Task? @State private var validationTask: Task? @State private var isValidating: Bool = false - @State private var pendingCoinbaseLaunch: Bool = false @State private var errorDialog: DialogItem? @FocusState private var focusedField: Field? @State private var isShowingPhotoPicker = false @State private var isShowingFilePicker = false - @State private var isShowingFundingSheet = false + /// Drives the `PurchaseMethodSheet` when the user picks "Pay to Create" + /// and USDF doesn't cover the launch cost. + @State private var pendingOperation: PaymentOperation? + + @Environment(PhantomCoordinator.self) private var phantomCoordinator /// Non-nil while the Reserves-funded launch is in flight. Drives a /// `fullScreenCover` that presents `CurrencyLaunchProcessingScreen`. @State private var reservesLaunchContext: ReservesLaunchContext? @@ -184,7 +187,7 @@ struct CurrencyCreationWizardScreen: View { previewFiat: previewFiat, totalLaunchCost: totalLaunchCost, isValidating: isValidating, - onBuy: { isShowingFundingSheet = true } + onBuy: onPayToCreateTap ) .transition(direction.slide) } @@ -232,54 +235,12 @@ struct CurrencyCreationWizardScreen: View { ) { result in handleFileImport(result) } - .sheet(isPresented: $isShowingFundingSheet, onDismiss: { - // SwiftUI allows only one modal sheet at a time. If the user picked - // Coinbase and they're unverified, the coordinator needs to present - // its own verification sheet — defer the kickoff until the funding - // sheet has fully dismissed so the two sheets don't collide. - guard pendingCoinbaseLaunch else { return } - pendingCoinbaseLaunch = false - - // Launch the currency upfront so the stateful swap stream can be - // opened with the new mint before Apple Pay is presented. - let displayName = state.currencyName - let launchAmount = self.launchAmount - let launchFee = self.launchFee - let totalLaunchCost = self.totalLaunchCost - validationTask?.cancel() - validationTask = Task { - guard let mint = await launchCurrencyWithPreflightRouting() else { - isValidating = false - return - } - onrampCoordinator.start( - .launch( - mint: mint, - displayName: displayName, - launchAmount: launchAmount, - launchFee: launchFee - ), - amount: totalLaunchCost - ) - } - }) { - FundingSelectionSheet( - reserveBalance: reserveBalance, - isCoinbaseAvailable: session.hasCoinbaseOnramp, - onSelectReserves: { - isShowingFundingSheet = false - launchAndBuyWithReserves() - }, - onSelectCoinbase: { - pendingCoinbaseLaunch = true - isValidating = true - isShowingFundingSheet = false - }, - onSelectPhantom: { - isShowingFundingSheet = false - beginPhantomLaunch() - }, - onDismiss: { isShowingFundingSheet = false } + .sheet(item: $pendingOperation) { operation in + PurchaseMethodSheet( + operation: operation, + sources: [.applePay, .phantom], + applePayAction: { startApplePayLaunch() }, + onDismiss: { pendingOperation = nil } ) } .fullScreenCover(item: $reservesLaunchContext) { context in @@ -331,8 +292,9 @@ struct CurrencyCreationWizardScreen: View { } } // The wizard only hosts launch covers — buy-existing Phantom flows - // present their own cover from `CurrencyInfoScreen`. - .fullScreenCover(item: Bindable(walletConnection).launchProcessing) { processing in + // present their own cover from `BuyAmountScreen`. State forwards + // from `WalletConnection.launchProcessing` via the coordinator. + .fullScreenCover(item: Bindable(phantomCoordinator).launchProcessing) { processing in NavigationStack { CurrencyLaunchProcessingScreen( swapId: processing.swapId, @@ -343,14 +305,14 @@ struct CurrencyCreationWizardScreen: View { ) .environment(\.dismissParentContainer, { // Single-motion dismiss: sheet animation carries the - // cover off. Cleanup of shared wallet state is deferred + // cover off. Cleanup of coordinator state is deferred // past the animation so it doesn't trigger a separate // cover-dismiss in front of the sheet. `isValidating` // is @State and self-cleans on unmount. router.dismissSheet() Task { @MainActor in try? await Task.sleep(for: AppRouter.dismissAnimationDuration) - walletConnection.dismissProcessing() + phantomCoordinator.dismissProcessing() } }) } @@ -747,46 +709,57 @@ struct CurrencyCreationWizardScreen: View { return .launch(swapId: swapId, mint: mint) } - /// Kicks off the Phantom launch flow. Phantom signing happens out of - /// process (deeplink), so we don't toggle `isValidating` — once Phantom - /// returns, `WalletConnection.processing` becomes non-nil and the - /// `fullScreenCover` takes over the UI. Any failure while *requesting* - /// the swap surfaces a generic dialog. - private func beginPhantomLaunch() { + // MARK: - Pay-to-Create dispatch + + /// "Pay X to Create" tap handler. USDF gate first — if reserves cover the + /// total launch cost, run the reserves flow immediately. Otherwise wire + /// the Phantom coordinator's launch handler and open the picker. + private func onPayToCreateTap() { + if reserveBalance != nil { + launchAndBuyWithReserves() + return + } + + // Wire the Phantom launch handler now (before the picker opens) so the + // coordinator has it when the user taps Phantom and confirms. Buy + // operations on `PhantomCoordinator` don't use this handler — only + // launch does. + phantomCoordinator.launchHandler = { signature, _ in + try await launchAfterExternalFunding(signature: signature, source: .phantom) + } + + pendingOperation = .launch(.init( + currencyName: state.currencyName, + total: totalLaunchCost, + launchAmount: launchAmount, + launchFee: launchFee + )) + } + + /// Apple Pay dispatch closure passed into `PurchaseMethodSheet`. Runs the + /// Launch RPC preflight (creates the mint) before starting Coinbase, so + /// the destination address is valid when the Apple Pay sheet opens. + private func startApplePayLaunch() { + let displayName = state.currencyName + let launchAmount = self.launchAmount + let launchFee = self.launchFee + let totalLaunchCost = self.totalLaunchCost + isValidating = true validationTask?.cancel() validationTask = Task { - let displayName = state.currencyName - do { - if !walletConnection.isConnected { - try await walletConnection.connect() - // Returning from Phantom drops us through a brief - // background → inactive → active scene transition. - // UIApplication.shared.open is silently suppressed - // until the scene is fully active, so the follow-up - // sign request is no-op'd without this yield. - try await Task.sleep(for: .seconds(1)) - } - try await walletConnection.requestSwapForLaunch( - usdc: totalLaunchCost.onChainAmount, - displayName: displayName, - onCompleted: { signature, _ in - try await launchAfterExternalFunding(signature: signature, source: .phantom) - } - ) - } catch is CancellationError { - // User declined the connect request in Phantom. Surface a - // dialog so the wizard shows visible feedback instead of - // silently returning to the confirmation screen. - errorDialog = makeDestructiveDialog( - title: "Wallet Connection Cancelled", - subtitle: "You cancelled the connection in your wallet. Tap Phantom again to retry." - ) - } catch { - if Task.isCancelled { return } - logger.error("Failed to request Phantom swap", metadata: ["error": "\(error)"]) - ErrorReporting.captureError(error) - presentGenericErrorDialog() + guard let mint = await launchCurrencyWithPreflightRouting() else { + isValidating = false + return } + onrampCoordinator.start( + .launch( + mint: mint, + displayName: displayName, + launchAmount: launchAmount, + launchFee: launchFee + ), + amount: totalLaunchCost + ) } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/FundingSelectionSheet.swift b/Flipcash/Core/Screens/Main/Currency Info/FundingSelectionSheet.swift deleted file mode 100644 index aa70f891..00000000 --- a/Flipcash/Core/Screens/Main/Currency Info/FundingSelectionSheet.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// FundingSelectionSheet.swift -// Code -// -// Created by Claude on 2025-02-04. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct FundingSelectionSheet: View { - let reserveBalance: ExchangedFiat? - let isCoinbaseAvailable: Bool - let onSelectReserves: () -> Void - let onSelectCoinbase: () -> Void - let onSelectPhantom: () -> Void - let onDismiss: () -> Void - - var body: some View { - PartialSheet { - VStack { - HStack { - Text("Select Purchase Method") - .font(.appBarButton) - .foregroundStyle(Color.textMain) - Spacer() - } - .padding(.vertical, 20) - - if isCoinbaseAvailable { - Button { - onSelectCoinbase() - } label: { - HStack(spacing: 4) { - Text("Debit Card with") - Text("Pay") - .font(.body.bold()) - } - } - .buttonStyle(.filled) - } - - if let reserveBalance, reserveBalance.hasDisplayableValue() { - Button("USDF (\(reserveBalance.nativeAmount.formatted()))") { - onSelectReserves() - } - .buttonStyle(.filled) - } - - Button { - onSelectPhantom() - } label: { - HStack(spacing: 4) { - Text("Solana USDC With") - Image.asset(.phantom) - .renderingMode(.template) - .resizable() - .frame(width: 20, height: 20) - Text("Phantom") - } - } - .buttonStyle(.filled) - - Button("Dismiss", action: onDismiss) - .buttonStyle(.subtle) - } - .padding() - } - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift index 78a038d8..42a20fc0 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift @@ -16,7 +16,7 @@ struct SwapProcessingScreen: View { @Environment(Session.self) private var session @Environment(PushController.self) private var pushController @Environment(\.dismissParentContainer) private var dismissParentContainer - @Environment(WalletConnection.self) private var walletConnection + @Environment(PhantomCoordinator.self) private var phantomCoordinator // MARK: - Init - @@ -92,7 +92,7 @@ struct SwapProcessingScreen: View { .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) - .onChange(of: walletConnection.isProcessingCancelled, initial: true) { _, cancelled in + .onChange(of: phantomCoordinator.isProcessingCancelled, initial: true) { _, cancelled in if cancelled { viewModel.cancel() } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 3705aae0..62c02752 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -238,11 +238,12 @@ final class SessionAuthenticator { userID: initializedAccount.userID ) - let walletConnection = WalletConnection(owner: owner, client: container.client) + let walletConnection = WalletConnection(owner: owner) return SessionContainer( session: session, database: database, + client: container.client, walletConnection: walletConnection, ratesController: ratesController, historyController: historyController, @@ -417,11 +418,13 @@ struct SessionContainer { let flipClient: FlipClient let onrampDeeplinkInbox: OnrampDeeplinkInbox let onrampCoordinator: OnrampCoordinator + let phantomCoordinator: PhantomCoordinator let appRouter: AppRouter init( session: Session, database: Database, + client: Client, walletConnection: WalletConnection, ratesController: RatesController, historyController: HistoryController, @@ -437,6 +440,11 @@ struct SessionContainer { self.flipClient = flipClient self.onrampDeeplinkInbox = OnrampDeeplinkInbox() self.onrampCoordinator = OnrampCoordinator(session: session, flipClient: flipClient) + self.phantomCoordinator = PhantomCoordinator( + walletConnection: walletConnection, + session: session, + client: client + ) self.appRouter = AppRouter() } @@ -449,6 +457,7 @@ struct SessionContainer { .environment(pushController) .environment(walletConnection) .environment(onrampCoordinator) + .environment(phantomCoordinator) .environment(onrampDeeplinkInbox) } diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift index 6d0d236d..55ef9c65 100644 --- a/FlipcashTests/Buy/BuyAmountViewModelTests.swift +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -43,6 +43,17 @@ struct BuyAmountViewModelTests { ) ) + // Force USD as the balance currency. Without this the rates controller + // reads `LocalDefaults.balanceCurrency`, which can be polluted by other + // suites that called `configureTestRates(balanceCurrency: .cad, ...)` — + // the persisted `.cad` then makes `currentPinnedState(for: .cad)` return + // nil here (no CAD pin seeded), routing through `.staleRate` instead + // of the picker. + container.ratesController.configureTestRates( + balanceCurrency: .usd, + rates: [Rate(fx: 1.0, currency: .usd)] + ) + // Pin a fresh USD verified state so prepareSubmission() succeeds. await container.ratesController.verifiedProtoService.saveRates([ .freshRate(currencyCode: "USD", rate: 1.0) @@ -77,10 +88,10 @@ struct BuyAmountViewModelTests { viewModel.enteredAmount = "20" await viewModel.amountEnteredAction(router: router) - // Gate routed to auto-buy, not the picker. The subsequent + // Gate routed to reserve-funded buy, not the picker. The subsequent // session.buy network call is out of scope for this unit test — // here we only assert the gate decision. - #expect(viewModel.pendingMethodSelection == nil) + #expect(viewModel.pendingOperation == nil) } @Test( @@ -99,13 +110,13 @@ struct BuyAmountViewModelTests { viewModel.enteredAmount = enteredAmount await viewModel.amountEnteredAction(router: router) - let context = try #require(viewModel.pendingMethodSelection) - #expect(context.amount.nativeAmount.value > 0) + let operation = try #require(viewModel.pendingOperation) + #expect(operation.displayAmount.nativeAmount.value > 0) // No push fired — picker is a local sheet, not a stack destination. #expect(router[.balance].count == 0) } - @Test("Pinned amount is carried into the PurchaseMethodContext") + @Test("Pinned amount is carried into the PaymentOperation buy payload") func pinPropagation() async throws { let container = try await Self.makeContainer(usdfQuarks: 0) let viewModel = Self.makeViewModel(container: container) @@ -115,10 +126,14 @@ struct BuyAmountViewModelTests { viewModel.enteredAmount = "10" await viewModel.amountEnteredAction(router: router) - let context = try #require(viewModel.pendingMethodSelection) - // Native USD amount round-trips through the pin into the context. - #expect(context.amount.nativeAmount.value == 10) - #expect(context.amount.nativeAmount.currency == .usd) + let operation = try #require(viewModel.pendingOperation) + guard case .buy(let payload) = operation else { + Issue.record("Expected .buy operation, got \(operation)") + return + } + // Native USD amount round-trips through the pin into the payload. + #expect(payload.amount.nativeAmount.value == 10) + #expect(payload.amount.nativeAmount.currency == .usd) } @Test("Empty entered amount does nothing on submit") @@ -131,7 +146,7 @@ struct BuyAmountViewModelTests { viewModel.enteredAmount = "" await viewModel.amountEnteredAction(router: router) - #expect(viewModel.pendingMethodSelection == nil) + #expect(viewModel.pendingOperation == nil) #expect(router[.balance].count == 0) #expect(viewModel.dialogItem == nil) // Loading flicker on an empty submit would be a regression. diff --git a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift index 1bd28dde..00987662 100644 --- a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift +++ b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift @@ -52,9 +52,12 @@ struct PurchaseMethodSheetTests { let container = try SessionContainer.makeTest(holdings: []) // Default test session has no userFlags set, so hasCoinbase is false. - let methods = PurchaseMethodSheet.methods(forSession: container.session) + let visible = PurchaseMethodSheet.visibleSources( + from: [.applePay, .phantom, .otherWallet], + session: container.session + ) - #expect(methods == [.phantom, .otherWallet]) + #expect(visible == [.phantom, .otherWallet]) } } @@ -63,9 +66,26 @@ struct PurchaseMethodSheetTests { try Self.withCoinbaseBetaFlag(enabled: false) { let container = try Self.makeContainerWithCoinbase() - let methods = PurchaseMethodSheet.methods(forSession: container.session) + let visible = PurchaseMethodSheet.visibleSources( + from: [.applePay, .phantom, .otherWallet], + session: container.session + ) - #expect(methods == [.applePay, .phantom, .otherWallet]) + #expect(visible == [.applePay, .phantom, .otherWallet]) + } + } + + @Test("Launch flow's `sources` array omits Other Wallet — picker reflects exactly that") + func launchSources_excludeOtherWallet() throws { + try Self.withCoinbaseBetaFlag(enabled: false) { + let container = try Self.makeContainerWithCoinbase() + + let visible = PurchaseMethodSheet.visibleSources( + from: [.applePay, .phantom], + session: container.session + ) + + #expect(visible == [.applePay, .phantom]) } } } diff --git a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift index 1b731cff..f0a1626d 100644 --- a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift @@ -71,10 +71,7 @@ struct AppRouterNestedSheetTests { let router = AppRouter() router.present(.balance) router.presentNested(.buy(Self.mintA)) - router.pushAny(BuyFlowPath.phantomEducation( - mint: Self.mintA, - amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) - )) + router.push(.usdcDepositEducation) router.presentNested(.buy(Self.mintB)) @@ -188,10 +185,7 @@ struct AppRouterNestedSheetTests { let router = AppRouter() router.present(.balance) router.presentNested(.buy(Self.mintA)) - router.pushAny(BuyFlowPath.phantomEducation( - mint: Self.mintA, - amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) - )) + router.push(.usdcDepositEducation) router.dismissSheet() // Pop .buy(mintA); its path is still populated. router.presentNested(.buy(Self.mintA)) @@ -205,10 +199,7 @@ struct AppRouterNestedSheetTests { let router = AppRouter() router.present(.balance) router.presentNested(.buy(Self.mintA)) - router.pushAny(BuyFlowPath.phantomEducation( - mint: Self.mintA, - amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) - )) + router.push(.usdcDepositEducation) router.dismissSheet() // .buy dismissed router.dismissSheet() // .balance dismissed @@ -238,10 +229,7 @@ struct AppRouterNestedSheetTests { router.present(.balance) router.push(.currencyInfo(Self.mintA)) router.presentNested(.buy(Self.mintA)) - router.pushAny(BuyFlowPath.phantomEducation( - mint: Self.mintA, - amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) - )) + router.push(.usdcDepositEducation) router.dismissAll() router.present(.balance) @@ -286,10 +274,7 @@ struct AppRouterNestedSheetTests { router.present(.balance) router.presentNested(.buy(Self.mintA)) - router.pushAny(BuyFlowPath.phantomEducation( - mint: Self.mintA, - amount: ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: .oneToOne, supplyQuarks: nil) - )) + router.push(.usdcDepositEducation) #expect(router[.buy].count == 1, "pushes target the topmost stack") #expect(router[.balance].isEmpty, "root stack stays clean") diff --git a/FlipcashTests/Phantom/PhantomCoordinatorTests.swift b/FlipcashTests/Phantom/PhantomCoordinatorTests.swift new file mode 100644 index 00000000..1c8b9670 --- /dev/null +++ b/FlipcashTests/Phantom/PhantomCoordinatorTests.swift @@ -0,0 +1,172 @@ +// +// PhantomCoordinatorTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +/// Public-API coverage for `PhantomCoordinator`'s state machine. Tests that +/// drive the post-sign chain — simulation, server-notify, chain submit — +/// would require either yielding `DeeplinkEvent`s directly to the stream +/// (which crosses a `private` boundary on `WalletConnection`) or fabricating +/// fully-encrypted `transactionSigned` URLs. Both are deferred to a follow-up +/// that introduces protocol DI on the deeplink source. +@MainActor +@Suite("PhantomCoordinator — state machine") +struct PhantomCoordinatorTests { + + // MARK: - Fixtures + + private static func makeOperation() -> PaymentOperation { + .buy(.init( + mint: .usdc, + currencyName: "USDC", + amount: ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: 1_000_000, mint: .usdc), + rate: .oneToOne, + supplyQuarks: nil + ), + verifiedState: .stale(bonded: false) + )) + } + + private static func makeLaunchOperation() -> PaymentOperation { + let amount = ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: 5_000_000, mint: .usdf), + rate: .oneToOne, + supplyQuarks: nil + ) + return .launch(.init( + currencyName: "Jeffy", + total: amount, + launchAmount: amount, + launchFee: ExchangedFiat.compute( + onChainAmount: .zero(mint: .usdf), + rate: .oneToOne, + supplyQuarks: nil + ) + )) + } + + private static func makeCoordinator() -> PhantomCoordinator { + PhantomCoordinator( + walletConnection: .mock, + session: .mock, + client: Container.mock.client + ) + } + + // MARK: - State machine + + @Test("Initial state is .idle with no operation, no pending swap") + func initial_isIdle() { + let coordinator = Self.makeCoordinator() + #expect(coordinator.state == .idle) + #expect(coordinator.operation == nil) + #expect(coordinator.isAwaitingExternalSwap == false) + #expect(coordinator.processingState == .idle) + #expect(coordinator.isProcessingCancelled == false) + } + + @Test("start(_:) transitions to .connecting and captures the operation") + func start_transitionsToConnecting() { + let coordinator = Self.makeCoordinator() + let operation = Self.makeOperation() + + coordinator.start(operation) + + #expect(coordinator.state == .connecting) + #expect(coordinator.operation == operation) + } + + @Test("start(.buy) clears a leftover launchHandler from a prior wizard flow") + func start_buyClearsStaleLaunchHandler() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + + coordinator.start(Self.makeOperation()) + + #expect(coordinator.launchHandler == nil) + } + + @Test("start(.launch) preserves a launchHandler set by the caller") + func start_launchPreservesHandler() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + + coordinator.start(Self.makeLaunchOperation()) + + // Handler stays — the wizard sets it right before starting the + // launch flow and the coordinator must invoke it after the user signs. + #expect(coordinator.launchHandler != nil) + } + + @Test("confirm() is a no-op outside .awaitingConfirm") + func confirm_noOpFromWrongState() { + let coordinator = Self.makeCoordinator() + // No start() called — state is .idle. + coordinator.confirm() + #expect(coordinator.state == .idle) + + coordinator.start(Self.makeOperation()) + #expect(coordinator.state == .connecting) + // Calling confirm during the connect handshake should not advance. + coordinator.confirm() + #expect(coordinator.state == .connecting) + } + + @Test("cancel() resets pre-signing state to .idle") + func cancel_resetsToIdle() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + coordinator.start(Self.makeLaunchOperation()) + + coordinator.cancel() + + #expect(coordinator.state == .idle) + #expect(coordinator.operation == nil) + #expect(coordinator.launchHandler == nil) + #expect(coordinator.isAwaitingExternalSwap == false) + } + + @Test("dismissProcessing() clears processingState and pre-signing state") + func dismissProcessing_resetsAll() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + coordinator.start(Self.makeLaunchOperation()) + + coordinator.dismissProcessing() + + #expect(coordinator.state == .idle) + #expect(coordinator.operation == nil) + #expect(coordinator.launchHandler == nil) + #expect(coordinator.processingState == .idle) + } + + @Test("Setting processing = nil while idle is a no-op") + func processing_nilWhileIdle_noOp() { + let coordinator = Self.makeCoordinator() + coordinator.processing = nil // would crash if it tried to mutate + #expect(coordinator.processingState == .idle) + #expect(coordinator.processing == nil) + } + + @Test("Setting launchProcessing = nil while idle is a no-op") + func launchProcessing_nilWhileIdle_noOp() { + let coordinator = Self.makeCoordinator() + coordinator.launchProcessing = nil + #expect(coordinator.processingState == .idle) + #expect(coordinator.launchProcessing == nil) + } + + @Test("isAwaitingExternalSwap is false when no pending swap") + func isAwaitingExternalSwap_falseInitially() { + let coordinator = Self.makeCoordinator() + #expect(coordinator.isAwaitingExternalSwap == false) + coordinator.start(Self.makeOperation()) + #expect(coordinator.isAwaitingExternalSwap == false) // not until confirm + } +} diff --git a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift index 7abeb414..f2ac9e00 100644 --- a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift +++ b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift @@ -62,14 +62,18 @@ struct Regression_native_amount_mismatch { router.present(.balance) await vm.amountEnteredAction(router: router) - let context = try #require(vm.pendingMethodSelection) + let operation = try #require(vm.pendingOperation) + guard case .buy(let payload) = operation else { + Issue.record("Expected .buy operation, got \(operation)") + return + } // $1 CAD / 1.35 × 10^6, HALF_UP rounded via scaleUpInt → 740_741 USDF quarks. // The buggy live path (1.37) would round to 729_927 quarks — the value // the server rejected in production. - #expect(context.amount.onChainAmount.quarks == 740_741) - #expect(context.amount.currencyRate.fx == Decimal(1.35)) - #expect(context.verifiedState.exchangeRate == 1.35) + #expect(payload.amount.onChainAmount.quarks == 740_741) + #expect(payload.amount.currencyRate.fx == Decimal(1.35)) + #expect(payload.verifiedState.exchangeRate == 1.35) } // MARK: - Scenario D (sell) @@ -170,7 +174,7 @@ struct Regression_native_amount_mismatch { router.present(.balance) await vm.amountEnteredAction(router: router) - #expect(vm.pendingMethodSelection == nil) + #expect(vm.pendingOperation == nil) #expect(vm.dialogItem?.title == DialogItem.staleRate.title) } diff --git a/FlipcashTests/TestSupport/Mocks.swift b/FlipcashTests/TestSupport/Mocks.swift index 433b7c91..e716f20c 100644 --- a/FlipcashTests/TestSupport/Mocks.swift +++ b/FlipcashTests/TestSupport/Mocks.swift @@ -96,6 +96,7 @@ extension SessionContainer { return .init( session: session, database: database, + client: Container.mock.client, walletConnection: .mock, ratesController: ratesController, historyController: historyController, diff --git a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift index 357f7951..318831ac 100644 --- a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift +++ b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift @@ -72,6 +72,7 @@ extension SessionContainer { return SessionContainer( session: session, database: database, + client: Container.mock.client, walletConnection: .mock, ratesController: ratesController, historyController: .mock, diff --git a/FlipcashTests/WalletConnectionStateTests.swift b/FlipcashTests/WalletConnectionStateTests.swift deleted file mode 100644 index 4c9e64ea..00000000 --- a/FlipcashTests/WalletConnectionStateTests.swift +++ /dev/null @@ -1,382 +0,0 @@ -// -// WalletConnectionStateTests.swift -// FlipcashTests -// - -import Foundation -import Testing -import FlipcashCore -@testable import Flipcash - -@MainActor -@Suite("WalletConnection state machine") -struct WalletConnectionStateTests { - - nonisolated private static let buyingContext = ExternalSwapProcessing( - swapId: .generate(), - currencyName: "Test Coin", - amount: ExchangedFiat.mockOne - ) - - nonisolated private static let launchingContext = ExternalLaunchProcessing( - swapId: .generate(), - launchedMint: .jeffy, - currencyName: "New Coin", - amount: ExchangedFiat.mockOne - ) - - private func makeConnection(rpc: any SolanaRPC = SolanaJSONRPCClient()) -> WalletConnection { - WalletConnection(owner: .mock, client: .mock, rpc: rpc) - } - - @Test("`.idle` reports no active processing") - func idleReportsNoProcessing() { - let conn = makeConnection() - #expect(conn.state == .idle) - #expect(conn.processing == nil) - #expect(conn.launchProcessing == nil) - #expect(conn.isProcessingCancelled == false) - } - - @Test("`.buying` exposes context via `processing`, not `launchProcessing`") - func buyingExposesProcessing() { - let conn = makeConnection() - conn.state = .buying(Self.buyingContext, isFailed: false) - #expect(conn.processing == Self.buyingContext) - #expect(conn.launchProcessing == nil) - #expect(conn.isProcessingCancelled == false) - } - - @Test("`.launching` exposes context via `launchProcessing`, not `processing`") - func launchingExposesLaunchProcessing() { - let conn = makeConnection() - conn.state = .launching(Self.launchingContext, isFailed: false) - #expect(conn.launchProcessing == Self.launchingContext) - #expect(conn.processing == nil) - #expect(conn.isProcessingCancelled == false) - } - - @Test( - "`isFailed: true` surfaces through `isProcessingCancelled`, context preserved", - arguments: [ - WalletProcessingState.buying(buyingContext, isFailed: true), - WalletProcessingState.launching(launchingContext, isFailed: true), - ] - ) - func failedStateFlipsCancelledFlag(initial: WalletProcessingState) { - let conn = makeConnection() - conn.state = initial - #expect(conn.isProcessingCancelled == true) - #expect(conn.state == initial) - } - - @Test("Setting `processing = nil` transitions `.buying` to `.idle`") - func nillingProcessingWhileBuyingResetsToIdle() { - let conn = makeConnection() - conn.state = .buying(Self.buyingContext, isFailed: false) - conn.processing = nil - #expect(conn.state == .idle) - } - - @Test("Setting `processing = nil` while `.launching` is a no-op") - func nillingProcessingWhileLaunchingIsNoOp() { - let conn = makeConnection() - let initial = WalletProcessingState.launching(Self.launchingContext, isFailed: false) - conn.state = initial - conn.processing = nil - #expect(conn.state == initial) - } - - @Test("Setting `launchProcessing = nil` transitions `.launching` to `.idle`") - func nillingLaunchProcessingWhileLaunchingResetsToIdle() { - let conn = makeConnection() - conn.state = .launching(Self.launchingContext, isFailed: false) - conn.launchProcessing = nil - #expect(conn.state == .idle) - } - - @Test("Setting `launchProcessing = nil` while `.buying` is a no-op") - func nillingLaunchProcessingWhileBuyingIsNoOp() { - let conn = makeConnection() - let initial = WalletProcessingState.buying(Self.buyingContext, isFailed: false) - conn.state = initial - conn.launchProcessing = nil - #expect(conn.state == initial) - } - - @Test( - "`dismissProcessing()` resets to `.idle` from any active state", - arguments: [ - WalletProcessingState.buying(buyingContext, isFailed: false), - WalletProcessingState.buying(buyingContext, isFailed: true), - WalletProcessingState.launching(launchingContext, isFailed: false), - WalletProcessingState.launching(launchingContext, isFailed: true), - ] - ) - func dismissProcessingResetsActiveStates(initial: WalletProcessingState) { - let conn = makeConnection() - conn.state = initial - conn.dismissProcessing() - #expect(conn.state == .idle) - } - - @Test("`dismissProcessing()` clears `dialogItem` even when `.idle`") - func dismissProcessingClearsDialog() { - let conn = makeConnection() - conn.dialogItem = .init( - style: .destructive, - title: "T", - subtitle: "S", - dismissable: true - ) { .okay(kind: .destructive) } - conn.dismissProcessing() - #expect(conn.dialogItem == nil) - #expect(conn.state == .idle) - } - - // MARK: - Simulation outcome - - - @Test("Preflight rejection returns `.blocked` with a user-facing dialog") - func simulationRejectionBlocks() async { - let conn = makeConnection(rpc: StubRPC(simulate: .failure( - SolanaRPCError.transactionSimulationError(logs: [ - "Program 11111111 invoke [1]", - "Transfer: insufficient lamports", - "Program 11111111 failed: custom program error: 0x1", - ]) - ))) - - let outcome = await conn.simulateSignedTransaction("dummyBase64", swapMetadata: [:]) - - switch outcome { - case .proceed: - Issue.record("Expected .blocked for simulation rejection") - case .blocked(let dialog): - #expect(dialog.title == "Transaction Failed") - #expect(dialog.subtitle?.contains("wouldn't accept") == true) - } - } - - @Test("Non-simulation errors pass through as `.proceed`") - func simulationTransportErrorProceeds() async { - let conn = makeConnection(rpc: StubRPC(simulate: .failure(URLError(.timedOut)))) - - let outcome = await conn.simulateSignedTransaction("dummyBase64", swapMetadata: [:]) - - if case .blocked = outcome { - Issue.record("Transport errors must not block — the RPC may just be flaky") - } - } - - @Test("Successful simulation returns `.proceed`") - func simulationSuccessProceeds() async { - let conn = makeConnection(rpc: StubRPC(simulate: .succeeds)) - - let outcome = await conn.simulateSignedTransaction("dummyBase64", swapMetadata: [:]) - - if case .blocked = outcome { - Issue.record("Successful simulation must proceed to chain submission") - } - } - - // MARK: - completeSwap full flow - - - @Test( - "Any RPC-reported preflight rejection halts the flow before server + chain submit", - arguments: [ - SolanaRPCError.transactionSimulationError(logs: ["insufficient funds"]), - SolanaRPCError.responseError(SolanaRPCResponseError( - code: -32002, - message: "insufficient funds", - data: SolanaRPCResponseError.Payload(logs: ["Transfer: insufficient lamports"]) - )), - ] - ) - func completeSwap_preflightRejectionHaltsFlow(_ rpcError: SolanaRPCError) async { - let rpc = StubRPC( - simulate: .failure(rpcError), - send: .failure(TestError.shouldNotBeCalled) - ) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap(onCompleted: { _, _ in - Issue.record("Server notification must not run after preflight rejection") - throw TestError.shouldNotBeCalled - }) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - #expect(conn.state == .idle) - #expect(conn.dialogItem?.title == "Transaction Failed") - } - - @Test("Chain submit failure flips `.buying` to isFailed: true") - func completeSwap_chainSubmitFailureMarksFailed() async { - let swapId = SwapId.generate() - let rpc = StubRPC(simulate: .succeeds, send: .failure(URLError(.networkConnectionLost))) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap( - fundingSwapId: swapId, - onCompleted: { _, _ in .buyExisting(swapId: swapId) } - ) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - guard case .buying(_, let isFailed) = conn.state else { - Issue.record("Expected state to remain `.buying` so the processing screen can surface the failure") - return - } - #expect(isFailed == true) - } - - @Test("Chain submit success for `.buyExisting` leaves `.buying` clean") - func completeSwap_buyExistingSuccessStaysBuying() async { - let swapId = SwapId.generate() - let rpc = StubRPC(simulate: .succeeds, send: .succeeds) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap( - fundingSwapId: swapId, - onCompleted: { _, _ in .buyExisting(swapId: swapId) } - ) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - guard case .buying(let ctx, let isFailed) = conn.state else { - Issue.record("Expected `.buying` state after successful buy-existing flow") - return - } - #expect(isFailed == false) - #expect(ctx.swapId == swapId) - } - - @Test("Chain submit success for `.launch` transitions state to `.launching`") - func completeSwap_launchSuccessTransitionsToLaunching() async { - let fundingId = SwapId.generate() - let buyId = SwapId.generate() - let mint = PublicKey.mock - let rpc = StubRPC(simulate: .succeeds, send: .succeeds) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap( - fundingSwapId: fundingId, - onCompleted: { _, _ in .launch(swapId: buyId, mint: mint) } - ) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - guard case .launching(let ctx, let isFailed) = conn.state else { - Issue.record("Expected `.launching` state after successful launch flow") - return - } - #expect(isFailed == false) - #expect(ctx.swapId == buyId) - #expect(ctx.launchedMint == mint) - } - - @Test("Server notification failure resets to `.idle` without submitting") - func completeSwap_serverNotificationFailureResetsToIdle() async { - let rpc = StubRPC(simulate: .succeeds, send: .failure(TestError.shouldNotBeCalled)) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap(onCompleted: { _, _ in - throw TestError.serverRejected - }) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - #expect(conn.state == .idle) - } - - @Test("Missing pending is a no-op") - func completeSwap_nilPendingIsNoOp() async { - let rpc = StubRPC( - simulate: .failure(TestError.shouldNotBeCalled), - send: .failure(TestError.shouldNotBeCalled) - ) - let conn = makeConnection(rpc: rpc) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: nil).value - - #expect(conn.state == .idle) - #expect(conn.dialogItem == nil) - } - - // MARK: - Helpers - - - private static func makePendingSwap( - fundingSwapId: SwapId = .generate(), - displayName: String = "Test Coin", - amount: ExchangedFiat = .mockOne, - onCompleted: @escaping @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult - ) -> WalletConnection.PendingSwap { - WalletConnection.PendingSwap( - fundingSwapId: fundingSwapId, - amount: amount, - displayName: displayName, - onCompleted: onCompleted - ) - } - - /// Produces base58 bytes that round-trip cleanly through - /// `SolanaTransaction(data:)` — the decode gate inside `didSignTransaction`. - /// The signatures are zero-filled, which is fine because the production - /// flow only reads `tx.identifier` (which maps to `signatures[0]`) and - /// forwards it to the server-notify closure. - private static func validSignedTxBase58() -> String { - let tx = SolanaTransaction( - payer: PublicKey.mock, - recentBlockhash: nil, - instructions: [] as [Instruction] - ) - return Base58.fromBytes([UInt8](tx.encode())) - } -} - -// MARK: - StubRPC - - -/// Minimal `SolanaRPC` for tests. Both `simulateTransaction` and -/// `sendTransaction` are configurable; `getLatestBlockhash` traps because no -/// current test exercises paths that fetch a blockhash. -private struct StubRPC: SolanaRPC { - enum SimulateBehavior { - case succeeds - case failure(Error) - } - - enum SendBehavior { - case succeeds - case failure(Error) - } - - let simulate: SimulateBehavior - let send: SendBehavior - - init(simulate: SimulateBehavior, send: SendBehavior = .failure(TestError.shouldNotBeCalled)) { - self.simulate = simulate - self.send = send - } - - func getLatestBlockhash(commitment: SolanaCommitment) async throws -> Hash { - fatalError("getLatestBlockhash not stubbed") - } - - func sendTransaction(_ base64Transaction: String, configuration: SolanaSendTransactionConfig) async throws -> Signature { - switch send { - case .succeeds: - return .mock - case .failure(let error): - throw error - } - } - - func simulateTransaction(_ base64Transaction: String, configuration: SolanaSimulateTransactionConfig) async throws -> SolanaSimulationResult { - switch simulate { - case .succeeds: - return SolanaSimulationResult(err: nil, logs: []) - case .failure(let error): - throw error - } - } -} - -private enum TestError: Error { - case shouldNotBeCalled - case serverRejected -} diff --git a/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift index f40b8231..01790506 100644 --- a/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift +++ b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift @@ -10,20 +10,17 @@ import XCTest /// /// - The buy nested sheet opens on top of CurrencyInfoScreen. /// - An amount above the USDF balance routes to `PurchaseMethodSheet`. -/// - Selecting Phantom on an account with no saved Phantom session pushes -/// `PhantomEducationScreen` (rather than skipping directly to the -/// Confirm screen — the no-session branch in `PurchaseMethodSheet`). +/// - Selecting Phantom pushes `PhantomEducationScreen` (the picker no longer +/// triggers a connect deeplink — that happens when the user taps the +/// Connect CTA on the education screen). /// - The Connect CTA is hittable. /// -/// The test stops at the Connect tap. Driving the actual Phantom callback -/// is out of scope — `WalletCallbackRegressionTests` covers the deeplink -/// re-entry surface separately. +/// The test stops short of the Connect tap. Driving the actual Phantom +/// callback is out of scope for the local simulator without a real Phantom +/// install. /// /// **Prerequisites:** /// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` -/// - The test account must NOT have a saved Phantom session in Keychain -/// (a fresh test account is fine; a previously-Phantom-connected account -/// would skip past `PhantomEducationScreen`). final class BuyPhantomRegressionTests: BaseUITestCase { override var requiresAuthentication: Bool { true } diff --git a/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift b/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift index 766631d4..ffbd0b01 100644 --- a/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift +++ b/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift @@ -6,10 +6,10 @@ import XCTest /// Page object for the PhantomEducationScreen — the pre-flight shown when -/// the user picks Phantom from `PurchaseMethodSheet` without a saved Phantom -/// session. Tapping `connectButton` triggers `walletConnection.connect()` -/// which deeplinks out to Phantom (tests can't reasonably continue past that -/// point on the local simulator without a real Phantom install). +/// the user picks Phantom from `PurchaseMethodSheet`. Tapping `connectButton` +/// triggers `PhantomCoordinator.start(_:)` which always deeplinks out to +/// Phantom for connect (tests can't reasonably continue past that point on +/// the local simulator without a real Phantom install). @MainActor struct PhantomEducationScreen { diff --git a/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift b/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift index 37708793..9f85009c 100644 --- a/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift +++ b/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift @@ -6,9 +6,10 @@ import XCTest /// Page object for the PurchaseMethodSheet shown when the user's USDF -/// reserve doesn't cover the entered buy amount. Lists Apple Pay (conditional -/// on the Coinbase onramp gate), Phantom, and Other Wallet, plus a Dismiss -/// row. +/// reserve doesn't cover the entered buy amount (or any time the launch flow +/// opens the picker). Lists Apple Pay (conditional on the Coinbase onramp +/// gate), Phantom, and Other Wallet (omitted for the launch flow), plus a +/// Dismiss row. @MainActor struct PurchaseMethodSheet { @@ -20,10 +21,11 @@ struct PurchaseMethodSheet { // MARK: - Elements - /// Apple Pay row. Label contains the U+F8FF "Pay" glyph; matched by - /// "Debit Card with" prefix so it's robust to glyph-rendering differences. + /// Apple Pay row. Matched by accessibility identifier — the visible label + /// is just the U+F8FF Apple logo glyph + "Pay" (intentional per HIG), + /// which is brittle to match by label predicate. var applePayButton: XCUIElement { - app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Debit Card with'")).firstMatch + app.buttons["apple-pay-method-button"] } /// Phantom row. The button's label is just "Phantom" since the inline