feat: amount-first buy flow#275
Open
raulriera wants to merge 30 commits into
Open
Conversation
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.
669f39b to
512cebe
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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