Skip to content

feat: amount-first buy flow#275

Open
raulriera wants to merge 30 commits into
mainfrom
feat/amount-first-buy-flow
Open

feat: amount-first buy flow#275
raulriera wants to merge 30 commits into
mainfrom
feat/amount-first-buy-flow

Conversation

@raulriera
Copy link
Copy Markdown
Collaborator

@raulriera raulriera commented May 13, 2026

Summary

Inverts the buy flow so the user enters an amount first, then picks a funding method only if their USDF reserve doesn't cover it. The flow opens as a sheet over the wallet and currency info instead of pushing onto the navigation stack, so the underlying context stays visible. Apple Pay, Phantom, and direct USDC deposit each get their own pre-flight and completion screens unified under one stack.

The router learned to stack sheets to support this UX — sheets are an ordered stack with a root and any number of nested layers, with a shared modifier that mounts each nested level from inside its parent's content tree (the only place SwiftUI actually allows them to stack).

Test plan

  • Confirm the buy sheet stacks on top of currency info and dismisses back to it.
  • Confirm the entry region can be changed from the amount screen.
  • With enough USDF, confirm the buy completes and lands back on currency info.
  • With Apple Pay, confirm the buy completes and lands back on currency info.
  • With a saved Phantom session, confirm the buy completes and lands back on currency info.
  • Without a saved Phantom session, confirm the connect step runs before the buy completes.
  • Confirm cancelling the Phantom connect shows no error and keeps the sheet open.
  • Confirm cancelling the Phantom sign shows an error above the sheet without dismissing it.
  • Confirm Other Wallet copies the deposit address and shows a toast.
  • Confirm the deposit deeplink opens the buy sheet over currency info.
  • Confirm the buy sheet can't be swiped down during signing or Apple Pay.

raulriera added 26 commits May 13, 2026 12:54
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.
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.
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.
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.
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.
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.
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.
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.
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.
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<SheetPresentation> 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.
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.
`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.
@raulriera raulriera force-pushed the feat/amount-first-buy-flow branch from 669f39b to 512cebe Compare May 13, 2026 17:42
raulriera added 3 commits May 13, 2026 14:36
…ordinator dialog

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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant