Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
dfe81a1
feat: add .buy router sheet and BuyFlowPath sub-flow
raulriera May 12, 2026
b4f5dab
chore: address review nits on buy router foundation
raulriera May 12, 2026
792851a
feat: add BuyAmountViewModel with USDF balance gate
raulriera May 12, 2026
525db8a
chore: address review nits on BuyAmountViewModel
raulriera May 12, 2026
3ba932b
feat: scaffold buy flow screens and register .buy sheet
raulriera May 12, 2026
92ce901
chore: address review nits on buy flow scaffold
raulriera May 12, 2026
736a307
feat: route currency-info buy button to new .buy sheet
raulriera May 12, 2026
3592f91
feat: implement PurchaseMethodSheet with Apple Pay / Phantom / Other …
raulriera May 12, 2026
7731259
chore: address review nits on PurchaseMethodSheet
raulriera May 12, 2026
190fd0e
feat: implement PhantomEducationScreen with state-driven push to confirm
raulriera May 12, 2026
5807f22
feat: implement PhantomConfirmScreen and chain to .processing
raulriera May 12, 2026
068f971
feat: implement USDC deposit education and address screens
raulriera May 12, 2026
b0c0221
feat: wire Apple Pay button through OnrampCoordinator on buy flow
raulriera May 12, 2026
cd9ef71
feat: deeplink .currencyInfoForDeposit opens new buy sheet
raulriera May 12, 2026
48abb6e
refactor: delete legacy buy/onramp screens and viewmodels
raulriera May 12, 2026
2cc21dd
chore: address /simplify findings on buy flow
raulriera May 12, 2026
a78a8bc
fix: address SwiftUI + concurrency + testing review findings on buy flow
raulriera May 12, 2026
30c4d73
fix: buy flow as navigation push instead of top-level sheet
raulriera May 12, 2026
a809123
fix: scope dismissParentContainer to the navigation destination
raulriera May 12, 2026
cf548b7
fix: restore currency picker on buy amount screen
raulriera May 12, 2026
8c4b86b
feat: open buy flow as nested sheet on top of currency info
raulriera May 13, 2026
564b5bb
fix: phantom buy flow polish
raulriera May 13, 2026
177291a
refactor: extract BadgedIcon, redesign Phantom screens, share assets
raulriera May 13, 2026
a35c238
refactor: address review findings on the nested-sheet buy flow
raulriera May 13, 2026
aed65a4
test: rewrite buy flow XCUITests for the nested-sheet UX
raulriera May 13, 2026
512cebe
fix: block swipe-dismiss on the buy processing screen
raulriera May 13, 2026
7ff0165
fix: buy flow polish — swipe lock, button spinner, Apple Pay gate, co…
raulriera May 13, 2026
0da61c4
fix: rebuild swap amount from Coinbase's recorded purchase
raulriera May 13, 2026
69dc272
chore: address /simplify findings on buy flow
raulriera May 13, 2026
9bbaa3f
feat: USDF Currency Info Deposit/Withdraw and normalize balance row
raulriera May 13, 2026
08288cd
refactor: BadgedIcon uses a fixed size across all call sites
raulriera May 14, 2026
12a913d
fix: keep empty state on the wallet when only USDF is held
raulriera May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[.<stack>])` 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[.<stack>])` 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.

Expand Down Expand Up @@ -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
Expand Down
42 changes: 38 additions & 4 deletions Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -500,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<Void, Swift.Error>) in
pendingConnect?.resume(throwing: CancellationError())
pendingConnect = continuation
connectToPhantom()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Swift.Error>) 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

Expand Down
29 changes: 29 additions & 0 deletions Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
37 changes: 34 additions & 3 deletions Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -667,7 +671,7 @@ final class OnrampCoordinator {
}

Analytics.onrampInvokePayment(amount: amount.usdfValue)

let id = UUID()
let userRef = session.ownerKeyPair.publicKey.base58
let orderRef = "\(userRef):\(id)"
Expand Down Expand Up @@ -698,16 +702,39 @@ final class OnrampCoordinator {
))

logger.info("Coinbase order created", metadata: [
"order_id": "\(response.id)"
"order_id": "\(response.id)",
"recorded_purchase": "\(response.order.purchaseAmount ?? "<missing>")",
])

// 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 {
Expand Down Expand Up @@ -913,6 +940,10 @@ final class OnrampCoordinator {

// MARK: - Supporting types -

enum OnrampError: Error {
case missingCoinbaseApiKey
}

private enum Origin: Int {
case root
case info
Expand Down
18 changes: 18 additions & 0 deletions Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// 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
@MainActor
final class OnrampDeeplinkInbox {
var pendingEmailVerification: VerificationDescription?
}
10 changes: 6 additions & 4 deletions Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +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 (`OnrampAmountScreen`,
/// `CurrencyCreationWizardScreen`) 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
Expand Down
21 changes: 21 additions & 0 deletions Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
22 changes: 20 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension AppRouter {

// Wallet flow
case currencyInfo(PublicKey)
/// Same screen as `currencyInfo` but auto-presents the funding-selection
/// 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".
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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"
Expand All @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct DestinationView: View {
mint: mint,
container: container,
sessionContainer: sessionContainer,
showFundingOnAppear: true
showBuyOnAppear: true
)
.id(mint)

Expand Down Expand Up @@ -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()
}
}
}
Expand Down
Loading