Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
91 changes: 91 additions & 0 deletions .claude/plans/2026-08-20-newui-flag-teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# `BetaFlags.Option.newUI` teardown

The tab-bar UI shipped to everyone in #613 by flipping `newUI` to `.shipped`. This is the
follow-up that removes the flag itself, collapses every branch it gated, and deletes the
v1 surfaces stranded as a result. Mirrors the Android teardown (`code-android-app` #1290).

`BetaFlags` itself stays — `.vibrateOnScan` and `.enableCoinbase` still use it, and
`Availability.shipped` is kept as the mechanism for the next rollout.

## The two shells, collapsed to one

v1 was scanner-first: `ScanScreen` owned the chrome (`ScanTopBar`, `ScanBottomBar`), the
balance was a screen (`BalanceScreen`) presented as the `.balance` sheet, and Discover was
the `.discover` sheet. v2 is `HomeTabView` — Scan / Wallet / Chat / You — where the wallet
and the tips list are *tabs*, not sheets.

Deleted outright: `BalanceScreen`, `ScanTopBar`, `ScanBottomBar`, `CurrencyCreationPromoCard`,
`CurrencyInfoHeaderSection`, `CurrencyInfoFooter`, and the whole v1 sell flow
(`CurrencySellViewModel`, `CurrencySellAmountScreen`, `CurrencySellConfirmationScreen`,
`CurrencySellConfirmationViewModel`) — Convert replaced it.

Extracted rather than deleted, because v2 still needs them:
- `ExchangedBalance` — the balance model that lived inside `BalanceScreen`.
- `Home/BalanceHeaderButton` — the wallet's balance header.
- `Navigation/RootSheetHost` — the app-level sheet host `ScanScreen` used to embed, so
`router.present(_:)` works from any tab rather than only from the scanner.

## Router: two sheets became tabs

`SheetPresentation` lost `.balance` and `.discover`. That has knock-on effects worth
knowing before touching `AppRouter`:

- `Stack.sheet` is now `nil` for `.balance` and `.you` (tab stacks) as well as for
`.buy` / `.addMoney` / `.sendAmount` (nested-only, payload-bearing).
- `navigate(to:)` therefore has two branches. The tab branch is checked **first**, because
a tab stack has no sheet to look up: it dismisses every sheet, sets the path, and parks
`requestedTabStack` for `HomeTabView.selectRequestedTab()` to consume. The sheet branch
handles the rest.
- Which branch a destination takes is decided by `Stack.isTabHosted`, a static fact on the
stack — *not* a set registered at runtime by the view. An earlier revision had
`HomeTabView.onAppear` publish `router.tabStacks`; a deep link arriving before that view
appeared would then find the set empty, fall through to the sheet lookup, and be dropped
on the floor (`.balance` has no sheet). `isTabHosted` must agree with `HomeTab.pushStack`
— `AppRouterCrossStackTests.tabHostedStacks_matchHomeTabs()` pins the two together.
- `.tips` is **both**: the Chat tab hosts it, and `present(.tips)` still puts the same
stack in a sheet from surfaces with no tab bar. The tab wins for `navigate(to:)`.
- Deep links follow: `flipcash://balance` → `.wallet` (bring the tab forward at its root),
`flipcash://discover` → `.discoverCurrencies` (a push onto the wallet).
- `topmostStack` is `presentedSheet?.stack ?? activeTabStack`, so `push`/`pop` work in a
tab with no sheet up. The "no sheet presented" warnings are now "no topmost stack".

## Currency Info

`CurrencyInfoScreen` collapsed onto `CurrencyInfoContentV2`. The v2 layout renders
**Give / Convert / Withdraw** for a held currency and only **Get** for one that isn't
held — so an owned token has no Buy affordance at all. Anything that navigated to Buy
from a wallet currency needs a new entry point.

## Test fallout

- Router fixtures: tests about *sheet* semantics swapped the removed `.balance`/`.discover`
root for `.give` (or `.settings`/`.tips` where `.give` collided). Tests about *stack
paths* kept `.balance` and host it via `router.activeTabStack = .balance`, which also
exercises the `topmostStack` fallback.
- `navigate` can only reach three owning stacks now (`.balance`, `.settings`, `.tips`) and
two of those are tab-hosted, so the only sheet↔sheet swap left is into `.settings`. The
cross-stack suite was rewritten around that rather than renamed.
- The deleted sell suite's money math was ported to `ConvertConfirmationViewModelTests`
(fee bps, native-proportional scaling, `UInt64` overflow, stale-pin refusal) and two
converted scenarios in `Regression_native_amount_mismatch`.
- XCUITests: none. The suite's tab-bar rewrite landed separately in #659 and is
recorded in `2026-08-20-ui-test-tab-bar-rewrite.md`; this branch rebased onto it and
kept none of its own UI-test changes.

## Orphan sweep

After the deletions, a HEAD-vs-worktree reference-count diff found exactly three
symbols whose last consumer was v1 code:

| Symbol | Was used by | Disposition |
|---|---|---|
| `Session.canUseTips` | `ScanScreen`'s bottom bar | deleted (body was `true` — Tips shipped out of beta) |
| `Image.Symbol.hamburger` | `ScanTopBar` | deleted, along with `UI.xcassets/icons/hamburger.imageset` |
| `Analytics.TokenInfoEvent.openedFromWallet` | `BalanceScreen` | **kept and rewired** — see below |

`openedFromWallet` marks the wallet → token-info funnel step, which the tab-bar
shell stopped emitting because the v2 wallet expands the card in place rather
than pushing a screen. Rather than lose the signal with v1, `WalletScreen`
now fires it from `openCard`. `openedFromDeeplink` had the same gap (dead since
before this change), so `openCardImmediately` — reached only from
`DeepLinkController`'s `requestedCardMint` — now fires that one.
23 changes: 8 additions & 15 deletions Flipcash/Core/ContainerScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import FlipcashUI
struct ContainerScreen: View {

@Environment(SessionAuthenticator.self) var sessionAuthenticator
@Environment(BetaFlags.self) var betaFlags

var body: some View {
VStack {
Expand All @@ -35,21 +34,15 @@ struct ContainerScreen: View {

case .loggedIn(let sessionContainer):
ZStack {
Group {
if betaFlags.hasEnabled(.newUI) {
HomeTabView()
} else {
ScanScreen()
}
}
.modifier(OnrampHostModifier())
HomeTabView()
.modifier(OnrampHostModifier())

// Bills / tipcards render at the app root (over both the v1
// scanner and the v2 tab bar) so a bill set by a push or deep
// link appears over whatever tab is showing, not buried in the
// unmounted Scan tab. Mirrors Android's app-root BillOverlay.
// Kept a sibling *inside* the injected scope (rather than an
// `.overlay` on the Group) so it inherits `SessionContainer`.
// Bills / tipcards render at the app root, over the tab bar,
// so a bill set by a push or deep link appears over whatever
// tab is showing, not buried in the unmounted Scan tab.
// Mirrors Android's app-root BillOverlay. Kept a sibling
// *inside* the injected scope (rather than an `.overlay` on
// the tab view) so it inherits `SessionContainer`.
BillOverlayView()
}
.injectingEnvironment(from: sessionContainer)
Expand Down
15 changes: 0 additions & 15 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,6 @@ class BetaFlags {
return options.contains(option)
}

/// Whether Dollars (USDF) can be given, sent, or tipped like a community
/// currency. Dollars give ships with the new UI: its only entry point is the
/// Give tile on the Dollars card, which the new currency-info layout alone
/// draws, so on the old UI a Dollars row in a give picker would offer a
/// currency the rest of that UI never gives.
var allowsDollarsGive: Bool {
hasEnabled(.newUI)
}

/// Whether the Beta Features screen has anything to show — the public
/// flags everyone gets, plus the developer-only section once the version
/// easter egg unlocks access. False means the screen draws its empty state.
Expand Down Expand Up @@ -134,7 +125,6 @@ extension BetaFlags {

case vibrateOnScan
case enableCoinbase
case newUI

var id: String {
localizedTitle
Expand All @@ -146,8 +136,6 @@ extension BetaFlags {
return "Vibrate on scan"
case .enableCoinbase:
return "Enable Coinbase"
case .newUI:
return "New tab-bar UI"
}
}

Expand All @@ -157,8 +145,6 @@ extension BetaFlags {
return "If enabled, the device will vibrate to indicate that the camera has registered the code on the bill"
case .enableCoinbase:
return "If enabled, Coinbase onramp will be available regardless of region"
case .newUI:
return "If enabled, the app launches into the new tab-bar UI (Wallet, Scan, Chat, Tip Card) instead of the scanner-first UI"
}
}

Expand All @@ -167,7 +153,6 @@ extension BetaFlags {
switch self {
case .vibrateOnScan: return .developer
case .enableCoinbase: return .developer
case .newUI: return .shipped
}
}
}
Expand Down
69 changes: 35 additions & 34 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,10 @@ final class DeepLinkController {
return action(.openSheet(.give))

case .balance:
return action(.openSheet(.balance))
return action(.wallet)

case .discover:
return action(.openSheet(.discover))
return action(.discoverCurrencies)

case .unknown:
break
Expand Down Expand Up @@ -236,19 +236,14 @@ struct DeepLinkAction {
case .currencyInfo(let mint):
if let container = sessionAuthenticator.loggedInContainer {
Analytics.deeplinkRouted(kind: kind)
// The wallet opens the token as its expanded card, so a link
// lands exactly where tapping the card would — same chrome, same
// dismissal. Pushing it instead gives a screen with a back
// chevron that belongs to a stack the user never navigated.
let router = container.appRouter
if router.tabStacks.contains(.balance) {
// v2: the wallet opens the token as its expanded card, so a
// link lands exactly where tapping the card would — same
// chrome, same dismissal. Pushing it instead gives a screen
// with a back chevron that belongs to a stack the user never
// navigated.
router.setPath([], on: .balance)
router.requestedTabStack = .balance
router.requestedCardMint = mint
} else {
router.navigate(to: .currencyInfo(mint))
}
router.setPath([], on: .balance)
router.requestedTabStack = .balance
router.requestedCardMint = mint
}

case .chat(let conversationID):
Expand Down Expand Up @@ -291,36 +286,36 @@ struct DeepLinkAction {
container.tipFlow.begin(username: username)
}

case .wallet:
if let container = sessionAuthenticator.loggedInContainer {
Analytics.deeplinkRouted(kind: kind)
// `flipcash://balance` means "show me the wallet" — bring the tab
// forward at its root rather than pushing anything onto it.
let router = container.appRouter
while router.presentedSheet != nil { router.dismissSheet() }
router.setPath([], on: .balance)
router.requestedTabStack = .balance
}

case .discoverCurrencies:
if let container = sessionAuthenticator.loggedInContainer {
Analytics.deeplinkRouted(kind: kind)
// Discover is a push from the wallet, the same as its tile.
container.appRouter.navigate(to: .discoverCurrencies)
}

case .openSheet(let sheet):
if let container = sessionAuthenticator.loggedInContainer {
Analytics.deeplinkRouted(kind: kind)
if sheet == .give {
let rate = container.ratesController.rateForBalanceCurrency()
let gate = giveCashGate(session: container.session, rate: rate, includingDollars: BetaFlags.shared.allowsDollarsGive)
let gate = giveCashGate(session: container.session, rate: rate)
if let dialog = gate.blockingDialog(router: container.appRouter, addMoneySource: .giveShortfall) {
container.session.dialogItem = dialog
return
}
}
let router = container.appRouter
// Discover is a push from the wallet in the tab UI, the same as
// its tile — its own sheet is the v1 route in.
if sheet == .discover, router.tabStacks.contains(.balance) {
router.navigate(to: .discoverCurrencies)
return
}

// A sheet whose stack a tab owns is that tab — `flipcash://balance`
// means "show me the wallet", and presenting the sheet puts the v1
// balance list over the wallet tab instead of selecting it.
let stack = sheet.stack
if router.tabStacks.contains(stack) {
while router.presentedSheet != nil { router.dismissSheet() }
router.setPath([], on: stack)
router.requestedTabStack = stack
} else {
router.present(sheet)
}
container.appRouter.present(sheet)
}
}
}
Expand All @@ -338,6 +333,10 @@ extension DeepLinkAction {
case chatSendCash(ConversationID)
case tip(UserID)
case username(Username)
/// The Wallet tab, at its root.
case wallet
/// Discover, pushed onto the Wallet tab.
case discoverCurrencies
case openSheet(AppRouter.SheetPresentation)
}
}
Expand All @@ -353,6 +352,8 @@ extension DeepLinkAction.Kind {
case .chatSendCash: "ChatSendCash"
case .tip: "Tip"
case .username: "Username"
case .wallet: "Wallet"
case .discoverCurrencies: "DiscoverCurrencies"
case .openSheet(let sheet): "Sheet:\(sheet)"
}
}
Expand Down
6 changes: 2 additions & 4 deletions Flipcash/Core/Controllers/RatesController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,16 +362,14 @@ class RatesController {
/// as an intentional Dollars choice would open every flow in Dollars. Where
/// Dollars isn't giveable at all it can't be auto-picked either, so a
/// Dollars-only account resolves to nothing.
///
/// - Parameter includingDollars: `BetaFlags.allowsDollarsGive`.
func resolveInitialBalance(mint: PublicKey?, session: Session, includingDollars: Bool) -> ExchangedBalance? {
func resolveInitialBalance(mint: PublicKey?, session: Session) -> ExchangedBalance? {
let rate = rateForBalanceCurrency()

if let mint, let stored = session.balance(for: mint) {
return stored.exchanged(with: rate)
}

let giveable = session.balances(for: rate).giveable(includingDollars: includingDollars)
let giveable = session.balances(for: rate).giveable()

if let stored = selectedTokenMint, stored != .usdf,
let match = giveable.first(where: { $0.stored.mint == stored }) {
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ extension AppRouter {
case activity
case give(PublicKey)
/// Pushes the buy flow (`BuyAmountScreen`) onto the current stack instead
/// of presenting it as a sheet — the new-UI currency-info "Get" tile.
/// of presenting it as a sheet — the currency-info "Get" tile.
case buyCurrency(PublicKey)
/// Pushes the convert flow (`ConvertAmountScreen`) onto the current stack
/// — the new-UI currency-info "Convert" tile. Sells this currency into a
/// — the currency-info "Convert" tile. Sells this currency into a
/// chosen destination (Dollars or another launchpad token).
case convertCurrency(PublicKey)
/// Withdraw flow on the Wallet's stack (pops back to the wallet on
Expand Down
12 changes: 3 additions & 9 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,9 @@ private struct AddMoneyFlowStepDestination: View {
var body: some View {
AddMoneyFlowDestination(step: step, onStep: { router.pushAny($0) })
.environment(\.dismissParentContainer, {
// v2 pushes the flow onto the host stack, so finishing pops back
// to that stack's root, returning to where it was launched. v1
// only reaches here over the buy sheet, where finishing dismisses
// the sheet as it always has.
if BetaFlags.shared.hasEnabled(.newUI) {
router.popToRoot()
} else {
router.dismissSheet()
}
// The flow is pushed onto the host stack, so finishing pops back
// to that stack's root, returning to where it was launched.
router.popToRoot()
})
}
}
2 changes: 1 addition & 1 deletion Flipcash/Core/Navigation/AppRouter+NestedSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ private struct NestedSheetRootView: View {
case .addMoney:
AddMoneySheetRoot()

case .balance, .settings, .give, .discover, .downloadApp, .tips:
case .settings, .give, .downloadApp, .tips:
// Root-only sheets; `presentNested` logs a warning if one
// lands here.
EmptyView()
Expand Down
Loading