diff --git a/.claude/plans/2026-08-20-newui-flag-teardown.md b/.claude/plans/2026-08-20-newui-flag-teardown.md new file mode 100644 index 000000000..ec91ad941 --- /dev/null +++ b/.claude/plans/2026-08-20-newui-flag-teardown.md @@ -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. diff --git a/Flipcash/Core/ContainerScreen.swift b/Flipcash/Core/ContainerScreen.swift index 49f968124..d3742c128 100644 --- a/Flipcash/Core/ContainerScreen.swift +++ b/Flipcash/Core/ContainerScreen.swift @@ -11,7 +11,6 @@ import FlipcashUI struct ContainerScreen: View { @Environment(SessionAuthenticator.self) var sessionAuthenticator - @Environment(BetaFlags.self) var betaFlags var body: some View { VStack { @@ -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) diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index e8c485e5c..10828e7d8 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -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. @@ -134,7 +125,6 @@ extension BetaFlags { case vibrateOnScan case enableCoinbase - case newUI var id: String { localizedTitle @@ -146,8 +136,6 @@ extension BetaFlags { return "Vibrate on scan" case .enableCoinbase: return "Enable Coinbase" - case .newUI: - return "New tab-bar UI" } } @@ -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" } } @@ -167,7 +153,6 @@ extension BetaFlags { switch self { case .vibrateOnScan: return .developer case .enableCoinbase: return .developer - case .newUI: return .shipped } } } diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 197c5caac..3041862fa 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -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 @@ -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): @@ -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) } } } @@ -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) } } @@ -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)" } } diff --git a/Flipcash/Core/Controllers/RatesController.swift b/Flipcash/Core/Controllers/RatesController.swift index 1e7c5a39e..fe1a85f41 100644 --- a/Flipcash/Core/Controllers/RatesController.swift +++ b/Flipcash/Core/Controllers/RatesController.swift @@ -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 }) { diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index cd7fd1b5f..cdafda3b5 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -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 diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 21dea6c7d..01eb6058c 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -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() }) } } diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 276808d64..ad8f81e5e 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -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() diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index fd1197208..9c3f26015 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -11,13 +11,11 @@ import FlipcashCore extension AppRouter { /// Identifies a top-level modal sheet. The router can present multiple at - /// once — the bottom of the stack is the root sheet (overlays `ScanScreen`) + /// once — the bottom of the stack is the root sheet (overlays the tab bar) /// 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) /// Standalone Add Money flow (deposit USDF). Payload selects the /// "No Balance Yet" subtitle; the flow itself is currency-agnostic. @@ -36,10 +34,8 @@ extension AppRouter { /// re-presentation starts at root rather than restoring the stale leaf. var stack: Stack { switch self { - case .balance: .balance case .settings: .settings case .give: .give - case .discover: .discover case .buy: .buy case .addMoney: .addMoney case .downloadApp: .downloadApp @@ -53,10 +49,8 @@ extension AppRouter { /// 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 case .addMoney: .addMoney case .downloadApp: .downloadApp @@ -66,10 +60,8 @@ extension AppRouter { } enum CaseKind: Hashable, Sendable { - case balance case settings case give - case discover case buy case addMoney case downloadApp @@ -79,10 +71,8 @@ extension AppRouter { var description: String { switch self { - case .balance: "balance" case .settings: "settings" case .give: "give" - case .discover: "discover" case .buy: "buy" case .addMoney: "addMoney" case .downloadApp: "downloadApp" diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift index 233310598..576d991c3 100644 --- a/Flipcash/Core/Navigation/AppRouter+Stack.swift +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -16,7 +16,6 @@ extension AppRouter { case balance case settings case give - case discover case buy case addMoney case downloadApp @@ -30,19 +29,41 @@ extension AppRouter { /// `.buy`, `.addMoney`, and `.sendAmount` return `nil` — their sheets /// carry a payload (mint / context / contact) that can't be synthesized /// from the stack alone, so they're entered via `presentNested`/`present` - /// directly, never via `navigate(to:)`. + /// directly, never via `navigate(to:)`. `.balance` and `.you` are tab + /// stacks, reached by bringing their tab forward. var sheet: SheetPresentation? { switch self { - case .balance: .balance + case .balance: nil case .settings: .settings case .give: .give - case .discover: .discover case .buy: nil case .addMoney: nil case .downloadApp: .downloadApp case .sendAmount: nil case .tips: .tips - case .you: nil // a tab stack, entered by tab selection, never via navigate(to:) + case .you: nil + } + } + + /// Whether a tab hosts this stack rather than a sheet. `navigate(to:)` + /// reaches these by bringing the tab forward — presenting a sheet + /// instead would lay a second copy of the surface over the tab that + /// already holds it. Mirrored by `HomeTab.pushStack`. + /// + /// `.tips` is both: the Chat tab hosts it, and `present(.tips)` still + /// puts the same stack in a sheet from surfaces that have no tab bar. + /// The tab wins for `navigate(to:)`. + var isTabHosted: Bool { + switch self { + case .balance: true + case .settings: false + case .give: false + case .buy: false + case .addMoney: false + case .downloadApp: false + case .sendAmount: false + case .tips: true + case .you: true } } @@ -51,7 +72,6 @@ extension AppRouter { case .balance: "balance" case .settings: "settings" case .give: "give" - case .discover: "discover" case .buy: "buy" case .addMoney: "addMoney" case .downloadApp: "downloadApp" diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 6dfc02f61..baa93e2bf 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -58,19 +58,13 @@ final class AppRouter { private var paths: [Stack: NavigationPath] = [:] - /// In the v2 tab UI, the stack owned by the currently-selected tab. A tab is - /// the active surface without being a *sheet*, so `presentedSheet` is nil - /// while a tab is showing; the topmost-stack mutators (`push`, `popTopmost`, - /// …) fall back to this so in-tab navigation lands on the right stack. - /// `HomeTabView` keeps it in sync with the selected tab; nil in the v1 - /// sheet-first UI, where a sheet is always the push target. + /// The stack owned by the currently-selected tab. A tab is the active + /// surface without being a *sheet*, so `presentedSheet` is nil while a tab + /// is showing; the topmost-stack mutators (`push`, `popTopmost`, …) fall + /// back to this so in-tab navigation lands on the right stack. + /// `HomeTabView` keeps it in sync with the selected tab. var activeTabStack: Stack? - /// The stacks a tab owns, registered by `HomeTabView`. A deep link into one - /// of these belongs on its tab, not in a sheet — the router itself has no - /// notion of tabs, so it is told. - var tabStacks: Set = [] - /// A token the wallet should open in its expanded card state, rather than /// as a pushed screen. Deep links use this so following a link lands where /// tapping the card would, chrome and dismissal included. The wallet clears @@ -95,7 +89,7 @@ final class AppRouter { var requestedTabStack: Stack? /// The stack that `push`/`pop`-style calls target: the presented sheet's - /// stack, or — when no sheet is up (a v2 tab is the active surface) — the + /// stack, or — when no sheet is up and a tab is the active surface — the /// active tab's stack. private var topmostStack: Stack? { presentedSheet?.stack ?? activeTabStack } @@ -121,18 +115,19 @@ final class AppRouter { // MARK: - Stack mutators - /// 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. + /// Pushes onto whatever stack is topmost — the presented sheet's stack, or + /// the active tab's when no sheet is up. 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 + /// No-op with a warning if there is no topmost stack — pushes onto a hidden /// stack would silently corrupt that stack's path until the user later - /// presents that sheet. + /// surfaces it. /// /// Cross-stack navigation is `navigate(to:)`'s job, not `push`'s. func push(_ destination: Destination) { guard let stack = topmostStack else { - logger.warning("Push attempted with no sheet presented", metadata: [ + logger.warning("Push attempted with no topmost stack", metadata: [ "destination": "\(destination)", ]) return @@ -145,10 +140,10 @@ final class AppRouter { /// 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. + /// if there is no topmost stack. func pushAny(_ value: H) { guard let stack = topmostStack else { - logger.warning("Push (sub-flow) attempted with no sheet presented", metadata: [ + logger.warning("Push (sub-flow) attempted with no topmost stack", metadata: [ "type": "\(type(of: value))", ]) return @@ -182,14 +177,14 @@ final class AppRouter { /// presented sheet hosts. Symmetric with `push(_:)`. Used by callers /// that need to dismiss a state-driven screen on operation completion /// without hand-stamping which stack it lives on (the Phantom flow - /// screen, for instance, can ride on `.buy`, `.balance`, or `.discover`). + /// screen, for instance, can ride on `.buy` or the Wallet tab's `.balance`). func popTopmost() { guard let stack = topmostStack else { return } pop(on: stack) } - /// Atomic replace of the topmost destination on whichever stack the - /// currently-presented sheet hosts: pops the current top, then pushes + /// Atomic replace of the topmost destination on whichever stack is + /// topmost: pops the current top, then pushes /// `value` in a single mutation. Mirrors the `push` / `pushAny` split — /// this is the sub-flow variant that accepts any `Hashable` so sub-flow /// path types (e.g. `BuyFlowPath`) can be swapped in. Used for "swap @@ -197,10 +192,10 @@ final class AppRouter { /// SwapProcessing on success) where leaving the old screen on the stack /// underneath would let a back-swipe reveal a dead UI. /// - /// No-op with a warning if no sheet is presented. + /// No-op with a warning if there is no topmost stack. func replaceTopmostAny(_ value: H) { guard let stack = topmostStack else { - logger.warning("replaceTopmostAny attempted with no sheet presented", metadata: [ + logger.warning("replaceTopmostAny attempted with no topmost stack", metadata: [ "type": "\(type(of: value))", ]) return @@ -277,7 +272,7 @@ final class AppRouter { /// 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]`. + /// `present(.settings)` while `[.settings, .buy(mint)]` is up → `[.settings]`. /// - Different root (with or without nested above) → dismiss everything, /// present new root. /// @@ -408,10 +403,10 @@ final class AppRouter { /// The stack the deposit flow pushes onto once a method is chosen from the /// Add Money picker: the sheet directly beneath the picker, or — when the - /// picker is the root sheet over a v2 tab — that tab's stack. `nil` when - /// nothing sits beneath the picker (e.g. opened over the bare scanner via - /// the no-balance gate); the picker then falls back to its own flow sheet - /// since there is no navigation stack to host the push. + /// picker is the root sheet over a tab — that tab's stack. `nil` when + /// nothing sits beneath the picker (e.g. opened over the Scan tab via the + /// no-balance gate); the picker then falls back to its own flow sheet since + /// there is no navigation stack to host the push. var addMoneyPushStack: Stack? { if presentedSheets.count >= 2 { return presentedSheets[presentedSheets.count - 2].stack @@ -471,16 +466,6 @@ final class AppRouter { /// > view's view model. func navigate(to destination: Destination) { let targetStack = destination.owningStack - // `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) @@ -488,8 +473,9 @@ final class AppRouter { // A stack a tab owns is reached by bringing that tab forward. Presenting // its sheet instead lays a second copy of the surface over the tab that // already holds it — a token link, for instance, ends up in a sheet with - // no tab bar rather than pushed on the wallet. - if tabStacks.contains(targetStack) { + // no tab bar rather than pushed on the wallet. Checked before the sheet + // lookup below, since a tab stack need not have a sheet of its own. + if targetStack.isTabHosted { let alreadyThere = presentedSheets.isEmpty && activeTabStack == targetStack && paths[targetStack, default: NavigationPath()] == expected @@ -505,6 +491,16 @@ final class AppRouter { return } + // Every non-tab owning stack has a sheet — `.buy` and friends are + // nested-only and never an owning stack — so a nil here is a misroute. + guard let targetSheet = targetStack.sheet else { + logger.warning("navigate(to:) hit a stack with no sheet — destination is misrouted", metadata: [ + "stack": "\(targetStack)", + "destination": "\(destination)", + ]) + return + } + let alreadyThere = presentedSheets == [targetSheet] && paths[targetStack, default: NavigationPath()] == expected guard !alreadyThere else { return } @@ -514,21 +510,15 @@ final class AppRouter { setPath([destination], on: targetStack) } - /// Surfaces the user's own tip card: the You tab at its root in the tab UI, - /// or the "My Tip Card" screen in the tips sheet without tabs. + /// Surfaces the user's own tip card: the You tab at its root. /// - /// Not expressible as `navigate(to:)` in the tab UI — the You tab's stack is - /// entered by tab selection and has no destination that names the card. + /// Not expressible as `navigate(to:)` — the You tab's stack is entered by + /// tab selection and has no destination that names the card. /// /// Idempotent, like `navigate(to:)`: the scanner decodes a tipcode every /// frame until its camera tears down, so arriving is not allowed to re-fire /// the tab request. func showOwnTipCard() { - guard tabStacks.contains(.you) else { - navigate(to: .tipcard) - return - } - // `requestedTabStack` covers the gap before `HomeTabView` selects the tab // and publishes `activeTabStack` — until then the request is in flight, // not yet arrived. diff --git a/Flipcash/Core/Navigation/RootSheetHost.swift b/Flipcash/Core/Navigation/RootSheetHost.swift new file mode 100644 index 000000000..8bccfa0ca --- /dev/null +++ b/Flipcash/Core/Navigation/RootSheetHost.swift @@ -0,0 +1,128 @@ +// +// RootSheetHost.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + + +/// Renders the modal sheet currently selected by `AppRouter.presentedSheet`. +/// Each case is a top-level modal; switching between them is a sheet swap. +private struct RoutedSheet: View { + + let sheet: AppRouter.SheetPresentation + + @Environment(AppRouter.self) private var router + + var body: some View { + @Bindable var router = router + switch sheet { + case .settings: + SettingsScreen() + case .give: + NavigationStack(path: $router[.give]) { + GiveScreen(mint: nil) + .appRouterDestinations() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(action: router.dismissSheet) + } + } + } + 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() + case .addMoney: + // Add Money entered as a root sheet — the give-cash no-balance case + // (Scan / deeplink). Buy & launch shortfalls present it *nested* over + // their gating sheet via `presentNested(.addMoney(context))`. + AddMoneySheetRoot() + case .downloadApp: + NavigationStack(path: $router[.downloadApp]) { + DownloadAppScreen() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(action: router.dismissSheet) + } + } + } + case .tips: + TipsSheetRoot() + case .sendAmount(let target): + // Send Cash entered directly as a root sheet — e.g. the notification + // Send Cash deeplink / App Intent opens the amount entry with no chat + // behind it. (In-chat Send Cash still enters it via presentNested.) + SendAmountSheetRoot(target: target) + } + } +} + +// MARK: - RootSheetHostModifier - + +/// Hosts the app-level `router.rootSheet` presentation (the `RoutedSheet` swap), +/// the tip-resume-after-profile-creation hooks, and the dismiss-sheets-on-bill +/// hook. Owned by the tab container (`HomeTabView`) — exactly one live view must +/// own it so `router.present(_:)` works from anywhere. +/// +/// The bill / tipcard surface itself (BillCanvas, actions, designer, and the +/// received-cash / send-tip sheets) lives in `BillOverlayView` at the app root — +/// see `ContainerScreen` — so a pushed bill renders over any tab. +struct RootSheetHostModifier: ViewModifier { + + @Environment(AppRouter.self) private var router + @Environment(SessionContainer.self) private var sessionContainer + + private var session: Session { sessionContainer.session } + + func body(content: Content) -> some View { + content + // Resume a tip held for profile creation the moment the profile + // becomes tippable. The sheet-close hook is the belt for a missed + // flip edge (e.g. the profile record arriving mid-transition): + // closing Tips with a held tip resumes it when a profile exists + // and drops it when creation was abandoned. + .onChange(of: session.profile?.isTippable ?? false) { _, isTippable in + guard isTippable else { return } + sessionContainer.tipFlow.resumeAfterProfileCreation() + } + .onChange(of: router.rootSheet) { old, new in + guard old == .tips, new == nil else { return } + if session.profile?.isTippable == true { + sessionContainer.tipFlow.resumeAfterProfileCreation() + } else { + sessionContainer.tipFlow.abandonPendingTip() + } + } + // 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.rootSheet }, + set: { newValue in + if newValue == nil { + router.dismissSheet() + } + } + )) { sheet in + RoutedSheet(sheet: sheet) + .appRouterNestedSheet() + } + // Dismiss all presented sheets when a bill is about to appear. + // Bills render behind sheets, so any sheet on top (Settings, Give, + // Tips) would obscure them. This ensures cash links received via + // push notifications or deep links are always visible regardless of + // the current navigation state. + .onChange(of: session.presentationState.isPresenting) { _, isPresenting in + guard isPresenting else { return } + router.dismissSheet() + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index a3baef5b3..01c017f80 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -318,7 +318,7 @@ struct ConversationScreen: View { case .contact: .giveCash } let rate = ratesController.rateForBalanceCurrency() - if let dialog = giveCashGate(session: session, rate: rate, includingDollars: BetaFlags.shared.allowsDollarsGive).blockingDialog(router: router, addMoneySource: .chat, context: context) { + if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .chat, context: context) { session.dialogItem = dialog return } diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift index 3f1cb50da..c88d2e5a2 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift @@ -6,15 +6,6 @@ import Foundation import FlipcashCore -/// Read-only access to the user's USDF reserve balance. -@MainActor -protocol USDFReserveReading: AnyObject { - func balance(for mint: PublicKey) -> StoredBalance? -} - -extension Session: USDFReserveReading {} - - /// Read access to every balance the launch gate weighs. @MainActor protocol LaunchBalanceReading: AnyObject { @@ -43,11 +34,10 @@ func shouldAddMoneyBeforeLaunch(session: some LaunchBalanceReading, launchCost: !session.balances.contains { canPayLaunchCost($0, launchCost: launchCost) } } -/// The balance inputs the give/send cash gate needs — `USDFReserveReading` -/// plus the giveable-balance predicate. +/// The giveable-balance predicate the give/send cash gate reads. @MainActor -protocol GiveBalanceReading: USDFReserveReading { - func hasGiveableBalance(for rate: Rate, includingDollars: Bool) -> Bool +protocol GiveBalanceReading: AnyObject { + func hasGiveableBalance(for rate: Rate) -> Bool } extension Session: GiveBalanceReading {} @@ -55,28 +45,16 @@ extension Session: GiveBalanceReading {} /// Where a "give cash" entry (Cash tab, in-chat Send, tip sheet, give deeplink) /// routes. enum GiveCashGate: Equatable { - /// Something spendable on hand — a community currency, or Dollars when the - /// new UI has them giveable. + /// Something spendable on hand — Dollars or a community currency. case proceed - /// Dollars on hand but nothing this UI can give (old UI only). - case discoverCurrencies /// No balance at all. case addMoney } /// Returns where a give-cash entry routes given the user's balances. Every mint /// counts only at displayable value, so the prompt agrees with the balance the -/// wallet renders. `includingDollars` is `BetaFlags.allowsDollarsGive`. +/// wallet renders. @MainActor -func giveCashGate(session: some GiveBalanceReading, rate: Rate, includingDollars: Bool) -> GiveCashGate { - if session.hasGiveableBalance(for: rate, includingDollars: includingDollars) { return .proceed } - - // Old UI only: Dollars is on hand but isn't giveable there, so point at - // Discover — the user has money, just nothing this UI can give. Once - // Dollars is giveable the branch is unreachable, since a displayable - // Dollars balance has already proceeded above. - let hasUSDF = session.balance(for: .usdf)? - .computeExchangedValue(with: rate) - .hasDisplayableValue() ?? false - return hasUSDF ? .discoverCurrencies : .addMoney +func giveCashGate(session: some GiveBalanceReading, rate: Rate) -> GiveCashGate { + session.hasGiveableBalance(for: rate) ? .proceed : .addMoney } diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift index 3f409a220..ce2951342 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift @@ -23,13 +23,10 @@ struct AddMoneyStartScreen: View { @State private var pendingCoinbaseAmount = false var body: some View { - // The v2 tab-bar UI uses the richer "Add Money With" cards (Figma / - // Android parity); v1 keeps the plain "Select Method" buttons. - let isV2 = BetaFlags.shared.hasEnabled(.newUI) PartialSheet { VStack(spacing: 12) { HStack { - Text(isV2 ? "Add Money With" : "Select Method") + Text("Add Money With") .font(.appBarButton) .foregroundStyle(Color.textMain) Spacer() @@ -37,11 +34,7 @@ struct AddMoneyStartScreen: View { .padding(.vertical, 20) ForEach(Self.visibleMethods(hasCoinbaseOnramp: session.hasCoinbaseOnramp), id: \.self) { method in - if isV2 { - AddMoneyMethodRow(method: method) { select(method) } - } else { - AddMoneyMethodButton(method: method) { select(method) } - } + AddMoneyMethodRow(method: method) { select(method) } } Button("Dismiss", action: { router.dismissSheet() }) @@ -49,13 +42,12 @@ struct AddMoneyStartScreen: View { } .padding(.horizontal) .padding(.top) - .padding(.bottom, isV2 ? 0 : 16) } .sheet(item: $flowMethod) { method in AddMoneyFlowSheet(method: method) .environment(\.dismissParentContainer, { router.dismissSheet() }) } - // Up-front (v2) Coinbase verification — presented directly over the + // Up-front Coinbase verification — presented directly over the // picker, before the amount flow opens. .sheet(item: $verificationViewModel.cancellingOnDismiss()) { vm in VerifyInfoScreen(viewModel: vm) @@ -75,26 +67,14 @@ struct AddMoneyStartScreen: View { /// Chooses a deposit method. /// - /// In the v2 UI the flow pushes onto the navigation stack the picker was - /// launched from — matching the rest of the app — via `startFlow`, and a - /// debit-card (Coinbase) deposit verifies phone/email first so no empty - /// screen appears ahead of it (skipped over the buy sheet, already gated). - /// - /// The v1 UI keeps the sheet-based flow unchanged: the deposit opens as its - /// own sheet, except over the buy sheet where it pushes as it always has. + /// The flow pushes onto the navigation stack the picker was launched from — + /// matching the rest of the app — via `startFlow`, and a debit-card + /// (Coinbase) deposit verifies phone/email first so no empty screen appears + /// ahead of it (skipped over the buy sheet, already gated). private func select(_ method: DepositMethod) { Analytics.addMoneyMethodSelected(method: method) let router = self.router - guard BetaFlags.shared.hasEnabled(.newUI) else { - if router.isAddMoneyOverBuy, let stack = router.addMoneyPushStack { - dismissThenPush(AddMoneyFlowStep.method(method), onto: stack, using: router) - } else { - flowMethod = method - } - return - } - guard method == .coinbase, !router.isAddMoneyOverBuy else { startFlow(method, using: router) return @@ -102,8 +82,8 @@ struct AddMoneyStartScreen: View { // A debit-card deposit verifies phone/email up front. With a host stack // the whole verification (intro → phone → email) pushes onto it ahead of - // the deposit flow; without one (over the bare scanner) it falls back to - // the sheet-based verification and sheet deposit flow. + // the deposit flow; without one (over the Scan tab) it falls back to the + // sheet-based verification and sheet deposit flow. if let stack = router.addMoneyPushStack { startCoinbaseVerification(pushingOnto: stack, using: router) } else { @@ -123,12 +103,11 @@ struct AddMoneyStartScreen: View { } } - /// Enters the deposit flow for `method` in the v2 UI. When the picker sits - /// over an existing navigation stack (the common case) it dismisses the - /// picker and pushes the flow onto that stack so it reads like the rest of - /// the app's navigation. With no stack beneath it — the no-balance gate - /// opened over the bare scanner — it falls back to presenting the flow as - /// its own sheet. + /// Enters the deposit flow for `method`. When the picker sits over an + /// existing navigation stack (the common case) it dismisses the picker and + /// pushes the flow onto that stack so it reads like the rest of the app's + /// navigation. With no stack beneath it — the no-balance gate opened over + /// the Scan tab — it falls back to presenting the flow as its own sheet. private func startFlow(_ method: DepositMethod, using router: AppRouter) { if let stack = router.addMoneyPushStack { dismissThenPush(AddMoneyFlowStep.method(method), onto: stack, using: router) @@ -204,45 +183,7 @@ struct AddMoneyStartScreen: View { } } -private struct AddMoneyMethodButton: View { - - let method: DepositMethod - let action: () -> Void - - var body: some View { - Button(action: action) { - switch method { - case .coinbase: - Text("\u{F8FF}Pay") - .font(.body.bold()) - case .phantom: - HStack(spacing: 4) { - Image.asset(.phantom) - .renderingMode(.template) - .resizable() - .frame(width: 20, height: 20) - Text("Phantom") - } - case .otherWallet: - Text("Other Wallet") - } - } - .buttonStyle(.filled) - .accessibilityIdentifier(accessibilityIdentifier) - } - - /// Stable identifier for UI tests — the Apple-glyph "Pay" label is - /// brittle to match by text. - private var accessibilityIdentifier: String { - switch method { - case .coinbase: "apple-pay-method-button" - case .phantom: "phantom-method-button" - case .otherWallet: "other-wallet-method-button" - } - } -} - -/// The v2 "Add Money With" row: a titled/subtitled card with a trailing method +/// An "Add Money With" row: a titled/subtitled card with a trailing method /// glyph (Figma / Android parity). private struct AddMoneyMethodRow: View { diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift deleted file mode 100644 index 6c2ff6c27..000000000 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ /dev/null @@ -1,305 +0,0 @@ -// -// BalanceScreen.swift -// Code -// -// Created by Dima Bart on 2025-04-23. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -/// Thin environment-reading wrapper that hands the session container to -/// ``BalanceScreenContent``, whose `init` seeds the balance `@State` arrays -/// synchronously — that synchronous seed is what prevents the empty-state flash -/// on first render. -struct BalanceScreen: View { - - @Environment(SessionContainer.self) private var sessionContainer - - /// When shown as the v2 Wallet tab it is full-screen chrome, not a sheet, so - /// the dismiss button is suppressed. Defaults to `false` — the v1 sheet - /// presentation keeps its close button. - var isEmbedded: Bool = false - - var body: some View { - BalanceScreenContent(sessionContainer: sessionContainer, isEmbedded: isEmbedded) - } -} - -private struct BalanceScreenContent: View { - - @Environment(AppRouter.self) private var router - @Environment(RatesController.self) private var ratesController - @Environment(HistoryController.self) private var historyController - @Environment(NotificationController.self) private var notificationController - - - let session: Session - - /// Owned, mutable source for the LazyVStack. Reorder animations only fire - /// when the data source is mutated inside an active animation transaction — - /// a body-time computed property doesn't satisfy that. - @State private var sortedBalances: [ExchangedBalance] = [] - - /// Filtered subset of `sortedBalances` used by the wallet list and the - /// `hasBalances` empty-state gate. USDF is always present in - /// `session.balances(for:)` after sync (pinned by the BalanceScreen - /// normalization invariant); it's dropped here when it has no displayable - /// fiat value so a brand-new user still gets the empty state, and renders - /// as a normal row once it has value. Non-USDF balances are filtered - /// upstream by `session.balances(for:)`. Lives in `@State` rather than as - /// a body-time `.filter` so its mutation joins the same `withAnimation` - /// transaction as `sortedBalances`. - @State private var visibleBalances: [ExchangedBalance] = [] - - /// Synchronizes per-row geometry across reorders so rows slide to their new - /// positions instead of popping when the sort order shuffles. - @Namespace private var balanceRowNamespace - - private var hasBalances: Bool { - !visibleBalances.isEmpty - } - - private var balance: ExchangedFiat { - sortedBalances.map(\.exchangedFiat).total(rate: balanceRate) - } - - private var balanceRate: Rate { - ratesController.rateForBalanceCurrency() - } - - /// Takes balances by parameter so callers can pass a cached snapshot and - /// avoid re-iterating `sortedBalances` on every body evaluation. - private func computeAppreciation(for balances: [ExchangedBalance]) -> (amount: FiatAmount, isPositive: Bool) { - var totalAppreciation: Decimal = 0 - - for balance in balances { - let (value, isPositive) = balance.stored.computeAppreciation(with: balanceRate) - let amount = value.nativeAmount.value - totalAppreciation += isPositive ? amount : -amount - } - - let isPositive = totalAppreciation >= 0 - let amount = FiatAmount(value: abs(totalAppreciation), currency: balanceRate.currency) - return (amount, isPositive) - } - - /// See ``BalanceScreen/isEmbedded``. - private let isEmbedded: Bool - - // MARK: - Init - - - init(sessionContainer: SessionContainer, isEmbedded: Bool = false) { - self.session = sessionContainer.session - self.isEmbedded = isEmbedded - - // Seed the @State arrays synchronously so the first body evaluation - // renders the correct branch — otherwise `visibleBalances == []` flips - // `hasBalances` false on initial render and the empty state flashes - // before `.onChange(initial: true)` populates the real values inside - // `withAnimation`. - let initialSorted = sessionContainer.session.balances( - for: sessionContainer.ratesController.rateForBalanceCurrency() - ) - _sortedBalances = State(initialValue: initialSorted) - _visibleBalances = State(initialValue: initialSorted.filter(Self.isVisible)) - } - - /// Predicate shared between the init seed and `refreshSortedBalances` so - /// both paths apply the same USDF displayable-threshold rule. - private static func isVisible(_ balance: ExchangedBalance) -> Bool { - balance.stored.mint != .usdf || balance.exchangedFiat.hasDisplayableValue() - } - - // MARK: - Lifecycle - - - private func onAppear() { - historyController.sync() - } - - // MARK: - Body - - - var body: some View { - @Bindable var router = router - NavigationStack(path: $router[.balance]) { - Background(color: .backgroundMain) { - VStack(spacing: 0) { - list() - } - } - .onAppear(perform: onAppear) - .onChange(of: session.balances, initial: true) { _, _ in refreshSortedBalances() } - .onChange(of: balanceRate) { _, _ in refreshSortedBalances() } - .navigationTitle("Wallet") - .toolbarTitleDisplayMode(.inline) - .appRouterDestinations() - .toolbar { - if !isEmbedded { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: router.dismissSheet) - } - } - } - .onChange(of: notificationController.pushWillPresent) { _, _ in - session.updateBalance() - historyController.sync() - } - } - } - - @ViewBuilder private func emptyState() -> some View { - VStack(spacing: 10) { - Text("No Balance Yet") - .font(.appTextLarge) - - Text("Buy your first currency to get started") - .font(.appTextMedium) - .foregroundStyle(Color.textSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - - BubbleButton(text: "Add Money") { - router.presentAddMoney(.general, source: .balance) - } - .padding(.top, 8) - } - .padding(.horizontal, 20) - .containerRelativeFrame(.vertical) { length, _ in length * 0.5 } - } - - @ViewBuilder private func list() -> some View { - 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 - // content scrolling under the home-indicator region. The button itself - // is pushed back into the safe area via `safeAreaInsets.bottom`. - GeometryReader { proxy in - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionFooters]) { - Section { - VStack { - BalanceHeaderButton(balance: balance) - .frame(height: 60) - - ValueAppreciation(amount: appreciation.amount, isPositive: appreciation.isPositive) - .padding(.top, 4) - } - .padding(.vertical, 30) - - if hasBalances { - ForEach(visibleBalances) { 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) - } - } else { - emptyState() - } - } footer: { - if hasBalances { - CodeButton(style: .filledSecondary, title: "Add Money") { - router.presentAddMoney(.general, source: .balance) - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 20 + proxy.safeAreaInsets.bottom) - .frame(maxWidth: .infinity) - .background { - LinearGradient( - gradient: Gradient(colors: [Color.backgroundMain, Color.backgroundMain, .clear]), - startPoint: .bottom, - endPoint: .top - ) - } - } - } - } - } - .ignoresSafeArea(.container, edges: .bottom) - } - } - - /// Wrapped in `withAnimation` so the LazyVStack diff joins an animation - /// transaction — `matchedGeometryEffect` then interpolates each row to its - /// new slot when the sort order shuffles. `visibleBalances` is refreshed - /// in the same transaction so USDF entering or leaving the displayable - /// threshold animates together with the row reorder. - private func refreshSortedBalances() { - let nextSorted = session.balances(for: balanceRate) - guard nextSorted != sortedBalances else { return } - let nextVisible = nextSorted.filter(Self.isVisible) - withAnimation(.smooth) { - sortedBalances = nextSorted - visibleBalances = nextVisible - } - } - -} - -struct ExchangedBalance: Identifiable, Hashable { - let stored: StoredBalance - let exchangedFiat: ExchangedFiat - - var id: PublicKey { - stored.id - } -} - -extension Array where Element == ExchangedBalance { - - /// Balances eligible to give, send, or tip. Dollars appears only when - /// `includingDollars` — see `BetaFlags.allowsDollarsGive` — and only when it - /// carries a displayable value: `balances(for:)` keeps USDF at any value so - /// the wallet can render a zero Dollars card, and an amount-entry picker has - /// no use for a balance that can't fund anything. - func giveable(includingDollars: Bool) -> [ExchangedBalance] { - filter { balance in - guard balance.stored.mint == .usdf else { return true } - return includingDollars && balance.exchangedFiat.hasDisplayableValue() - } - } -} - -extension StoredBalance { - func exchanged(with rate: Rate) -> ExchangedBalance { - ExchangedBalance(stored: self, exchangedFiat: computeExchangedValue(with: rate)) - } -} - -struct BalanceHeaderButton: View { - let balance: ExchangedFiat - - @Environment(RatesController.self) private var ratesController - @State private var isShowingCurrencySelection = false - - var body: some View { - VStack(spacing: 10) { - Button { - isShowingCurrencySelection.toggle() - } label: { - AmountText( - flagStyle: balance.nativeAmount.currency.flagStyle, - content: balance.nativeAmount.formatted(), - showChevron: true - ) - .font(.appDisplayLarge) - .foregroundStyle(Color.textMain) - .contentTransition(.numericText()) - } - .accessibilityIdentifier("balance-header") - .frame(maxWidth: .infinity) - .animation(.default, value: balance) - .sheet(isPresented: $isShowingCurrencySelection) { - CurrencySelectionScreen(ratesController: ratesController) - } - } - } -} diff --git a/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift b/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift index 86b985193..245a2d36b 100644 --- a/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift +++ b/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift @@ -115,16 +115,13 @@ private struct BillOverlayContent: View { session.isShowingBill && !session.isScannerForeground } - /// How the scrim enters. For an outgoing give in the new UI, snap it in - /// (`.identity` insertion) so it masks the amount-entry popping back to the - /// currency info behind the sliding bill — instead of letting it flash - /// through mid-slide. A received bill / cash link (presented with `.pop`) - /// and legacy UI keep the v1 ramp that arrives with the bill. Both fade out - /// on dismiss. + /// How the scrim enters. For an outgoing give, snap it in (`.identity` + /// insertion) so it masks the amount-entry popping back to the currency info + /// behind the sliding bill — instead of letting it flash through mid-slide. + /// A received bill / cash link (presented with `.pop`) keeps the ramp that + /// arrives with the bill. Both fade out on dismiss. private var scrimTransition: AnyTransition { - let isOutgoingGive = presentationStyle == .slide - let snapsIn = BetaFlags.shared.hasEnabled(.newUI) && isOutgoingGive - return snapsIn + presentationStyle == .slide ? .asymmetric(insertion: .identity, removal: .opacity) : .opacity } @@ -164,7 +161,7 @@ private struct BillOverlayContent: View { .ignoresSafeArea() .animation(.easeInOut(duration: 0.15), value: session.billState.bill == nil) // Governs the scrim's fade-out on dismiss, and its ramp-in everywhere - // except a new-UI outgoing give, which snaps in via `.identity` (see + // except an outgoing give, which snaps in via `.identity` (see // `scrimTransition`) so this doesn't animate its entrance there. .animation(.spring(response: 0.6, dampingFraction: 0.6), value: showsScrim) .animation(.spring(response: 0.4, dampingFraction: 0.85), value: session.isShowingBillDesigner) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index 6c696d927..e0cc12ac2 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -47,7 +47,7 @@ private struct BuyAmountScreenContent: View { @Environment(AppRouter.self) private var router /// True when this is the root of a presented sheet; false when pushed onto a - /// host stack (new-UI Convert/Get) where the system back arrow replaces Close. + /// host stack (Convert/Get) where the system back arrow replaces Close. @Environment(\.presentedAsSheetRoot) private var presentedAsSheetRoot init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { @@ -109,7 +109,7 @@ private struct BuyAmountScreenContent: View { // fresh view identity per path value so init-seeded @State can't // survive a same-depth value swap (the DestinationView convention). BuyFlowDestinationView(path: path) - // The legacy buy sheet dismisses itself; the new-UI "Get" flow is + // The presented buy sheet dismisses itself; the pushed "Get" flow is // pushed from a token's expanded card, so finishing pops back to // the wallet and dismisses that card overlay. .environment(\.dismissParentContainer, presentedAsSheetRoot diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 99baa844e..2ccc9b4b5 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -60,25 +60,19 @@ final class BuyAmountViewModel { @ObservationIgnored private let session: Session @ObservationIgnored private let ratesController: RatesController @ObservationIgnored private let amountValidator = AmountValidator() - @ObservationIgnored private let collectsUSDFFee: Bool /// Double-tap guard around the async pin fetch. private var isSubmitting = false - /// `collectsUSDFFee` is injected rather than read from `BetaFlags.shared` at - /// each use so both fee branches stay testable without mutating the - /// persisted flag — same convention as `BuyConfirmationViewModel`. init( mint: PublicKey, currencyName: String, session: Session, - ratesController: RatesController, - collectsUSDFFee: Bool = BetaFlags.shared.hasEnabled(.newUI) + ratesController: RatesController ) { self.mint = mint self.currencyName = currencyName self.session = session self.ratesController = ratesController - self.collectsUSDFFee = collectsUSDFFee // Default the payment source to Dollars when it's spendable, otherwise // the largest eligible balance — so Next is never dead on arrival. @@ -153,14 +147,13 @@ final class BuyAmountViewModel { /// top* of the entry rather than being skimmed out of it. /// /// A token-funded buy pays the pool's sell fee out of the sale, so the debit - /// is the entry grossed up; a new-UI Dollars buy adds a flat 1% on top. The - /// old-UI reserves buy is fee-free. + /// is the entry grossed up; a Dollars buy adds a flat 1% on top. private var paymentFee: (bps: UInt64, chargedOnTop: Bool) { guard let selected = session.balance(for: paymentMint) else { return (0, false) } guard selected.mint == .usdf else { return (UInt64(max(0, selected.sellFeeBps ?? 100)), false) } - return collectsUSDFFee ? (100, true) : (0, false) + return (100, true) } /// Drops the entry to the most the payment balance can fund once its fee is diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift index d15551807..4af4e3e91 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift @@ -40,19 +40,17 @@ struct BuyConfirmationScreen: View { ) .padding(.top, 24) - if viewModel.chargesFee { - VStack(spacing: 10) { - ConfirmationBreakdownRow( - title: "Amount to convert", - value: viewModel.amountToBuy.nativeAmount.formatted() - ) - ConfirmationBreakdownRow( - title: "Conversion fee", - value: viewModel.feeFormatted - ) - } - .padding() + VStack(spacing: 10) { + ConfirmationBreakdownRow( + title: "Amount to convert", + value: viewModel.amountToBuy.nativeAmount.formatted() + ) + ConfirmationBreakdownRow( + title: "Conversion fee", + value: viewModel.feeFormatted + ) } + .padding() ConfirmationAmountRow( title: "You Get", @@ -60,7 +58,6 @@ struct BuyConfirmationScreen: View { imageURL: viewModel.targetImageURL, amount: viewModel.amountToBuy.nativeAmount.formatted() ) - .padding(.top, viewModel.chargesFee ? 0 : 24) .padding(.bottom, 24) } } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift index 4fa3d893c..5f99c1e2a 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift @@ -25,8 +25,6 @@ final class BuyConfirmationViewModel { /// Icon for the You Receive row, resolved from cached mint metadata. private(set) var targetImageURL: URL? - @ObservationIgnored private let collectsUSDFFee: Bool - var isUSDF: Bool { payment.mint == .usdf } var canPerformAction: Bool { !pinnedState.isStale } @@ -35,14 +33,6 @@ final class BuyConfirmationViewModel { /// the token-funded path uses the payment pool's own sell fee. var feeBps: UInt64 { isUSDF ? 100 : UInt64(max(0, payment.sellFeeBps ?? 100)) } - /// New-UI USDF reserves buys collect a 1% fee (split off on-chain via the - /// server's fee destination). Old UI leaves the reserves buy fee-free. - private var chargesUSDFFee: Bool { isUSDF && collectsUSDFFee } - - /// Whether a fee row is shown and collected. Token-funded buys always carry - /// the implicit sell fee; USDF buys only in the new UI. - var chargesFee: Bool { !isUSDF || chargesUSDFFee } - var fee: ExchangedFiat { // Same call both ways: for USDF `paymentAmount` is the net purchase so // this is 1% on top; for tokens it's the gross debit so this is the @@ -60,34 +50,28 @@ final class BuyConfirmationViewModel { /// amount *is* the net purchase (fee added on top); for tokens it's the /// gross debit minus the implicit sell fee. var amountToBuy: ExchangedFiat { - guard chargesFee else { return paymentAmount } - return isUSDF ? paymentAmount : paymentAmount.subtractingFee(fee.onChainAmount) + isUSDF ? paymentAmount : paymentAmount.subtractingFee(fee.onChainAmount) } /// The amount actually removed from the balance ("You Pay"). For USDF it's /// the net purchase plus the on-top fee; for tokens `paymentAmount` already /// is the gross debit. var grossDebit: ExchangedFiat { - guard chargesFee else { return paymentAmount } - return isUSDF ? paymentAmount.adding(fee) : paymentAmount + isUSDF ? paymentAmount.adding(fee) : paymentAmount } - /// Injected rather than read from `BetaFlags.shared` at each use so both fee - /// branches stay testable without mutating the persisted flag. init( targetMint: PublicKey, targetName: String, payment: StoredBalance, paymentAmount: ExchangedFiat, - pinnedState: VerifiedState, - collectsUSDFFee: Bool = BetaFlags.shared.hasEnabled(.newUI) + pinnedState: VerifiedState ) { self.targetMint = targetMint self.targetName = targetName self.payment = payment self.paymentAmount = paymentAmount self.pinnedState = pinnedState - self.collectsUSDFFee = collectsUSDFFee } // MARK: - Actions @@ -143,7 +127,7 @@ final class BuyConfirmationViewModel { // and split off on-chain, so the debit is `paymentAmount + fee`. swapId = try await session.buy( amount: paymentAmount, - feeAmount: chargesFee ? fee : nil, + feeAmount: fee, verifiedState: pinnedState, of: targetMint ) diff --git a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyCreationPromoCard.swift b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyCreationPromoCard.swift deleted file mode 100644 index 9cf3d5d04..000000000 --- a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyCreationPromoCard.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// CurrencyCreationPromoCard.swift -// Flipcash -// - -import SwiftUI -import FlipcashUI - -/// The inline "Create Your Own Currency" row shown above the leaderboard in the -/// legacy Discover UI. The new tab-bar UI surfaces currency creation as a Wallet -/// tile instead, so this only appears on the legacy path. -struct CurrencyCreationPromoCard: View { - let action: () -> Void - - private let cornerRadius: CGFloat = 16 - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text("Create Your Own Currency") - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - Image.system(.arrowRight) - .foregroundStyle(Color.textMain) - } - Text("Create a currency in minutes and\nimmediately use it as cash") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(alignment: .bottomTrailing) { - Image(.CurrencyDiscovery.bills) - .resizable() - .scaledToFit() - .frame(width: 120) - } - .background(Color.backgroundRow) - .compositingGroup() - .clipShape(.rect(cornerRadius: cornerRadius)) - } - .buttonStyle(.plain) - .accessibilityIdentifier("discover-create-currency-card") - .padding(.horizontal, 20) - .padding(.top, 16) - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift index 518c9c0d9..2aceb926c 100644 --- a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift +++ b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryList.swift @@ -14,11 +14,6 @@ struct CurrencyDiscoveryList: View { @State private var mints: [MintMetadata]? @State private var isFailed: Bool = false - /// v2 ranks the leaderboard by market cap; the legacy UI stays on holders. - private var rankingSystem: RankingSystem { - BetaFlags.shared.hasEnabled(.newUI) ? .marketCap : .holders - } - private enum LoadState { case loading case failed @@ -42,7 +37,7 @@ struct CurrencyDiscoveryList: View { Button { onSelectMint(item.element.address) } label: { - CurrencyDiscoveryRow(rank: item.index + 1, mint: item.element, rankingSystem: rankingSystem) + CurrencyDiscoveryRow(rank: item.index + 1, mint: item.element, rankingSystem: .marketCap) } .buttonStyle(.plain) // Stable handle for UI tests — the visible label is diff --git a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryRow.swift b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryRow.swift index e94964285..009b26993 100644 --- a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryRow.swift +++ b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryRow.swift @@ -11,9 +11,8 @@ struct CurrencyDiscoveryRow: View { let rank: Int let mint: MintMetadata /// Drives which metric is prominent (trailing value + delta) and which is the - /// secondary line under the name. Callers pass `.marketCap` under v2; the - /// default stays on holders for the legacy UI. See ``RankingSystem``. - var rankingSystem: RankingSystem = .holders + /// secondary line under the name. See ``RankingSystem``. + var rankingSystem: RankingSystem = .marketCap var body: some View { HStack(spacing: 12) { diff --git a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift index 0e7f3ae29..3f67693d5 100644 --- a/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Discovery/CurrencyDiscoveryScreen.swift @@ -11,18 +11,9 @@ struct CurrencyDiscoveryScreen: View { @Environment(AppRouter.self) private var router - /// The new tab-bar UI surfaces currency creation as a Wallet tile, so Discover - /// drops its promo entirely; the legacy UI keeps it as the first row above the - /// leaderboard. - private var hidesPromo: Bool { BetaFlags.shared.hasEnabled(.newUI) } - var body: some View { ScrollView { LazyVStack(spacing: 0) { - if !hidesPromo { - promoCard - } - LeaderboardSectionTitle() CurrencyDiscoveryList( @@ -36,10 +27,4 @@ struct CurrencyDiscoveryScreen: View { .navigationTitle("Discover Currencies") .toolbarTitleDisplayMode(.inline) } - - private var promoCard: some View { - CurrencyCreationPromoCard { - router.push(.currencyCreationSummary) - } - } } diff --git a/Flipcash/Core/Screens/Main/Currency Discovery/RankingSystem.swift b/Flipcash/Core/Screens/Main/Currency Discovery/RankingSystem.swift index 0ceb9c6fc..76f3b286a 100644 --- a/Flipcash/Core/Screens/Main/Currency Discovery/RankingSystem.swift +++ b/Flipcash/Core/Screens/Main/Currency Discovery/RankingSystem.swift @@ -8,10 +8,10 @@ import Foundation /// The metric the discovery leaderboard ranks by, which also drives what each /// row's prominent value and weekly delta represent. /// -/// v2 ranks by market cap: the Discover RPC now returns `MarketCapMetrics` -/// (`current_market_cap` + per-range deltas), so a row shows the current market -/// cap and its weekly change. Holder metrics remain available as the secondary -/// line and an alternate ranking. +/// The leaderboard ranks by market cap: the Discover RPC returns +/// `MarketCapMetrics` (`current_market_cap` + per-range deltas), so a row shows +/// the current market cap and its weekly change. Holder metrics remain +/// available as the secondary line and an alternate ranking. enum RankingSystem { /// Rank by holder count; the weekly delta is the change in holders. case holders diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift index fcd22fe6d..f0905ed11 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift @@ -8,8 +8,8 @@ import FlipcashCore import FlipcashUI /// The "About" block on the currency info screen: an expand/collapse description -/// and, for community tokens, the social link chips. Reused across the legacy and -/// new-UI layouts so the copy + expand/collapse behaviour stays in one place. +/// and, for community tokens, the social link chips. Split out so the copy + +/// expand/collapse behaviour stays in one place. struct CurrencyInfoAboutSection: View { let description: String let socialLinks: [SocialLink] diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index f0f795359..0326330ec 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -2,10 +2,9 @@ // CurrencyInfoContentV2.swift // Flipcash // -// The new-UI (BetaFlags.newUI) currency info layout: a hero bill card, inline -// Give / Convert / Withdraw (or Get) tiles, a per-token Recent preview, the -// reused market-cap chart, the About block, and a created-at footer. Gated -// behind the new UI; the legacy `LoadedContent` stays for the old shell. +// The currency info layout: a hero bill card, inline Give / Convert / +// Withdraw (or Get) tiles, a per-token Recent preview, the reused market-cap +// chart, the About block, and a created-at footer. // import SwiftUI @@ -304,9 +303,8 @@ struct CurrencyInfoContentV2: View { @ViewBuilder private var actionTiles: some View { HStack(spacing: 12) { if isOwned { - // This layout is new-UI only, so Give (including Dollars) is - // inherently gated to the new UI. Convert works from any held - // currency — including Dollars, which converts via a reserves buy. + // Convert works from any held currency — including Dollars, + // which converts via a reserves buy. actionTile("Give", icon: .asset("IconBanknote"), action: onGive) actionTile("Convert", icon: .system("arrow.up.arrow.down"), action: onConvert) actionTile("Withdraw", icon: .system("arrow.up"), action: onWithdraw) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoFooter.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoFooter.swift deleted file mode 100644 index 7ab0b4e11..000000000 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoFooter.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CurrencyInfoFooter.swift -// Code -// -// Created by Dima Bart on 2025-10-28. -// - -import SwiftUI -import FlipcashUI - -struct CurrencyInfoFooter: View where Content: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - VStack { - Spacer() - - HStack(spacing: 12) { - content - } - .padding(20) - .background { - LinearGradient( - gradient: Gradient(colors: [Color.backgroundMain, Color.backgroundMain, .clear]), - startPoint: .bottom, - endPoint: .top - ) - .ignoresSafeArea() - } - } - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoHeaderSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoHeaderSection.swift deleted file mode 100644 index 56f8497c9..000000000 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoHeaderSection.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// CurrencyInfoHeaderSection.swift -// Code -// -// Created by Raul Riera on 2026-03-24. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct CurrencyInfoHeaderSection: View { - let balance: FiatAmount - let appreciation: (amount: FiatAmount, isPositive: Bool) - let isUSDF: Bool - let onCurrencySelection: () -> Void - let onViewTransaction: () -> Void - - var body: some View { - VStack { - Button { - onCurrencySelection() - } label: { - AmountText( - flagStyle: balance.currency.flagStyle, - content: balance.formatted(), - showChevron: true - ) - .font(.appDisplayLarge) - .foregroundStyle(Color.textMain) - .contentTransition(.numericText()) - } - .frame(height: 60) - .frame(maxWidth: .infinity) - .animation(.default, value: balance) - - if !isUSDF && balance.isPositive { - ValueAppreciation(amount: appreciation.amount, isPositive: appreciation.isPositive) - .padding(.top, 8) - - Button("Transaction History") { - onViewTransaction() - } - .buttonStyle(.filled20) - .padding(.top, 40) - } - } - .padding(.top, 30) - .padding(.bottom, 25) - .vSeparator(color: .rowSeparator) - .padding(.horizontal, 20) - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index d283e832d..c849ab61d 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -75,9 +75,7 @@ private struct CurrencyInfoScreenContent: View { /// Set by the wallet so this screen zooms out of the tapped token card. @Environment(\.walletCardNamespace) private var cardNamespace - @State private var presentedSellViewModel: CurrencySellViewModel? - @State private var isShowingCurrencySelection: Bool = false - /// New UI: the toolbar title only appears once the hero card's own title has + /// The toolbar title only appears once the hero card's own title has /// scrolled out of view (Apple Wallet / App Store behaviour). @State private var showsToolbarTitle: Bool = false /// How far through a pull-to-close the content currently is, 0 to 1. Used to @@ -110,8 +108,6 @@ private struct CurrencyInfoScreenContent: View { private let presentation: CurrencyInfoScreen.Presentation private let defersHeavyContent: Bool - private var isNewUI: Bool { BetaFlags.shared.hasEnabled(.newUI) } - /// Overlay hosting draws its own chrome; pushed hosting uses `.toolbar`. private var overlayClose: (() -> Void)? { if case .overlay(let onClose, _, _, _, _) = presentation { return onClose } @@ -160,28 +156,22 @@ private struct CurrencyInfoScreenContent: View { return false } - /// Whether the navigation bar's title item is visible. The new UI reveals - /// it on scroll; the old UI shows it for the whole screen. - private var showsBarTitle: Bool { - !isNewUI || showsToolbarTitle - } - - /// Where the title item sits. The old UI centres it. The new UI wants it - /// leading — but on iOS 26 a `.topBarLeading` item shares the back button's + /// Where the title item sits. The title wants to be leading — but on + /// iOS 26 a `.topBarLeading` item shares the back button's /// Liquid Glass platter group, and popping the screen makes UIKit collapse /// the departing platter into the back button's, which draws a hard-edged /// rectangle inside the circle for the length of the transition. A /// principal item is a group of its own with nothing to merge into, so iOS /// 26 places it there and offsets it back to the leading edge instead. private var titlePlacement: ToolbarItemPlacement { - isNewUI && !Self.hasGlassToolbar ? .topBarLeading : .principal + Self.hasGlassToolbar ? .principal : .topBarLeading } /// Horizontal nudge that puts the centred principal title where a leading /// item would have sat. Zero until both widths are measured — the title is /// hidden until the hero scrolls away, so it is never seen centred. private var titleOffset: CGFloat { - guard isNewUI, Self.hasGlassToolbar, barWidth > 0, titleWidth > 0 else { return 0 } + guard Self.hasGlassToolbar, barWidth > 0, titleWidth > 0 else { return 0 } return Self.titleLeadingInset - (barWidth - titleWidth) / 2 } @@ -251,78 +241,49 @@ private struct CurrencyInfoScreenContent: View { case .loading: CurrencyInfoLoadingView() case .loaded(let metadata, let decodedMetadata): - if isNewUI { - CurrencyInfoContentV2( - metadata: metadata, - decodedMetadata: decodedMetadata, - viewModel: viewModel, - ratesController: ratesController, - marketCapController: marketCapController, - session: session, - onGive: { - Analytics.buttonTapped(name: .give) - router.push(.give(mint)) - }, - // New UI pushes the convert flow onto the current stack — - // sells this currency into a chosen destination. - onConvert: { router.push(.convertCurrency(mint)) }, - // New UI pushes the buy flow onto the current stack rather - // than presenting it as a sheet. - onBuy: { router.push(.buyCurrency(mint)) }, - onWithdraw: { router.push(.withdrawCurrency(mint)) }, - onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, - onScrolledPastTitle: { scrolledPast in - guard showsToolbarTitle != scrolledPast else { return } - withAnimation(.easeInOut(duration: 0.2)) { - showsToolbarTitle = scrolledPast - } - }, - onPulledDown: { pulled in - guard overlayClose != nil else { return } - let progress = min(1, pulled / Self.pullToCloseDistance) - pullProgress = progress - onPull(progress) - }, - onPullEnded: { pulled in - guard overlayClose != nil else { return } - let passed = pulled >= Self.pullToCloseDistance - if !passed { - withAnimation(.smooth(duration: 0.25)) { pullProgress = 0 } - } - onPullEnded(passed, pulled) - }, - showsHeroCard: showsHeroCard, - heroOffset: heroOffset, - contentOpacity: contentOpacity, - defersHeavyContent: defersHeavyContent, - supportsPullToClose: overlayClose != nil - ) - } else { - LoadedContent( + CurrencyInfoContentV2( metadata: metadata, decodedMetadata: decodedMetadata, viewModel: viewModel, ratesController: ratesController, marketCapController: marketCapController, - onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, - onShowCurrencySelection: { isShowingCurrencySelection = true }, - onBuy: { router.presentNested(.buy(mint)) }, + session: session, onGive: { Analytics.buttonTapped(name: .give) router.push(.give(mint)) }, - onSell: { - Analytics.buttonTapped(name: .sell) - presentedSellViewModel = CurrencySellViewModel( - currencyMetadata: metadata, - session: session, - ratesController: ratesController - ) + // Convert and buy push onto the current stack rather than + // presenting as sheets, so they keep this screen behind them. + onConvert: { router.push(.convertCurrency(mint)) }, + onBuy: { router.push(.buyCurrency(mint)) }, + onWithdraw: { router.push(.withdrawCurrency(mint)) }, + onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, + onScrolledPastTitle: { scrolledPast in + guard showsToolbarTitle != scrolledPast else { return } + withAnimation(.easeInOut(duration: 0.2)) { + showsToolbarTitle = scrolledPast + } }, - onDeposit: { router.push(.usdcDepositEducation) }, - onWithdraw: { router.push(.withdrawCurrency(mint)) } - ) - } + onPulledDown: { pulled in + guard overlayClose != nil else { return } + let progress = min(1, pulled / Self.pullToCloseDistance) + pullProgress = progress + onPull(progress) + }, + onPullEnded: { pulled in + guard overlayClose != nil else { return } + let passed = pulled >= Self.pullToCloseDistance + if !passed { + withAnimation(.smooth(duration: 0.25)) { pullProgress = 0 } + } + onPullEnded(passed, pulled) + }, + showsHeroCard: showsHeroCard, + heroOffset: heroOffset, + contentOpacity: contentOpacity, + defersHeavyContent: defersHeavyContent, + supportsPullToClose: overlayClose != nil + ) case .error(let error): CurrencyInfoErrorView(error: error) { dismiss() @@ -360,14 +321,14 @@ private struct CurrencyInfoScreenContent: View { ToolbarItem(placement: titlePlacement) { toolbarContent() .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { titleWidth = $0 } - .opacity(showsBarTitle ? 1 : 0) + .opacity(showsToolbarTitle ? 1 : 0) .offset(x: titleOffset) } - .sharedBackgroundVisibility(isNewUI ? .hidden : .automatic) + .sharedBackgroundVisibility(.hidden) } else { ToolbarItem(placement: titlePlacement) { toolbarContent() - .opacity(showsBarTitle ? 1 : 0) + .opacity(showsToolbarTitle ? 1 : 0) } } if !isUSDF { @@ -389,12 +350,6 @@ private struct CurrencyInfoScreenContent: View { router.presentNested(.buy(mint)) } } - .sheet(item: $presentedSellViewModel) { sellViewModel in - CurrencySellAmountScreen(viewModel: sellViewModel) - } - .sheet(isPresented: $isShowingCurrencySelection) { - CurrencySelectionScreen(ratesController: ratesController) - } // Dialogs originating in the buy flow route through // `session.dialogItem` so they surface in `DialogWindow` rather than // fighting the sheet stack here. Binding `.dialog(item:)` on this @@ -479,46 +434,38 @@ private struct CurrencyInfoScreenContent: View { @ViewBuilder private func toolbarContent() -> some View { // USDF's name is already "Dollars", so no special-case is needed. if let metadata = mintMetadata { - if isNewUI { - // Compact leading label — on iOS 26 the system supplies a Liquid - // Glass platter around it (`CapsuleGlass`); on iOS 18 the same - // content shows without a pill background. `.fixedSize()` is - // required: the toolbar compresses the item to its icon otherwise, - // dropping the text. `CurrencyLabel` is row-shaped (it spaces name - // and amount apart with a Spacer), so it can't be reused here. - HStack(spacing: 8) { - RemoteImage(url: metadata.imageURL) - .frame(width: 24, height: 24) - .clipShape(Circle()) - VStack(alignment: .leading, spacing: 0) { - // Semantic styles rather than fixed white/grey: inside the - // glass they pick up the system's vibrancy, so the label - // stays legible over a bright bill card scrolling beneath. - Text(metadata.name) - .font(.appTextSmall) - .foregroundStyle(.primary) - // USDF has no market cap (no bonding curve), so it stays - // a single-line pill. - if !isUSDF { - Text(viewModel.marketCap.formatted()) - .font(.appTextCaption) - .foregroundStyle(.secondary) - } + // Compact leading label — on iOS 26 the system supplies a Liquid + // Glass platter around it (`CapsuleGlass`); on iOS 18 the same + // content shows without a pill background. `.fixedSize()` is + // required: the toolbar compresses the item to its icon otherwise, + // dropping the text. `CurrencyLabel` is row-shaped (it spaces name + // and amount apart with a Spacer), so it can't be reused here. + HStack(spacing: 8) { + RemoteImage(url: metadata.imageURL) + .frame(width: 24, height: 24) + .clipShape(Circle()) + VStack(alignment: .leading, spacing: 0) { + // Semantic styles rather than fixed white/grey: inside the + // glass they pick up the system's vibrancy, so the label + // stays legible over a bright bill card scrolling beneath. + Text(metadata.name) + .font(.appTextSmall) + .foregroundStyle(.primary) + // USDF has no market cap (no bonding curve), so it stays + // a single-line pill. + if !isUSDF { + Text(viewModel.marketCap.formatted()) + .font(.appTextCaption) + .foregroundStyle(.secondary) } } - .lineLimit(1) - .fixedSize() - // The pill's inset + height + glass are all iOS 26 only; on - // iOS 18 the title keeps natural toolbar spacing (no capsule, so - // no capsule padding to leave dead space around it). - .modifier(TitlePill()) - } else { - CurrencyLabel( - imageURL: metadata.imageURL, - name: metadata.name, - amount: nil - ) } + .lineLimit(1) + .fixedSize() + // The pill's inset + height + glass are all iOS 26 only; on + // iOS 18 the title keeps natural toolbar spacing (no capsule, so + // no capsule padding to leave dead space around it). + .modifier(TitlePill()) } } } @@ -608,137 +555,3 @@ private struct TitlePill: ViewModifier { } } -// MARK: - Loaded Content - - -/// Extracted subview that isolates observation tracking from the parent. -/// Reads from `viewModel`, `ratesController`, and `session` (indirectly) -/// are scoped to this view's body — the parent body is not invalidated -/// when poll-driven rate/balance changes occur every ~10 seconds. -private struct LoadedContent: View { - let metadata: StoredMintMetadata - /// Pre-decoded `MintMetadata` from the view model. Passed in ready-made - /// so the body doesn't JSON-decode on every observation-churn re-eval. - let decodedMetadata: MintMetadata - let viewModel: CurrencyInfoViewModel - let ratesController: RatesController - let marketCapController: MarketCapController - - let onShowTransactionHistory: () -> Void - let onShowCurrencySelection: () -> Void - let onBuy: () -> Void - let onGive: () -> Void - let onSell: () -> Void - let onDeposit: () -> Void - let onWithdraw: () -> Void - - private var isUSDF: Bool { - metadata.mint == .usdf - } - - private var currencyDescription: String { - metadata.bio ?? "No information" - } - - var body: some View { - let balance = viewModel.balance - let appreciation = viewModel.appreciation - let marketCap = viewModel.marketCap - - ZStack { - ScrollView { - VStack(spacing: 0) { - CurrencyInfoHeaderSection( - balance: balance, - appreciation: appreciation, - isUSDF: isUSDF, - onCurrencySelection: onShowCurrencySelection, - onViewTransaction: onShowTransactionHistory - ) - - // Currency Info - section(spacing: 20) { - if !isUSDF { - HStack { - Image(systemName: "text.justify.left") - .padding(.bottom, -1) - Text("Currency Info") - } - .font(.appBarButton) - .foregroundStyle(Color.textMain) - - if let createdAt = metadata.createdAt { - Text("Created \(createdAt.formatted(date: .abbreviated, time: .omitted))") - .foregroundStyle(Color.textSecondary) - .font(.appTextSmall) - } - } - - ExpandableText(currencyDescription) - .foregroundStyle(Color.textSecondary) - .font(.appTextSmall) - - if !isUSDF && !decodedMetadata.socialLinks.isEmpty { - CurrencyInfoSocialLinksSection(socialLinks: decodedMetadata.socialLinks) - } - } - - // Market Cap - if !isUSDF { - CurrencyInfoMarketCapSection( - marketCap: marketCap, - currencyCode: ratesController.balanceCurrency, - marketCapController: marketCapController - ) - } - - // Reserve space so the floating footer doesn't overlap - // scrolled content. - Color - .clear - .padding(.bottom, 100) - } - } - - // Floating Footer - if isUSDF { - CurrencyInfoFooter { - Button("Deposit") { - onDeposit() - } - .buttonStyle(.filled) - - CodeButton(style: .filledSecondary, title: "Withdraw") { - onWithdraw() - } - } - } else { - CurrencyInfoFooter { - Button("Buy") { - onBuy() - } - .buttonStyle(.filled) - - if balance.hasDisplayableValue { - CodeButton(style: .filledSecondary, title: "Give") { - onGive() - } - - CodeButton(style: .filledSecondary, title: "Sell") { - onSell() - } - } - } - } - } - } - - @ViewBuilder private func section(spacing: CGFloat = 0, @ViewBuilder builder: () -> some View) -> some View { - VStack(alignment: .leading, spacing: spacing) { - builder() - } - .padding(.top, 20) - .padding(.bottom, 25) - .vSeparator(color: .rowSeparator) - .padding(.horizontal, 20) - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index c068768f2..e1ebd180e 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -29,8 +29,8 @@ class CurrencyInfoViewModel { private(set) var loadingState: LoadingState = .loading - /// Recent activity for this token (newest first), previewed on the new-UI - /// info screen. Loaded via ``loadRecentActivities(limit:)`` so DB access stays + /// Recent activity for this token (newest first), previewed on the info + /// screen. Loaded via ``loadRecentActivities(limit:)`` so DB access stays /// in the view model rather than the view. private(set) var recentActivities: [Activity] = [] diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellAmountScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellAmountScreen.swift deleted file mode 100644 index bd154169e..000000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellAmountScreen.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// CurrencySellAmountScreen.swift -// Code -// -// Created by Raul Riera on 2025-12-30. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct CurrencySellAmountScreen: View { - @Bindable private var viewModel: CurrencySellViewModel - @Environment(\.dismiss) var dismissAction: DismissAction - @Environment(RatesController.self) private var ratesController - - @State private var isShowingCurrencySelection: Bool = false - - // MARK: - Init - - - init(viewModel: CurrencySellViewModel) { - self.viewModel = viewModel - } - - // MARK: - Body - - - var body: some View { - NavigationStack(path: $viewModel.path) { - Background(color: .backgroundMain) { - EnterAmountView( - mode: .sell, - enteredAmount: $viewModel.enteredAmount, - subtitle: .balanceWithLimit(viewModel.maxPossibleAmount), - actionState: .constant(.normal), - actionEnabled: { _ in - viewModel.canPerformAction - }, - action: viewModel.showConfirmationScreen, - currencySelectionAction: showCurrencySelection - ) - .foregroundStyle(.textMain) - .padding(20) - } - .ignoresSafeArea(.keyboard) - .navigationTitle(viewModel.screenTitle) - .toolbarTitleDisplayMode(.inline) - .navigationDestination(for: CurrencySellPath.self) { step in - switch step { - case .confirmation(let amount, let pinnedState): - CurrencySellConfirmationScreen( - mint: viewModel.currencyMetadata.mint, - currencyName: viewModel.currencyMetadata.name, - amount: amount, - pinnedState: pinnedState, - sellFeeBps: viewModel.currencyMetadata.sellFeeBps, - path: $viewModel.path - ) - .environment(\.dismissParentContainer, { - dismissAction() - }) - case .processing(let swapId, let currencyName, let amount): - SwapProcessingScreen(swapId: swapId, swapType: .sell, 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/CurrencySellConfirmationScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationScreen.swift deleted file mode 100644 index c200ced66..000000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationScreen.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// CurrencySellConfirmationScreen.swift -// Code -// -// Created by Raul Riera on 2025-12-30. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct CurrencySellConfirmationScreen: View { - let currencyName: String - let amount: ExchangedFiat - @Binding var path: [CurrencySellPath] - - @State private var viewModel: CurrencySellConfirmationViewModel - - @Environment(Session.self) private var session - - // MARK: - Init - - - init(mint: PublicKey, currencyName: String, amount: ExchangedFiat, pinnedState: VerifiedState, sellFeeBps: Int?, path: Binding<[CurrencySellPath]>) { - self.currencyName = currencyName - self.amount = amount - self._path = path - self.viewModel = CurrencySellConfirmationViewModel(mint: mint, amount: amount, pinnedState: pinnedState, sellFeeBps: sellFeeBps) - } - - var body: some View { - Background(color: .backgroundMain) { - VStack { - Spacer() - - BorderedContainer { - VStack(spacing: 10) { - HStack { - Text("Sell amount") - Spacer() - Text(viewModel.amount.nativeAmount.formatted()) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - } - - HStack { - Text("1% Fee") - Spacer() - Text(viewModel.feeFormatted) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - } - } - .padding() - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - - VStack { - Text("You Receive") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - AmountText( - flagStyle: viewModel.amountAfterFee.nativeAmount.currency.flagStyle, - content: viewModel.amountAfterFee.nativeAmount.formatted(), - showChevron: false, - canScale: false - ) - .font(.appDisplaySmall) - .foregroundStyle(Color.textMain) - } - .padding(.bottom, 32) - } - - Spacer() - - VStack { - Text("Review the above before confirming.\nOnce made, your transaction is irreversible.") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .multilineTextAlignment(.center) - CodeButton(state: viewModel.actionButtonState, - style: .filled, - title: "Sell", - disabled: !viewModel.canPerformAction, - action: performSell - ) - .padding(.top, 20) - } - } - .padding(20) - } - .interactiveDismissDisabled(!viewModel.canDismissSheet) - .dialog(item: $viewModel.dialogItem) - .navigationTitle("Confirm Sale") - .onChange(of: viewModel.pendingSwapId) { _, swapId in - if let swapId { - path.append(.processing(swapId: swapId, currencyName: currencyName, amount: viewModel.amountAfterFee)) - } - } - } - - // MARK: - Actions - - - private func performSell() { - viewModel.performSell(using: session) - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift deleted file mode 100644 index 7f8b6597f..000000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// CurrencySellConfirmationViewModel.swift -// Code -// -// Created by Raul Riera on 2025-12-30. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -@Observable -class CurrencySellConfirmationViewModel { - @ObservationIgnored let mint: PublicKey - @ObservationIgnored let amount: ExchangedFiat - @ObservationIgnored let pinnedState: VerifiedState - /// The pool's sell fee in basis points; nil falls back to the launchpad - /// default (100). - @ObservationIgnored let sellFeeBps: Int? - - var dialogItem: DialogItem? - private(set) var actionButtonState: ButtonState = .normal - /// Swap id of a successfully submitted sell. The confirmation screen observes this to push the processing screen. - var pendingSwapId: SwapId? - - var canDismissSheet: Bool = false - - var canPerformAction: Bool { - !pinnedState.isStale - } - - var fee: ExchangedFiat { - amount.launchpadSellFee(bps: UInt64(max(0, sellFeeBps ?? 100))) - } - - /// Formats the fee for display, prefixing with "~" when the value is - /// too small for the currency's display precision (e.g. "~$0.00" for USD, - /// "~¥0" for JPY) to indicate a non-zero but negligible fee. - var feeFormatted: String { - let prefix = fee.isApproximatelyZero() ? "~" : "" - return "\(prefix)\(fee.nativeAmount.formatted())" - } - - var amountAfterFee: ExchangedFiat { - amount.subtractingFee(fee.onChainAmount) - } - - // MARK: - Init - - - init(mint: PublicKey, amount: ExchangedFiat, pinnedState: VerifiedState, sellFeeBps: Int? = nil) { - self.mint = mint - self.amount = amount - self.pinnedState = pinnedState - self.sellFeeBps = sellFeeBps - } - - // MARK: - Actions - - - func performSell(using session: Session) { - actionButtonState = .loading - - Task { - do { - let swapId = try await session.sell(amount: amount, verifiedState: pinnedState, in: mint) - pendingSwapId = swapId - } catch Session.Error.verifiedStateStale { - // Session.assertFresh already logged this. Reset button only. - actionButtonState = .normal - } catch { - ErrorReporting.captureError( - error, - reason: "Failed to sell currency", - metadata: [ - "mint": mint.base58, - "amount": amount.nativeAmount.formatted(), - "fee": fee.nativeAmount.formatted(), - "amountAfterFees": amountAfterFee.nativeAmount.formatted(), - "quarks": "\(amount.onChainAmount.quarks)", - ], - userFacing: true - ) - actionButtonState = .normal - showErrorDialog(error: error) - } - } - } - - - // MARK: - Dialogs - - - private func showErrorDialog(error: Error) { - let title: String - let subtitle: String - - switch error { - case ErrorSwap.denied(_, let kinds, _) where kinds.contains(.insufficientSellFee): - title = "Amount Too Small" - subtitle = "The amount you entered is too small to cover the required transaction fee. Please enter a larger amount" - - default: - title = "Unable to Sell Currency" - subtitle = "We couldn't complete your sale. Please try again or contact support at support@flipcash.com if the issue persists." - } - - dialogItem = .error(title: title, subtitle: subtitle) { - .okay(kind: .destructive) { [weak self] in - self?.actionButtonState = .normal - } - } - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellViewModel.swift deleted file mode 100644 index 3448c5469..000000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellViewModel.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// CurrencySellViewModel.swift -// Code -// -// Created by Raul Riera on 2025-12-30. -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -@Observable -class CurrencySellViewModel: Identifiable { - var enteredAmount: String = "" - var path: [CurrencySellPath] = [] - var dialogItem: DialogItem? - @ObservationIgnored let currencyMetadata: StoredMintMetadata - - 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 Sell" - } - - var maxPossibleAmount: ExchangedFiat { - let rate = ratesController.rateForBalanceCurrency() - let zero = ExchangedFiat.compute( - onChainAmount: .zero(mint: currencyMetadata.mint), - rate: rate, - supplyQuarks: nil - ) - - guard let balance = session.balance(for: currencyMetadata.mint) else { - return zero - } - - return balance.computeExchangedValue(with: rate) - } - - @ObservationIgnored private let session: Session - @ObservationIgnored private let ratesController: RatesController - @ObservationIgnored private let amountValidator = AmountValidator() - - // MARK: - Init - - - init(currencyMetadata: StoredMintMetadata, session: Session, ratesController: RatesController) { - self.currencyMetadata = currencyMetadata - self.session = session - self.ratesController = ratesController - } - - // MARK: - Actions - - - func showConfirmationScreen() { - guard enteredFiat != nil else { return } - - Task { - guard let (amount, pin) = await prepareSubmission() else { - dialogItem = .error(title: "Rate Unavailable", subtitle: "Couldn't get a fresh rate. Please try again.") - return - } - path.append(.confirmation(amount: amount, pinnedState: pin)) - } - } - - /// Resolves the pin and computes the amount carried into the confirmation - /// screen — confirmation and `Session.sell` receive the same pin. - func prepareSubmission() async -> (amount: ExchangedFiat, pinnedState: VerifiedState)? { - let currency = ratesController.balanceCurrency - guard let pin = await ratesController.currentPinnedState(for: currency, mint: currencyMetadata.mint) else { - return nil - } - guard let amount = computeAmount(using: pin.rate, pinnedSupplyQuarks: pin.supplyFromBonding) else { - return nil - } - return (amount, pin) - } - - /// Preview passes `nil` for `pinnedSupplyQuarks` (falls back to live metadata); - /// submit passes the pinned supply so rate and supply come from one proof. - private func computeAmount(using rate: Rate, pinnedSupplyQuarks: UInt64? = nil) -> ExchangedFiat? { - guard !enteredAmount.isEmpty else { return nil } - guard let amount = amountValidator.validate(enteredAmount) else { return nil } - guard let supplyQuarks = pinnedSupplyQuarks ?? currencyMetadata.supplyFromBonding else { return nil } - - let balance = session.balance(for: currencyMetadata.mint) - - return ExchangedFiat.compute( - fromEntered: FiatAmount(value: amount, currency: rate.currency), - rate: rate, - mint: currencyMetadata.mint, - supplyQuarks: supplyQuarks, - balance: balance.map(\.usdf), - tokenBalanceQuarks: balance?.quarks - ) - } - -} - -enum CurrencySellPath: Hashable { - case confirmation(amount: ExchangedFiat, pinnedState: VerifiedState) - case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat) -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift index f1e472b39..1907131d0 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift @@ -33,8 +33,6 @@ class SwapProcessingViewModel { // Convert names its destination via `currencyName`, so the // same "X of " reads correctly there too. return "\(exchangedFiat.nativeAmount.formatted()) of \(currencyName)" - case .sell: - return "\(exchangedFiat.nativeAmount.formatted()) of USDF" } } return "Transaction Complete" @@ -49,7 +47,7 @@ class SwapProcessingViewModel { return "This transaction typically takes about a minute. You may leave the app while it completes" case .success: switch swapType { - case .buyWithReserves, .buyWithCurrency, .sell, .convert: + case .buyWithReserves, .buyWithCurrency, .convert: return "was just added to your Flipcash wallet" } case .failed: @@ -74,8 +72,6 @@ class SwapProcessingViewModel { switch swapType { case .buyWithReserves, .buyWithCurrency: "Buying \(currencyName)" - case .sell: - "Selling \(currencyName)" case .convert: "Converting" } @@ -98,8 +94,7 @@ class SwapProcessingViewModel { private let swapId: SwapId private let swapType: SwapType - /// The token being bought, or a convert's destination; nil on the sell path, - /// where `amount.mint` already names the subject token. Feeds analytics, and + /// The token being bought, or a convert's destination. Feeds analytics, and /// the success title's check for a conversion landing in the reserve. private let targetMint: PublicKey? private let currencyName: String @@ -196,8 +191,6 @@ class SwapProcessingViewModel { Analytics.tokenPurchase(method: .purchaseWithReserves, targetMint: targetMint, exchangedFiat: amount, successful: successful) case .buyWithCurrency: Analytics.tokenPurchase(method: .purchaseWithCurrency, targetMint: targetMint, exchangedFiat: amount, successful: successful) - case .sell: - Analytics.tokenSell(exchangedFiat: amount, successful: successful) case .convert: // A convert always disposes of the source token; record it as a // sell of the amount that left the wallet. @@ -227,7 +220,6 @@ enum SwapError: Error { nonisolated enum SwapType: CaseIterable { case buyWithReserves case buyWithCurrency - case sell /// Selling one currency straight into another (source → USDF, or source → /// another launchpad token). Drives the "Converting" copy. case convert diff --git a/Flipcash/Core/Screens/Main/ExchangedBalance.swift b/Flipcash/Core/Screens/Main/ExchangedBalance.swift new file mode 100644 index 000000000..afea76037 --- /dev/null +++ b/Flipcash/Core/Screens/Main/ExchangedBalance.swift @@ -0,0 +1,40 @@ +// +// ExchangedBalance.swift +// Code +// +// Created by Dima Bart on 2025-04-23. +// + +import Foundation +import FlipcashCore + +/// A stored balance paired with its fiat value at a given rate. +struct ExchangedBalance: Identifiable, Hashable { + let stored: StoredBalance + let exchangedFiat: ExchangedFiat + + var id: PublicKey { + stored.id + } +} + +extension Array where Element == ExchangedBalance { + + /// Balances eligible to give, send, or tip. Dollars appears only when it + /// carries a displayable value: `balances(for:)` keeps USDF at any value so + /// the wallet can render a zero Dollars card, and an amount-entry picker has + /// no use for a balance that can't fund anything. + func giveable() -> [ExchangedBalance] { + filter { balance in + guard balance.stored.mint == .usdf else { return true } + return balance.exchangedFiat.hasDisplayableValue() + } + } +} + +extension StoredBalance { + /// This balance paired with its fiat value at `rate`. + func exchanged(with rate: Rate) -> ExchangedBalance { + ExchangedBalance(stored: self, exchangedFiat: computeExchangedValue(with: rate)) + } +} diff --git a/Flipcash/Core/Screens/Main/GiveViewModel.swift b/Flipcash/Core/Screens/Main/GiveViewModel.swift index 2bd5ac04b..4df140494 100644 --- a/Flipcash/Core/Screens/Main/GiveViewModel.swift +++ b/Flipcash/Core/Screens/Main/GiveViewModel.swift @@ -53,7 +53,7 @@ final class GiveViewModel { init(container: Container, sessionContainer: SessionContainer, mint: PublicKey?) { let session = sessionContainer.session let ratesController = sessionContainer.ratesController - let resolved = ratesController.resolveInitialBalance(mint: mint, session: session, includingDollars: BetaFlags.shared.allowsDollarsGive) + let resolved = ratesController.resolveInitialBalance(mint: mint, session: session) self.container = container self.sessionContainer = sessionContainer diff --git a/Flipcash/Core/Screens/Main/Home/BalanceHeaderButton.swift b/Flipcash/Core/Screens/Main/Home/BalanceHeaderButton.swift new file mode 100644 index 000000000..466553a10 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/BalanceHeaderButton.swift @@ -0,0 +1,42 @@ +// +// BalanceHeaderButton.swift +// Code +// +// Created by Dima Bart on 2025-04-23. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The wallet's total-balance display; tapping it opens the balance-currency +/// picker. +struct BalanceHeaderButton: View { + let balance: ExchangedFiat + + @Environment(RatesController.self) private var ratesController + @State private var isShowingCurrencySelection = false + + var body: some View { + VStack(spacing: 10) { + Button { + isShowingCurrencySelection.toggle() + } label: { + AmountText( + flagStyle: balance.nativeAmount.currency.flagStyle, + content: balance.nativeAmount.formatted(), + showChevron: true + ) + .font(.appDisplayLarge) + .foregroundStyle(Color.textMain) + .contentTransition(.numericText()) + } + .accessibilityIdentifier("balance-header") + .frame(maxWidth: .infinity) + .animation(.default, value: balance) + .sheet(isPresented: $isShowingCurrencySelection) { + CurrencySelectionScreen(ratesController: ratesController) + } + } + } +} diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index d268bfc38..6be4c25c3 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -6,10 +6,8 @@ import SwiftUI import FlipcashUI -/// The v2 tab-bar root, shown post-login when `BetaFlags.newUI` is enabled (in -/// place of the scanner-first `ScanScreen`). Hosts the four tabs, launches on -/// Wallet, and owns the app-level `router.rootSheet` host so `router.present(_:)` -/// works from any tab. +/// The post-login root. Hosts the four tabs, launches on Wallet, and owns the +/// app-level `router.rootSheet` host so `router.present(_:)` works from any tab. /// /// On iOS 26 the tabs live in a native `TabView`, which renders the system /// Liquid Glass tab bar; below that we fall back to the home-grown floating @@ -82,22 +80,16 @@ struct HomeTabView: View { var body: some View { tabs .background(Color.backgroundMain) - // The app-level sheet host lives here in v2 (ScanScreen suppresses its - // own copy when embedded) so `router.present(_:)` works from any tab. - .modifier(RootSheetHostModifier(enabled: true)) + // The app-level sheet host, so `router.present(_:)` works from any tab. + .modifier(RootSheetHostModifier()) .onAppear { router.activeTabStack = selection.pushStack - // Tells the router which stacks live behind tabs, so a deep link - // into one selects its tab instead of presenting it as a sheet. - router.tabStacks = Set(HomeTab.allCases.compactMap(\.pushStack)) - } - .onChange(of: router.requestedTabStack) { _, requested in - guard let requested, - let tab = HomeTab.allCases.first(where: { $0.pushStack == requested }) - else { return } - selection = tab - router.requestedTabStack = nil + // A deep link that landed before this view started observing + // (cold start into a tab route) parked its request on the router; + // consume it here so it isn't dropped. + selectRequestedTab() } + .onChange(of: router.requestedTabStack) { _, _ in selectRequestedTab() } .onChange(of: selection) { _, tab in router.activeTabStack = tab.pushStack // Leaving the tab puts the card back (and the brightness with it). @@ -106,6 +98,15 @@ struct HomeTabView: View { .onDisappear { router.activeTabStack = nil } } + /// Brings the tab the router asked for forward and clears the request. + private func selectRequestedTab() { + guard let requested = router.requestedTabStack, + let tab = HomeTab.allCases.first(where: { $0.pushStack == requested }) + else { return } + selection = tab + router.requestedTabStack = nil + } + @ViewBuilder private var tabs: some View { if #available(iOS 26, *) { nativeTabs @@ -184,7 +185,7 @@ struct HomeTabView: View { switch tab { case .scan: if selection == .scan { - ScanScreen(isEmbedded: true) + ScanScreen() } else { Color.backgroundMain } @@ -200,10 +201,13 @@ struct HomeTabView: View { } } -private extension HomeTab { +extension HomeTab { /// The router stack this tab pushes onto, published to `AppRouter` as the /// active push target. `nil` for tabs that only present sheets (Scan) or /// never push (Tip Card owns a local stack). + /// + /// Must agree with `AppRouter.Stack.isTabHosted`, which is what the router + /// routes on; `AppRouterCrossStackTests` pins the two together. var pushStack: AppRouter.Stack? { switch self { case .wallet: return .balance diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 721068bb3..cdacc65fc 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -7,11 +7,10 @@ import SwiftUI import FlipcashUI import FlipcashCore -/// The v2 Wallet tab: the big balance header, per-token bill cards in a -/// collapsing ``TokenCardStack``, and an add-money affordance. Replaces the v1 -/// ``BalanceScreen`` list in the tab-bar UI. Owns its own `NavigationStack` bound -/// to `router[.balance]`, so the existing push destinations (currency info, -/// transaction history) work unchanged. +/// The Wallet tab: the big balance header, per-token bill cards in a collapsing +/// ``TokenCardStack``, and an add-money affordance. Owns its own +/// `NavigationStack` bound to `router[.balance]`, so the push destinations +/// (currency info, transaction history) work unchanged. struct WalletScreen: View { @Environment(SessionContainer.self) private var sessionContainer @@ -139,7 +138,7 @@ private struct WalletScreenContent: View { self.onScanTipCard = onScanTipCard let rate = sessionContainer.ratesController.rateForBalanceCurrency() // Seed synchronously so the first render shows real balances, not an - // empty-state flash (mirrors BalanceScreen). + // empty-state flash. let seed = Self.snapshot(session: sessionContainer.session, rate: rate) _cards = State(initialValue: seed.cards) _total = State(initialValue: seed.total) @@ -248,6 +247,7 @@ private struct WalletScreenContent: View { /// Opens a token: the deck reorganises around the tapped card, which stays /// on screen while the detail panel rises beneath it. private func openCard(_ item: TokenCardData, currentTop: CGFloat) { + Analytics.tokenInfoOpened(from: .openedFromWallet, mint: item.mint) transitionToken &+= 1 let token = transitionToken @@ -387,6 +387,7 @@ private struct WalletScreenContent: View { /// to start from. Everything lands where the opening animation would have /// left it, so closing still puts the card back into the deck normally. private func openCardImmediately(_ mint: PublicKey) { + Analytics.tokenInfoOpened(from: .openedFromDeeplink, mint: mint) transitionToken &+= 1 var immediate = Transaction() diff --git a/Flipcash/Core/Screens/Main/ScanBottomBar.swift b/Flipcash/Core/Screens/Main/ScanBottomBar.swift deleted file mode 100644 index 160e4ca95..000000000 --- a/Flipcash/Core/Screens/Main/ScanBottomBar.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ScanBottomBar.swift -// Flipcash -// - -import SwiftUI -import FlipcashUI - -struct ScanBottomBar: View { - let toast: String? - let showTips: Bool - let tipsBadgeCount: Int - let onGive: () -> Void - let onWallet: () -> Void - let onDiscover: () -> Void - let onTips: () -> Void - - var body: some View { - HStack(alignment: .bottom) { - LargeButton( - title: "Discover", - image: Image(.Icons.coins), - action: onDiscover - ) - .accessibilityIdentifier("scan-discover-button") - - LargeButton( - title: "Cash", - image: .asset(.cash), - action: onGive - ) - .accessibilityIdentifier("scan-cash-button") - - if showTips { - LargeButton( - title: "Tips", - image: Image(.Icons.tips), - badgeCount: tipsBadgeCount, - action: onTips - ) - .accessibilityIdentifier("scan-tips-button") - } - - ToastContainer(toast: toast) { - LargeButton( - title: "Wallet", - image: .asset(.history), - action: onWallet - ) - .accessibilityIdentifier("scan-wallet-button") - } - } - .padding(.bottom, 10) - } -} diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 9e679a706..15ee93807 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -11,28 +11,21 @@ import FlipcashCore /// Thin environment-reading wrapper that hands the DI containers to /// ``ScanScreenContent``, whose `init` builds the `@State` scan view model and -/// `@Bindable` session synchronously. `ScanScreen` is the post-login root — -/// ``ContainerScreen`` injects the `SessionContainer` into the environment here. +/// `@Bindable` session synchronously. Mounted as the Scan tab of +/// ``HomeTabView``, which owns the app-level `router.rootSheet` host. struct ScanScreen: View { @Environment(Container.self) private var container @Environment(SessionContainer.self) private var sessionContainer - /// When embedded as a tab in the v2 `HomeTabView`, the app-level - /// `router.rootSheet` host is owned by the tab container instead, so this - /// screen suppresses its own. Defaults to `false` — the v1 post-login root - /// owns the host itself. - var isEmbedded: Bool = false - var body: some View { - ScanScreenContent(container: container, sessionContainer: sessionContainer, isEmbedded: isEmbedded) + ScanScreenContent(container: container, sessionContainer: sessionContainer) } } private struct ScanScreenContent: View { @Environment(Preferences.self) private var preferences - @Environment(AppRouter.self) private var router @Bindable private var session: Session @@ -40,33 +33,17 @@ private struct ScanScreenContent: View { @State private var cameraAuthorizer = CameraAuthorizer() - private var toast: String? { - if let toast = session.toast { - let formatted = toast.amount.formatted() - if toast.isDeposit { - return "+\(formatted)" - } else { - return "-\(formatted)" - } - } - return nil - } - private var cameraPrompt: CameraPrompt? { CameraPrompt(status: cameraAuthorizer.status, cameraEnabled: preferences.cameraEnabled) } private let sessionContainer: SessionContainer - /// See ``ScanScreen/isEmbedded``. - private let isEmbedded: Bool - // MARK: - Init - - init(container: Container, sessionContainer: SessionContainer, isEmbedded: Bool = false) { + init(container: Container, sessionContainer: SessionContainer) { self.sessionContainer = sessionContainer self.session = sessionContainer.session - self.isEmbedded = isEmbedded self.viewModel = ScanViewModel( container: container, @@ -89,42 +66,26 @@ private struct ScanScreenContent: View { // in front of the BillCanvas, otherwise it // will swallow all touch events if let cameraPrompt { - CameraPromptView(prompt: cameraPrompt, embedded: isEmbedded) { + CameraPromptView(prompt: cameraPrompt, embedded: true) { performCameraPromptAction(cameraPrompt) } .zIndex(1) .transition(.opacity) } - - interfaceView() - .zIndex(1) - .transition(.opacity) } } // Fill the tab's full width and height. The iOS 26 native `TabView` does // not stretch tab content to fill, so without this the ZStack collapses to // its content width (the centered `CameraPromptView` at ~340pt, or the - // camera viewport), leaving black bars down both sides of the scanner. The - // v1 full-screen root masked this; only the embedded v2 tab exposed it. + // camera viewport), leaving black bars down both sides of the scanner. .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.backgroundMain) .animation(.easeInOut(duration: 0.15), value: showControls) .animation(.easeInOut(duration: 0.3), value: preferences.cameraEnabled) .ignoresSafeArea(.keyboard) - // The app-level `router.rootSheet` host (RoutedSheet + tip-resume + the - // bill-dismiss hook). Owned here for the v1 root; when embedded as a v2 - // tab, `HomeTabView` owns it instead, so this suppresses its own to avoid - // presenting the same sheet twice. - // - // The bill / tipcard surface itself (BillCanvas, actions, designer, and - // the received-cash / send-tip sheets) is hoisted to `BillOverlayView` at - // the app root — see `ContainerScreen` — so a pushed bill renders over any - // tab, not only when this scanner is the mounted surface. `showControls` - // still hides the scanner chrome while a bill is up. - .modifier(RootSheetHostModifier(enabled: !isEmbedded)) // Tells the app-root bill overlay the camera is behind it, so a grabbed - // bill shows over the live camera without a scrim (v1, or the v2 Scan - // tab). Any other surface gets the scrim. + // bill shows over the live camera without a scrim while the Scan tab is + // forward. Any other surface gets the scrim. .onAppear { session.isScannerForeground = true } .onDisappear { session.isScannerForeground = false } } @@ -161,179 +122,4 @@ private struct ScanScreenContent: View { } } - @ViewBuilder private func interfaceView() -> some View { - VStack { - // In the v2 tab-bar UI the scanner is a bare camera: the brand + - // settings top bar and the v1 bottom navigation are replaced by the - // app-level tab bar, so this chrome is suppressed when embedded. - if !isEmbedded { - ScanTopBar( - onBrand: { router.present(.downloadApp) }, - onSettings: { router.present(.settings) } - ) - } - Spacer() - if !isEmbedded { - ScanBottomBar( - toast: toast, - showTips: session.canUseTips, - tipsBadgeCount: sessionContainer.conversationController.unreadConversationCount(of: .tipDm), - onGive: presentGive, - onWallet: { router.present(.balance) }, - onDiscover: { router.present(.discover) }, - onTips: { router.present(.tips) } - ) - } - } - .opacity(session.isShowingBillDesigner ? 0 : 1) - } - - // MARK: - Actions - - - private func presentGive() { - let rate = sessionContainer.ratesController.rateForBalanceCurrency() - if let dialog = giveCashGate(session: session, rate: rate, includingDollars: BetaFlags.shared.allowsDollarsGive).blockingDialog(router: router, addMoneySource: .scanner) { - session.dialogItem = dialog - return - } - router.present(.give) - } -} - - -// MARK: - RoutedSheet - - -/// Renders the modal sheet currently selected by `AppRouter.presentedSheet`. -/// Each case is a top-level modal; switching between them is a sheet swap. -private struct RoutedSheet: View { - - let sheet: AppRouter.SheetPresentation - - @Environment(AppRouter.self) private var router - - var body: some View { - @Bindable var router = router - switch sheet { - case .balance: - BalanceScreen() - case .settings: - SettingsScreen() - case .give: - NavigationStack(path: $router[.give]) { - GiveScreen(mint: nil) - .appRouterDestinations() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: router.dismissSheet) - } - } - } - case .discover: - NavigationStack(path: $router[.discover]) { - CurrencyDiscoveryScreen() - .appRouterDestinations() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: router.dismissSheet) - } - } - } - 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() - case .addMoney: - // Add Money entered as a root sheet — the give-cash no-balance case - // (Scan / deeplink). Buy & launch shortfalls present it *nested* over - // their gating sheet via `presentNested(.addMoney(context))`. - AddMoneySheetRoot() - case .downloadApp: - NavigationStack(path: $router[.downloadApp]) { - DownloadAppScreen() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: router.dismissSheet) - } - } - } - case .tips: - TipsSheetRoot() - case .sendAmount(let target): - // Send Cash entered directly as a root sheet — e.g. the notification - // Send Cash deeplink / App Intent opens the amount entry with no chat - // behind it. (In-chat Send Cash still enters it via presentNested.) - SendAmountSheetRoot(target: target) - } - } -} - -// MARK: - RootSheetHostModifier - - -/// Hosts the app-level `router.rootSheet` presentation (the `RoutedSheet` swap), -/// the tip-resume-after-profile-creation hooks, and the dismiss-sheets-on-bill -/// hook. Exactly one live view must own this so `router.present(_:)` works from -/// anywhere: the v1 root (`ScanScreen`) in the scanner-first UI, or the tab -/// container (`HomeTabView`) in the v2 UI. `enabled` lets `ScanScreen` suppress -/// its own copy when it is embedded as a v2 tab. -struct RootSheetHostModifier: ViewModifier { - - var enabled: Bool = true - - @Environment(AppRouter.self) private var router - @Environment(SessionContainer.self) private var sessionContainer - - private var session: Session { sessionContainer.session } - - func body(content: Content) -> some View { - if enabled { - content - // Resume a tip held for profile creation the moment the profile - // becomes tippable. The sheet-close hook is the belt for a missed - // flip edge (e.g. the profile record arriving mid-transition): - // closing Tips with a held tip resumes it when a profile exists - // and drops it when creation was abandoned. - .onChange(of: session.profile?.isTippable ?? false) { _, isTippable in - guard isTippable else { return } - sessionContainer.tipFlow.resumeAfterProfileCreation() - } - .onChange(of: router.rootSheet) { old, new in - guard old == .tips, new == nil else { return } - if session.profile?.isTippable == true { - sessionContainer.tipFlow.resumeAfterProfileCreation() - } else { - sessionContainer.tipFlow.abandonPendingTip() - } - } - // 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.rootSheet }, - set: { newValue in - if newValue == nil { - router.dismissSheet() - } - } - )) { sheet in - RoutedSheet(sheet: sheet) - .appRouterNestedSheet() - } - // Dismiss all presented sheets when a bill is about to appear. - // Bills render behind sheets, so any sheet on top (Settings, - // Balance, Give) would obscure them. This ensures cash links - // received via push notifications or deep links are always visible - // regardless of the current navigation state. - .onChange(of: session.presentationState.isPresenting) { _, isPresenting in - guard isPresenting else { return } - router.dismissSheet() - } - } else { - content - } - } } diff --git a/Flipcash/Core/Screens/Main/ScanTopBar.swift b/Flipcash/Core/Screens/Main/ScanTopBar.swift deleted file mode 100644 index 0d3f274fa..000000000 --- a/Flipcash/Core/Screens/Main/ScanTopBar.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// ScanTopBar.swift -// Flipcash -// - -import SwiftUI -import FlipcashUI - -struct ScanTopBar: View { - let onBrand: () -> Void - let onSettings: () -> Void - - var body: some View { - HStack(alignment: .center) { - Button(action: onBrand) { - Image.asset(.flipcashBrand) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 28) - .frame(minHeight: 44) - .contentShape(Rectangle()) - } - .accessibilityLabel("Download Flipcash") - - Spacer() - - Button(action: onSettings) { - if #available(iOS 26, *) { - Image.asset(.hamburger) - .resizable() - .scaledToFit() - .foregroundStyle(Color.textMain) - .frame(width: 32, height: 32) - } else { - Image.asset(.hamburger) - .foregroundStyle(Color.textMain) - .frame(width: 44, height: 44) - } - } - .liquidGlassButtonStyle(shape: .circle) - .accessibilityLabel("Settings") - } - .padding(.horizontal, 20) - } -} diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index 1629978bc..d2a838cc9 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -21,7 +21,7 @@ struct SelectCurrencyScreen: View { private var balances: [ExchangedBalance] { session.balances(for: ratesController.rateForBalanceCurrency()) - .giveable(includingDollars: BetaFlags.shared.allowsDollarsGive) + .giveable() } init( diff --git a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift index 609c3e02f..5d5e50796 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift @@ -289,7 +289,7 @@ final class TipFlow { try? await Task.delay(milliseconds: 750) guard let self, submission != nil else { return } let rate = ratesController.rateForBalanceCurrency() - if let dialog = giveCashGate(session: session, rate: rate, includingDollars: BetaFlags.shared.allowsDollarsGive) + if let dialog = giveCashGate(session: session, rate: rate) .blockingDialog(router: router, addMoneySource: .scanner, context: .sendTips)? .onDismiss(perform: { [weak self] in self?.cancel() }) { session.dialogItem = dialog diff --git a/Flipcash/Core/Screens/Onramp/OnrampVerificationViewModel.swift b/Flipcash/Core/Screens/Onramp/OnrampVerificationViewModel.swift index f794b3cc7..917367ae3 100644 --- a/Flipcash/Core/Screens/Onramp/OnrampVerificationViewModel.swift +++ b/Flipcash/Core/Screens/Onramp/OnrampVerificationViewModel.swift @@ -153,14 +153,14 @@ final class OnrampVerificationViewModel: V // MARK: - Navigation - /// The first screen the verification sheet shows. When both phone and email - /// are required, v2 leads with a combined intro (matching Android); v1 — and - /// any single-step case — goes straight to the needed step (phone if - /// unverified, email otherwise). + /// are required it leads with a combined intro (matching Android); a + /// single-step case goes straight to the needed step (phone if unverified, + /// email otherwise). func initialStep() -> OnrampVerificationPath { let needsPhone = !phoneVerifier.isAlreadyVerified let needsEmail = !emailVerifier.isAlreadyVerified - if needsPhone && needsEmail && BetaFlags.shared.hasEnabled(.newUI) { + if needsPhone && needsEmail { return .intro } if needsPhone { diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index 6687b738b..a8fe47b8d 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -78,7 +78,7 @@ final class SendAmountViewModel { ) { let session = sessionContainer.session let ratesController = sessionContainer.ratesController - let resolved = ratesController.resolveInitialBalance(mint: mint, session: session, includingDollars: BetaFlags.shared.allowsDollarsGive) + let resolved = ratesController.resolveInitialBalance(mint: mint, session: session) self.session = session self.ratesController = ratesController diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 302292ed2..9aa06859f 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -104,12 +104,6 @@ class Session { scanOperation != nil } - /// Whether the Tips tab is available. Always on — Tips shipped out of beta, - /// so the tab shows for every account. - var canUseTips: Bool { - true - } - var hasCoinbaseOnramp: Bool { BetaFlags.shared.hasEnabled(.enableCoinbase) || userFlags?.hasCoinbase == true } @@ -185,7 +179,7 @@ class Session { return (try? database.hasEverAddedMoney()) ?? false } - /// Whether any token balance is non-zero. Unlike ``hasGiveableBalance(for:includingDollars:)`` + /// Whether any token balance is non-zero. Unlike ``hasGiveableBalance(for:)`` /// this asks only whether money is held, not whether it can be spent — dust /// too small to give away still proves money arrived. private var holdsBalance: Bool { @@ -212,14 +206,12 @@ class Session { } /// True when the user has at least one balance with a displayable fiat - /// value that a give, send, or tip can spend. Dollars counts only when - /// `includingDollars` — see `BetaFlags.allowsDollarsGive`. Skips the sort + - /// allocate that `balances(for:)` does, so callers gating a presentation pay - /// only the early-exit predicate cost. - func hasGiveableBalance(for rate: Rate, includingDollars: Bool) -> Bool { + /// value that a give, send, or tip can spend. Skips the sort + allocate that + /// `balances(for:)` does, so callers gating a presentation pay only the + /// early-exit predicate cost. + func hasGiveableBalance(for rate: Rate) -> Bool { updateableBalances.value.contains { stored in - guard stored.mint != .usdf || includingDollars else { return false } - return stored.computeExchangedValue(with: rate).hasDisplayableValue() + stored.computeExchangedValue(with: rate).hasDisplayableValue() } } diff --git a/Flipcash/UI/DialogItem+CashFlows.swift b/Flipcash/UI/DialogItem+CashFlows.swift index 1fc5366b0..05f31770e 100644 --- a/Flipcash/UI/DialogItem+CashFlows.swift +++ b/Flipcash/UI/DialogItem+CashFlows.swift @@ -30,17 +30,6 @@ extension DialogItem { } } - /// Nudges the user toward Discover when they hold USDF but no community - /// currency to give. - static func noCommunityCurrencies(onDiscover: @escaping () -> Void) -> DialogItem { - .info( - title: "No Community Currencies Yet", - subtitle: "Discover and buy a currency, or create your own" - ) { - .standard("Discover Currencies", action: onDiscover); - .cancel() - } - } } extension GiveCashGate { @@ -57,8 +46,6 @@ extension GiveCashGate { switch self { case .proceed: nil - case .discoverCurrencies: - .noCommunityCurrencies { router.present(.discover) } case .addMoney: .noBalance(subtitle: context.noBalanceSubtitle) { router.presentAddMoney(context, source: addMoneySource) diff --git a/Flipcash/UI/SwapAmountHeader.swift b/Flipcash/UI/SwapAmountHeader.swift index 95612261e..86a499b56 100644 --- a/Flipcash/UI/SwapAmountHeader.swift +++ b/Flipcash/UI/SwapAmountHeader.swift @@ -2,7 +2,7 @@ // SwapAmountHeader.swift // Flipcash // -// The top half of the new-UI Convert / Get amount screen: a left-aligned +// The top half of the Convert / Get amount screen: a left-aligned // amount field over an "$X available" hint. Shared by both flows (Convert // swaps it in for its source amount, Get for the payment amount) and dropped // into `EnterAmountView` via its `header` slot, replacing the default centered diff --git a/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift b/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift index a10e3415c..978cb2d36 100644 --- a/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift +++ b/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift @@ -35,16 +35,16 @@ struct AddMoneyRoutingTests { @Test("From inside the buy sheet, Add Money stacks on top — nothing dismisses on entry") func fromBuySheet_stacksOnTop() { let router = AppRouter() - router.present(.discover) + router.present(.give) router.presentNested(.buy(.usdc)) router.presentAddMoney(.buyCurrency, source: .buyShortfall) - #expect(router.presentedSheets == [.discover, .buy(.usdc), .addMoney(.buyCurrency)]) + #expect(router.presentedSheets == [.give, .buy(.usdc), .addMoney(.buyCurrency)]) } @Test("The options over the buy sheet report the buy entry") func isAddMoneyOverBuy_buyEntry() { let router = AppRouter() - router.present(.discover) + router.present(.give) router.presentNested(.buy(.usdc)) router.presentAddMoney(.buyCurrency, source: .buyShortfall) #expect(router.isAddMoneyOverBuy) @@ -52,7 +52,7 @@ struct AddMoneyRoutingTests { @Test( "The options report a non-buy entry everywhere else", - arguments: [AppRouter.SheetPresentation.settings, .balance, .give] + arguments: [AppRouter.SheetPresentation.settings, .give, .tips] ) func isAddMoneyOverBuy_nonBuyEntry(root: AppRouter.SheetPresentation) { let router = AppRouter() @@ -71,10 +71,10 @@ struct AddMoneyRoutingTests { @Test("Re-presenting Add Money with a different context swaps in place") func presentNested_differentContext_swaps() { let router = AppRouter() - router.present(.discover) + router.present(.give) router.presentNested(.addMoney(.buyCurrency)) router.presentNested(.addMoney(.general)) - #expect(router.presentedSheets == [.discover, .addMoney(.general)]) + #expect(router.presentedSheets == [.give, .addMoney(.general)]) } @Test("The addMoney stack has no root sheet — it is nested-only") @@ -85,7 +85,7 @@ struct AddMoneyRoutingTests { @Test("Method selection over buy pops the options and pushes the flow inside the buy sheet") func selectionOverBuy_popsOptionsAndPushesFlow() { let router = AppRouter() - router.present(.discover) + router.present(.give) router.presentNested(.buy(.usdc)) router.presentAddMoney(.buyCurrency, source: .buyShortfall) @@ -93,7 +93,7 @@ struct AddMoneyRoutingTests { router.dismissSheet() router.pushAny(AddMoneyFlowStep.method(.otherWallet)) - #expect(router.presentedSheets == [.discover, .buy(.usdc)]) + #expect(router.presentedSheets == [.give, .buy(.usdc)]) #expect(router[.buy].count == 1, "The deposit flow step must land on the buy sheet's stack") } @@ -108,7 +108,7 @@ struct AddMoneyRoutingTests { @Test("Over the buy sheet the deposit flow targets the buy stack") func addMoneyPushStack_overBuy_usesBuyStack() { let router = AppRouter() - router.present(.discover) + router.present(.give) router.presentNested(.buy(.usdc)) router.presentAddMoney(.buyCurrency, source: .buyShortfall) #expect(router.addMoneyPushStack == .buy) diff --git a/FlipcashTests/AddMoneyGateTests.swift b/FlipcashTests/AddMoneyGateTests.swift index ce2ac7cbe..94098493f 100644 --- a/FlipcashTests/AddMoneyGateTests.swift +++ b/FlipcashTests/AddMoneyGateTests.swift @@ -124,40 +124,30 @@ struct AddMoneyGateTests { // MARK: - Give / send cash - // `includingDollars` is `BetaFlags.allowsDollarsGive` at every call site: - // false on the old UI, which has no Dollars give affordance, true on the new. - - @Test("Give gate proceeds when a community currency is on hand", arguments: [false, true]) - func give_communityCurrency_proceeds(includingDollars: Bool) { + @Test("Give gate proceeds when a community currency is on hand") + func give_communityCurrency_proceeds() { let session = MockSession() session.giveableBalanceExists = true - #expect(giveCashGate(session: session, rate: .oneToOne, includingDollars: includingDollars) == .proceed) - } - - @Test("Give gate routes to Discover when only Dollars is on hand and Dollars can't be given") - func give_usdfOnly_oldUI_discovers() throws { - let session = MockSession() - session.usdfReserveBalance = try makeUSDFBalance(quarks: 1_000_000) - #expect(giveCashGate(session: session, rate: .oneToOne, includingDollars: false) == .discoverCurrencies) + #expect(giveCashGate(session: session, rate: .oneToOne) == .proceed) } - @Test("Give gate proceeds on Dollars alone once Dollars can be given") - func give_usdfOnly_newUI_proceeds() throws { + @Test("Give gate proceeds on Dollars alone") + func give_usdfOnly_proceeds() throws { let session = MockSession() session.usdfReserveBalance = try makeUSDFBalance(quarks: 1_000_000) - #expect(giveCashGate(session: session, rate: .oneToOne, includingDollars: true) == .proceed) + #expect(giveCashGate(session: session, rate: .oneToOne) == .proceed) } - @Test("Give gate routes to Add Money when there is no balance at all", arguments: [false, true]) - func give_noBalance_addsMoney(includingDollars: Bool) { + @Test("Give gate routes to Add Money when there is no balance at all") + func give_noBalance_addsMoney() { let session = MockSession() - #expect(giveCashGate(session: session, rate: .oneToOne, includingDollars: includingDollars) == .addMoney) + #expect(giveCashGate(session: session, rate: .oneToOne) == .addMoney) } - @Test("Give gate treats Dollars that displays as $0.00 as no balance", arguments: [false, true]) - func give_dustUSDF_addsMoney(includingDollars: Bool) throws { + @Test("Give gate treats Dollars that displays as $0.00 as no balance") + func give_dustUSDF_addsMoney() throws { let session = MockSession() session.usdfReserveBalance = try makeUSDFBalance(quarks: 1_000) // $0.001 - #expect(giveCashGate(session: session, rate: .oneToOne, includingDollars: includingDollars) == .addMoney) + #expect(giveCashGate(session: session, rate: .oneToOne) == .addMoney) } } diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift index d3043691a..f9db7144e 100644 --- a/FlipcashTests/Buy/BuyAmountViewModelTests.swift +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -53,15 +53,13 @@ struct BuyAmountViewModelTests { private static func makeViewModel( mint: PublicKey = .jeffy, currencyName: String = "Jeffy", - container: SessionContainer, - collectsUSDFFee: Bool = false + container: SessionContainer ) -> BuyAmountViewModel { BuyAmountViewModel( mint: mint, currencyName: currencyName, session: container.session, - ratesController: container.ratesController, - collectsUSDFFee: collectsUSDFFee + ratesController: container.ratesController ) } @@ -154,7 +152,7 @@ struct BuyAmountViewModelTests { let container = try await Self.makeContainer(holdings: []) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() - router.present(.balance) + router.present(.give) viewModel.primaryAction(router: router) @@ -168,7 +166,7 @@ struct BuyAmountViewModelTests { ]) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(.jeffy)) viewModel.enteredAmount = "10" @@ -186,7 +184,7 @@ struct BuyAmountViewModelTests { ]) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(.jeffy)) viewModel.enteredAmount = "" @@ -213,7 +211,7 @@ struct BuyAmountViewModelTests { let container = try await Self.makeContainer(holdings: [ .init(mint: .usdf, quarks: 10_000_000), // $10.00 ]) - let viewModel = Self.makeViewModel(mint: .usdcAuthority, container: container, collectsUSDFFee: true) + let viewModel = Self.makeViewModel(mint: .usdcAuthority, container: container) viewModel.enteredAmount = "10" viewModel.correctEntryToAffordable() @@ -227,7 +225,7 @@ struct BuyAmountViewModelTests { let container = try await Self.makeContainer(holdings: [ .init(mint: .usdf, quarks: 10_000_000), // $10.00 ]) - let viewModel = Self.makeViewModel(mint: .usdcAuthority, container: container, collectsUSDFFee: true) + let viewModel = Self.makeViewModel(mint: .usdcAuthority, container: container) viewModel.enteredAmount = "5" viewModel.correctEntryToAffordable() @@ -235,19 +233,6 @@ struct BuyAmountViewModelTests { #expect(viewModel.enteredAmount == "5") } - @Test("Without the USDF fee the whole Dollars balance stays spendable") - func wholeDollarsBalance_feeFreeUI_isLeftAlone() async throws { - let container = try await Self.makeContainer(holdings: [ - .init(mint: .usdf, quarks: 10_000_000), // $10.00 - ]) - let viewModel = Self.makeViewModel(mint: .usdcAuthority, container: container, collectsUSDFFee: false) - viewModel.enteredAmount = "10" - - viewModel.correctEntryToAffordable() - - #expect(viewModel.enteredAmount == "10") - } - @Test("Paying the whole token balance drops the entry to what the pool fee leaves") func wholeTokenBalance_isCorrected() async throws { let container = try await Self.makeContainer(holdings: [ diff --git a/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift index bcd54f414..cd5afa020 100644 --- a/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift +++ b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift @@ -71,22 +71,17 @@ struct BuyConfirmationViewModelTests { )) } - /// `collectsUSDFFee` is pinned rather than left to `BetaFlags.shared`, whose - /// persisted value varies by simulator — every case here states the fee - /// branch it means to exercise. private static func makeViewModel( payment: StoredBalance, paymentAmount: ExchangedFiat, - pin: VerifiedState, - collectsUSDFFee: Bool = false + pin: VerifiedState ) -> BuyConfirmationViewModel { BuyConfirmationViewModel( targetMint: .usdcAuthority, targetName: "Moony", payment: payment, paymentAmount: paymentAmount, - pinnedState: pin, - collectsUSDFFee: collectsUSDFFee + pinnedState: pin ) } @@ -109,7 +104,7 @@ struct BuyConfirmationViewModelTests { let viewModel = Self.makeViewModel(payment: jeffyBalance, paymentAmount: paymentAmount, pin: pin) let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(.usdcAuthority)) await viewModel.buyAction(session: container.session, router: router) @@ -121,8 +116,8 @@ struct BuyConfirmationViewModelTests { // MARK: - USDF variant - @Test("Fee-free USDF payments show no fee and amountToBuy equals the payment") - func usdfVariant_noFee() async throws { + @Test("USDF payments buy the entered amount and add the 1% fee on top of the debit") + func usdfVariant_feeOnTop() async throws { let container = try await Self.makeContainer(holdings: [ .init(mint: .usdf, quarks: 30_000_000), ]) @@ -133,15 +128,16 @@ struct BuyConfirmationViewModelTests { let viewModel = Self.makeViewModel(payment: usdfBalance, paymentAmount: paymentAmount, pin: pin) #expect(viewModel.isUSDF) - #expect(!viewModel.chargesFee) + #expect(viewModel.feeBps == 100) + // The entered amount is what gets purchased; the fee is charged on top, + // so only the debit grows. #expect(viewModel.amountToBuy == viewModel.paymentAmount) + #expect(viewModel.grossDebit.onChainAmount.quarks > paymentAmount.onChainAmount.quarks) + #expect(viewModel.grossDebit == paymentAmount.adding(viewModel.fee)) } - @Test( - "An underfunded USDF payment surfaces the insufficient sheet under either fee branch", - arguments: [false, true] - ) - func usdfUnderfunded_showsSheet(collectsUSDFFee: Bool) async throws { + @Test("An underfunded USDF payment surfaces the insufficient sheet") + func usdfUnderfunded_showsSheet() async throws { let container = try await Self.makeContainer(holdings: [ .init(mint: .usdf, quarks: 630_000), // $0.63 ]) @@ -149,14 +145,9 @@ struct BuyConfirmationViewModelTests { let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .usdf)) let paymentAmount = try Self.makePaymentAmount(entered: Decimal(string: "0.74")!, balance: usdfBalance, pin: pin, container: container) - let viewModel = Self.makeViewModel( - payment: usdfBalance, - paymentAmount: paymentAmount, - pin: pin, - collectsUSDFFee: collectsUSDFFee - ) + let viewModel = Self.makeViewModel(payment: usdfBalance, paymentAmount: paymentAmount, pin: pin) let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(.usdcAuthority)) await viewModel.buyAction(session: container.session, router: router) @@ -197,7 +188,7 @@ struct BuyConfirmationViewModelTests { let viewModel = Self.makeViewModel(payment: jeffyBalance, paymentAmount: paymentAmount, pin: pin) let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(.usdcAuthority)) await viewModel.buyAction(session: container.session, router: router) diff --git a/FlipcashTests/Concurrency/AppRouterStressTests.swift b/FlipcashTests/Concurrency/AppRouterStressTests.swift index 893b37464..721f4f92c 100644 --- a/FlipcashTests/Concurrency/AppRouterStressTests.swift +++ b/FlipcashTests/Concurrency/AppRouterStressTests.swift @@ -34,24 +34,24 @@ struct AppRouterStressTests { let router = AppRouter() for _ in 0..<100 { - router.present(.balance) + router.present(.give) router.dismissSheet() } #expect(router.presentedSheet == nil) - #expect(router[.balance].isEmpty) + #expect(router[.give].isEmpty) } /// Cycling through every `SheetPresentation` case mirrors the real - /// "swap between top-level sheets" flow — the user opens Balance, - /// then Settings, then Give, etc., dismissing each in turn. After 100 + /// "swap between top-level sheets" flow — the user opens Settings, + /// then Give, then Tips, etc., dismissing each in turn. After 100 /// rounds the router must be back at no presented sheet with every /// per-stack path empty. @Test("100 rounds across all sheet cases converge on empty state") func cyclingAllSheets_convergesOnEmptyState() { let router = AppRouter() - // `compactMap` skips nested-only stacks (`.buy`) — they can't be - // a root sheet, so they're outside this stress test's scope. + // `compactMap` skips the tab stacks and the nested-only ones + // (`.buy`) — they can't be a root sheet, so they're outside scope. let sheets = AppRouter.Stack.allCases.compactMap(\.sheet) for i in 0..<100 { diff --git a/FlipcashTests/ConvertConfirmationViewModelTests.swift b/FlipcashTests/ConvertConfirmationViewModelTests.swift new file mode 100644 index 000000000..4a134ce04 --- /dev/null +++ b/FlipcashTests/ConvertConfirmationViewModelTests.swift @@ -0,0 +1,283 @@ +// +// ConvertConfirmationViewModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SwiftUI +import FlipcashCore +import FlipcashUI +@testable import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("ConvertConfirmationViewModel") +struct ConvertConfirmationViewModelTests { + + // MARK: - Test Helpers - + + /// USD→CAD rate of 1.35 — native (CAD) is `usdValue * 1.35`, and + /// `usdfValue` is derived back as `nativeAmount / 1.35`. + static let testRate = Rate(fx: 1.35, currency: .cad) + + /// A bonded-mint amount, built directly to bypass the curve (pricing one + /// through `compute` would need a supply that isn't what's under test). + /// + /// - Parameter onChainQuarks: raw token-native quarks (10 decimals). + /// - Parameter nativeCAD: the CAD the amount is worth at ``testRate``. + static func tokenAmount(onChainQuarks: UInt64, nativeCAD: Decimal) -> ExchangedFiat { + ExchangedFiat( + onChainAmount: TokenAmount(quarks: onChainQuarks, mint: .jeffy), + nativeAmount: FiatAmount(value: nativeCAD, currency: .cad), + currencyRate: testRate + ) + } + + /// A USDF amount. USDF bypasses the bonding curve, so `onChainAmount.quarks` + /// is `usdfValue.value * 10^6`. + static func dollarsAmount(onChainQuarks: UInt64 = 10_000_000_000) -> ExchangedFiat { + ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: onChainQuarks, mint: .usdf), + rate: testRate, + supplyQuarks: nil + ) + } + + /// Token → Dollars: the sell path. The pool's fee comes out of the amount. + static func toDollars( + amount: ExchangedFiat? = nil, + sellFeeBps: Int? = 100, + pinnedState: VerifiedState? = nil + ) -> ConvertConfirmationViewModel { + ConvertConfirmationViewModel( + sourceMint: .jeffy, + destinationMint: .usdf, + destinationName: "Dollars", + amount: amount ?? tokenAmount(onChainQuarks: 10_000_000_000, nativeCAD: 13_500), + sellFeeBps: sellFeeBps, + pinnedState: pinnedState ?? .fresh(bonded: false) + ) + } + + /// Dollars → a token: a reserves buy. The flat 1% is added on top. + static func fromDollars( + amount: ExchangedFiat? = nil, + pinnedState: VerifiedState? = nil + ) -> ConvertConfirmationViewModel { + ConvertConfirmationViewModel( + sourceMint: .usdf, + destinationMint: .jeffy, + destinationName: "Jeffy", + amount: amount ?? dollarsAmount(), + sellFeeBps: nil, + pinnedState: pinnedState ?? .fresh(bonded: false) + ) + } + + // MARK: - Initialization - + + @Test("A fresh view model starts idle with nothing to dismiss") + func initialization_defaultValues() { + let viewModel = Self.toDollars() + + #expect(viewModel.actionButtonState == .normal) + #expect(viewModel.dialogItem == nil) + #expect(viewModel.canDismissSheet == false) + } + + @Test("Direction flags follow the source and destination mints") + func directionFlags() { + #expect(Self.toDollars().isToDollars) + #expect(!Self.toDollars().isFromDollars) + #expect(Self.fromDollars().isFromDollars) + #expect(!Self.fromDollars().isToDollars) + } + + // MARK: - Fee - + + @Test("The fee is 1% of the on-chain amount") + func fee_calculatesOnePercent() { + // 10,000 whole tokens at 13,500 CAD. 1% → 100 tokens / 135 CAD. + let viewModel = Self.toDollars( + amount: Self.tokenAmount(onChainQuarks: 10_000_000_000, nativeCAD: 13_500) + ) + + let fee = viewModel.fee + + // Token-native math: 10_000_000_000 * 100 / 10_000 = 100_000_000 quarks + #expect(fee.onChainAmount.quarks == 100_000_000) + #expect(fee.nativeAmount.value == 135) + // usdfValue is derived back through the rate: 135 / 1.35 = 100 + #expect(fee.usdfValue.value == 100) + } + + @Test("A large amount's fee stays exact — no overflow in the split multiply") + func fee_largeAmount_calculatesCorrectly() { + let viewModel = Self.toDollars( + amount: Self.tokenAmount(onChainQuarks: 1_000_000_000_000, nativeCAD: 1_350_000) + ) + + let fee = viewModel.fee + + #expect(fee.onChainAmount.quarks == 10_000_000_000) + #expect(fee.nativeAmount.value == 13_500) + #expect(fee.usdfValue.value == 10_000) + } + + @Test("A fee below one quark rounds down to zero on both sides") + func fee_smallAmount_roundsDown() { + // 50 quarks * 100 / 10_000 = 0 (integer division rounds down) + let viewModel = Self.toDollars( + amount: Self.tokenAmount(onChainQuarks: 50, nativeCAD: Decimal(string: "0.0000000675")!) + ) + + let fee = viewModel.fee + + #expect(fee.onChainAmount.quarks == 0) + #expect(fee.nativeAmount.value == 0) + } + + @Test("The fee carries the amount's mint, currency, and rate") + func fee_preservesCurrencyMetadata() { + let amount = Self.tokenAmount(onChainQuarks: 10_000_000_000, nativeCAD: 13_500) + let viewModel = Self.toDollars(amount: amount) + + let fee = viewModel.fee + + #expect(fee.nativeAmount.currency == amount.nativeAmount.currency) + #expect(fee.currencyRate.currency == amount.currencyRate.currency) + #expect(fee.mint == amount.mint) + } + + @Test("The fee's native side scales by the exact on-chain ratio") + func fee_scalesNativeProportionally() { + // 5 whole Jeffy (10 decimals) at $13.50 CAD. + let viewModel = Self.toDollars( + amount: Self.tokenAmount(onChainQuarks: 50_000_000_000, nativeCAD: Decimal(string: "13.50")!) + ) + + let fee = viewModel.fee + + // 50_000_000_000 * 100 / 10_000 = 500_000_000 Jeffy quarks + #expect(fee.onChainAmount.quarks == 500_000_000) + #expect(fee.onChainAmount.mint == .jeffy) + // Native scaled by 500M / 50B = 0.01 → 13.50 * 0.01 = 0.135 CAD + #expect(fee.nativeAmount.value == Decimal(string: "0.135")!) + #expect(fee.nativeAmount.currency == .cad) + } + + @Test("The source pool's sell fee drives the rate, not a hardcoded 1%") + func fee_usesSourceSellFeeBps() { + let amount = Self.tokenAmount(onChainQuarks: 10_000_000_000, nativeCAD: 13_500) + + // 250 bps = 2.5% + #expect(Self.toDollars(amount: amount, sellFeeBps: 250).fee.onChainAmount.quarks == 250_000_000) + // A missing bps falls back to 1%. + #expect(Self.toDollars(amount: amount, sellFeeBps: nil).fee.onChainAmount.quarks == 100_000_000) + // A nonsensical negative bps clamps to no fee rather than trapping. + #expect(Self.toDollars(amount: amount, sellFeeBps: -5).fee.onChainAmount.quarks == 0) + } + + @Test("Converting from Dollars charges a flat 1% regardless of the destination pool") + func fee_fromDollars_isFlatOnePercent() { + // 10,000 USDF (6 decimals) → 100 USDF fee. + let viewModel = Self.fromDollars(amount: Self.dollarsAmount(onChainQuarks: 10_000_000_000)) + + let fee = viewModel.fee + + #expect(fee.onChainAmount.quarks == 100_000_000) + #expect(fee.mint == .usdf) + } + + // MARK: - Fee formatting - + + @Test("A fee of exactly zero formats without the tilde") + func feeFormatted_zeroOnChainFee_dropsTildePrefix() { + // 1% of 50 quarks rounds to 0 — the fee is literally zero, so $0.00, + // not ~$0.00. + let viewModel = Self.toDollars( + amount: Self.tokenAmount(onChainQuarks: 50, nativeCAD: Decimal(string: "0.0000000675")!) + ) + + #expect(!viewModel.feeFormatted.contains("~")) + } + + @Test("A non-zero but sub-cent fee keeps the tilde") + func feeFormatted_nonZeroButSubCentFee_keepsTildePrefix() { + // 1% of 100 quarks is 1 quark — non-zero, but far below CAD's display + // precision. This is the "~$0.00" case. + let viewModel = Self.toDollars( + amount: Self.tokenAmount(onChainQuarks: 100, nativeCAD: Decimal(string: "0.000000135")!) + ) + + #expect(viewModel.feeFormatted.contains("~")) + } + + // MARK: - Amount after fee / total debited - + + @Test("Converting to Dollars nets the amount minus the fee, and debits the amount") + func amountAfterFee_toDollars_subtractsFee() { + let amount = Self.tokenAmount(onChainQuarks: 10_000_000_000, nativeCAD: 13_500) + let viewModel = Self.toDollars(amount: amount) + + // 10_000_000_000 - 100_000_000 = 9_900_000_000 quarks + #expect(viewModel.amountAfterFee.onChainAmount.quarks == 9_900_000_000) + #expect(viewModel.amountAfterFee.nativeAmount.value == 13_365) + #expect(viewModel.amountAfterFee.usdfValue.value == 9_900) + // The entered amount already is the debit — nothing is added on top. + #expect(viewModel.totalDebited.onChainAmount.quarks == amount.onChainAmount.quarks) + } + + @Test("Converting from Dollars receives the amount in full and debits amount + fee") + func amountAfterFee_fromDollars_addsFeeOnTop() { + let amount = Self.dollarsAmount(onChainQuarks: 10_000_000_000) + let viewModel = Self.fromDollars(amount: amount) + + #expect(viewModel.amountAfterFee.onChainAmount.quarks == amount.onChainAmount.quarks, + "the on-top fee must not shrink what the user receives") + #expect(viewModel.totalDebited.onChainAmount.quarks == 10_100_000_000) + #expect(viewModel.totalDebited == amount.adding(viewModel.fee)) + } + + @Test("The netted amount keeps the source mint") + func amountAfterFee_preservesMint() { + #expect(Self.toDollars().amountAfterFee.mint == .jeffy) + #expect(Self.fromDollars().amountAfterFee.mint == .usdf) + } + + @Test("A near-max amount's fee does not overflow") + func fee_maxUInt64_doesNotOverflow() { + // The split multiply in launchpadSellFee has to hold at 10-decimal + // launchpad scale, where quarks × bps would overflow UInt64. + let safeMax = UInt64.max / 100 + let viewModel = Self.toDollars( + amount: Self.tokenAmount(onChainQuarks: safeMax, nativeCAD: 13_500) + ) + + #expect(viewModel.fee.onChainAmount.quarks == safeMax * 100 / 10_000) + } + + // MARK: - Pinned state - + + @Test("canPerformAction is false when pinnedState is stale") + func canPerformAction_stalePinnedState_returnsFalse() { + #expect(Self.toDollars(pinnedState: .stale()).canPerformAction == false) + } + + @Test("canPerformAction is true when pinnedState is fresh") + func canPerformAction_freshPinnedState_returnsTrue() { + #expect(Self.toDollars(pinnedState: .fresh()).canPerformAction == true) + } + + // MARK: - Dialogs - + + @Test("A dialog set on the view model surfaces") + func dialogItem_canBeSet() { + let viewModel = Self.toDollars() + + viewModel.dialogItem = .success(title: "Test", subtitle: "Test subtitle") + + #expect(viewModel.dialogItem?.title == "Test") + } +} diff --git a/FlipcashTests/CurrencySellConfirmationViewModelTests.swift b/FlipcashTests/CurrencySellConfirmationViewModelTests.swift deleted file mode 100644 index dbd3b7093..000000000 --- a/FlipcashTests/CurrencySellConfirmationViewModelTests.swift +++ /dev/null @@ -1,304 +0,0 @@ -// -// CurrencySellConfirmationViewModelTests.swift -// FlipcashTests -// -// Created by Raul Riera on 2025-12-30. -// - -import Foundation -import Testing -import SwiftUI -import FlipcashCore -import FlipcashUI -@testable import FlipcashCore -@testable import Flipcash - -@MainActor -struct CurrencySellConfirmationViewModelTests { - - // MARK: - Test Helpers - - - /// USD→CAD rate of 1.35 — native (CAD) is derived as `usdValue * 1.35`. - static let testRate = Rate(fx: 1.35, currency: .cad) - - /// Helper to create ExchangedFiat for testing. USDF-minted fixtures bypass - /// the bonding curve so `onChainAmount.quarks` equals `usdfValue.value * 10^6` - /// and `nativeAmount.value` equals `usdfValue.value * rate.fx`. - /// - /// - Parameter onChainQuarks: raw token-native integer going into - /// `onChainAmount.quarks`. For USDF this is 6-decimal USD quarks; for a - /// bonded mint this is 10-decimal token quarks. - static func createExchangedFiat( - onChainQuarks: UInt64 = 10_000_000_000, // 10,000 USDF (6 decimals) - mint: PublicKey = .usdf - ) -> ExchangedFiat { - ExchangedFiat.compute( - onChainAmount: TokenAmount(quarks: onChainQuarks, mint: mint), - rate: testRate, - supplyQuarks: nil - ) - } - - /// Helper to create a test view model - static func createViewModel( - mint: PublicKey = .usdf, - amount: ExchangedFiat? = nil, - pinnedState: VerifiedState? = nil - ) -> CurrencySellConfirmationViewModel { - let exchangedFiat = amount ?? createExchangedFiat(mint: mint) - return CurrencySellConfirmationViewModel( - mint: mint, - amount: exchangedFiat, - pinnedState: pinnedState ?? .fresh(bonded: false) - ) - } - - // 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.dialogItem == nil) - #expect(viewModel.canDismissSheet == false) - } - - // MARK: - Fee Calculation Tests - - - @Test - func testFee_CalculatesOnePercent() { - // Given: 10,000 USDF on-chain. 1% fee → 100 USDF. - let amount = Self.createExchangedFiat( - onChainQuarks: 10_000_000_000 // 10,000 USDF (6 decimals) - ) - let viewModel = Self.createViewModel(amount: amount) - - // When: Getting fee - let fee = viewModel.fee - - // Then: Fee token-native math: 10_000_000_000 * 100 / 10_000 = 100_000_000 quarks - #expect(fee.onChainAmount.quarks == 100_000_000) - // USDF bypasses the bonding curve, so usdfValue == onChainAmount.decimalValue. - #expect(fee.usdfValue.value == 100) - // Native at rate 1.35 CAD/USD: 100 * 1.35 = 135 CAD - #expect(fee.nativeAmount.value == 135) - } - - @Test - func testFee_LargeAmount_CalculatesCorrectly() { - // Given: 1,000,000 USDF on-chain. 1% fee → 10,000 USDF. - let amount = Self.createExchangedFiat( - onChainQuarks: 1_000_000_000_000 // 1,000,000 USDF (6 decimals) - ) - let viewModel = Self.createViewModel(amount: amount) - - // When: Getting fee - let fee = viewModel.fee - - // Then: 1% fee in raw token quarks. - #expect(fee.onChainAmount.quarks == 10_000_000_000) // 10,000 USDF - #expect(fee.usdfValue.value == 10_000) - #expect(fee.nativeAmount.value == 13_500) // 10,000 * 1.35 CAD - } - - @Test - func testFee_SmallAmount_RoundsDown() { - // Given: Small amount where 1% would be fractional. - // 50 quarks * 100 / 10_000 = 0 (integer division rounds down) - let amount = Self.createExchangedFiat(onChainQuarks: 50) - let viewModel = Self.createViewModel(amount: amount) - - // When: Getting fee - let fee = viewModel.fee - - // Then: Fee rounds down to 0 quarks. - #expect(fee.onChainAmount.quarks == 0) - #expect(fee.usdfValue.value == 0) - #expect(fee.nativeAmount.value == 0) - } - - @Test - func testFee_PreservesCurrencyMetadata() { - // Given: Amount with specific currency / mint. - let amount = Self.createExchangedFiat() - let viewModel = Self.createViewModel(amount: amount) - - // When: Getting fee - let fee = viewModel.fee - - // Then: Currency / rate / mint should be preserved through compute(). - #expect(fee.nativeAmount.currency == amount.nativeAmount.currency) - #expect(fee.currencyRate.currency == amount.currencyRate.currency) - #expect(fee.mint == amount.mint) - } - - @Test - func testFee_BondedMint_ScalesNativeProportionally() { - // Given: A bonded-mint amount of 5 whole Jeffy at $13.50 CAD native. - // Construct directly to bypass the curve (which would need supply). - let amount = ExchangedFiat( - onChainAmount: TokenAmount(quarks: 50_000_000_000, mint: .jeffy), // 5 Jeffy at 10 decimals - nativeAmount: FiatAmount(value: Decimal(string: "13.50")!, currency: .cad), - currencyRate: Self.testRate - ) - let viewModel = Self.createViewModel(mint: .jeffy, amount: amount) - - // When: Getting fee - let fee = viewModel.fee - - // Then: On-chain side: 50_000_000_000 * 100 / 10_000 = 500_000_000 Jeffy quarks - #expect(fee.onChainAmount.quarks == 500_000_000) - #expect(fee.onChainAmount.mint == .jeffy) - // Native side: scaled by the exact on-chain ratio (500M / 50B = 0.01). - // 13.50 CAD * 0.01 = 0.135 CAD - #expect(fee.nativeAmount.value == Decimal(string: "0.135")!) - #expect(fee.nativeAmount.currency == .cad) - } - - @Test - func testFeeFormatted_ZeroOnChainFee_DropsTildePrefix() { - // Given: Amount small enough that 1% on-chain rounds to 0 quarks. - // The fee is literally zero — display should be $0.00, NOT ~$0.00. - let amount = Self.createExchangedFiat(onChainQuarks: 50) - let viewModel = Self.createViewModel(amount: amount) - - // When: Formatting the fee - let formatted = viewModel.feeFormatted - - // Then: No tilde — fee is exactly zero, not approximately zero. - #expect(!formatted.contains("~")) - } - - @Test - func testFeeFormatted_NonZeroButSubCentFee_KeepsTildePrefix() { - // Given: On-chain fee is 1+ quarks (non-zero) but converts to a - // sub-cent native amount (e.g. 100 USDF quarks → 1 quark fee → tiny CAD). - // This is the "~$0.00" display case — the fee *exists*, just below - // the currency's display precision. - let amount = Self.createExchangedFiat(onChainQuarks: 100) - let viewModel = Self.createViewModel(amount: amount) - - // When: Formatting the fee - let formatted = viewModel.feeFormatted - - // Then: Tilde present — non-zero but approximately zero. - #expect(formatted.contains("~")) - } - - // MARK: - Amount After Fee Tests - - - @Test - func testAmountAfterFee_SubtractsFeeCorrectly() { - // Given: 10,000 USDF on-chain, 1% fee → 9,900 USDF remaining. - let amount = Self.createExchangedFiat( - onChainQuarks: 10_000_000_000 - ) - let viewModel = Self.createViewModel(amount: amount) - - // When: Getting amount after fee - let afterFee = viewModel.amountAfterFee - - // Then: 10_000_000_000 - 100_000_000 = 9_900_000_000 quarks (9,900 USDF) - #expect(afterFee.onChainAmount.quarks == 9_900_000_000) - #expect(afterFee.usdfValue.value == 9_900) - // 9,900 * 1.35 = 13,365 CAD - #expect(afterFee.nativeAmount.value == 13_365) - } - - // MARK: - Sheet Dismissal Tests - - - @Test - func testCanDismissSheet_FalseAlways() { - // Given: View model - let viewModel = Self.createViewModel() - - // Then: Should prevent dismissal - #expect(viewModel.canDismissSheet == false) - } - - - // MARK: - Button State Tests - - - @Test - func testActionButtonState_InitiallyNormal() { - // Given/When: Fresh view model - let viewModel = Self.createViewModel() - - // Then: Should be normal - #expect(viewModel.actionButtonState == .normal) - } - - // MARK: - Dialog State Tests - - - @Test - func testDialogItem_InitiallyNil() { - // Given/When: Fresh view model - let viewModel = Self.createViewModel() - - // Then: Should have no dialog - #expect(viewModel.dialogItem == nil) - } - - @Test - func testDialogItem_CanBeSet() { - // Given: View model - let viewModel = Self.createViewModel() - - // When: Setting a dialog - viewModel.dialogItem = .success(title: "Test", subtitle: "Test subtitle") - - // Then: Should have dialog - #expect(viewModel.dialogItem != nil) - #expect(viewModel.dialogItem?.title == "Test") - } - - // MARK: - Edge Cases - - - @Test - func testFee_MaxUInt64_DoesNotOverflow() { - // Given: Very large but safe amount (avoiding overflow in multiplication) - // Max safe: UInt64.max / 100 to avoid overflow - let safeMax = UInt64.max / 100 - let amount = Self.createExchangedFiat(onChainQuarks: safeMax) - let viewModel = Self.createViewModel(amount: amount) - - // When: Getting fee - let fee = viewModel.fee - - // Then: Should calculate without overflow - let expectedFee = safeMax * 100 / 10_000 - #expect(fee.onChainAmount.quarks == expectedFee) - } - - @Test - func testAmountAfterFee_PreservesMint() { - // Given: Amount with specific mint - let mint = PublicKey.usdf - let viewModel = Self.createViewModel(mint: mint) - - // When: Getting amount after fee - let afterFee = viewModel.amountAfterFee - - // Then: Mint should be preserved - #expect(afterFee.mint == mint) - } - - // MARK: - Pinned State Tests - - - @Test("canPerformAction is false when pinnedState is stale") - func canPerformAction_stalePinnedState_returnsFalse() { - let viewModel = Self.createViewModel(pinnedState: .stale()) - - #expect(viewModel.canPerformAction == false) - } - - @Test("canPerformAction is true when pinnedState is fresh") - func canPerformAction_freshPinnedState_returnsTrue() { - let viewModel = Self.createViewModel(pinnedState: .fresh()) - - #expect(viewModel.canPerformAction == true) - } -} diff --git a/FlipcashTests/DialogItemFactoryTests.swift b/FlipcashTests/DialogItemFactoryTests.swift index 72168d69d..a31a7d104 100644 --- a/FlipcashTests/DialogItemFactoryTests.swift +++ b/FlipcashTests/DialogItemFactoryTests.swift @@ -68,17 +68,6 @@ struct DialogItemFactoryTests { #expect(dismissableSuccess.dismissable == true) } - @Test(".noCommunityCurrencies routes to Discover with a Cancel escape") - func noCommunityCurrencies_discoverCTA() { - let item = DialogItem.noCommunityCurrencies(onDiscover: {}) - #expect(item.title == "No Community Currencies Yet") - #expect(item.subtitle == "Discover and buy a currency, or create your own") - #expect(item.style == .standard) - #expect(item.actions.count == 2) - #expect(item.actions[0].title == "Discover Currencies") - #expect(item.actions[1].title == "Cancel") - } - @Test( ".contactsOnFlipcash pluralizes Contact/Contacts by count", arguments: [ diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index cd72387f8..82a532838 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -14,55 +14,61 @@ import FlipcashCore @Suite("AppRouter Cross-Stack Navigation") struct AppRouterCrossStackTests { - @Test("From cold state, navigate opens owning sheet with destination on top") - func navigate_fromColdState_opensOwningStack() { + @Test("From cold state, navigate to a tab-owned destination requests its tab") + func navigate_fromColdState_requestsOwningTab() { let router = AppRouter() router.navigate(to: .currencyInfo(.usdc)) - #expect(router.presentedSheet == .balance) + #expect(router.presentedSheets.isEmpty, "a tab stack is reached by bringing its tab forward") + #expect(router.requestedTabStack == .balance) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) } - @Test("Navigating to a destination on a different stack swaps the sheet") - func navigate_acrossStacks_swapsSheet() { + @Test("Navigating to a tab-owned destination dismisses whatever sheet is up") + func navigate_fromSheetToTabStack_dismissesSheet() { let router = AppRouter() router.present(.settings) router.setPath([.settingsMyAccount, .settingsAdvancedFeatures], on: .settings) router.navigate(to: .currencyInfo(.usdc)) - #expect(router.presentedSheet == .balance) + #expect(router.presentedSheets.isEmpty) + #expect(router.requestedTabStack == .balance) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) } - @Test("Sheet swap preserves the other stack's path for return trips") - func navigate_acrossStacks_preservesOtherStackPath() { + @Test("Routing to a tab-hosted stack leaves the other tabs' paths alone") + func navigate_toTabStack_preservesOtherTabPaths() { let router = AppRouter() - router.present(.settings) - let settingsPath: [AppRouter.Destination] = [.settingsMyAccount, .settingsAdvancedFeatures] - router.setPath(settingsPath, on: .settings) + router.activeTabStack = .balance + router.setPath([.currencyInfo(.usdc), .transactionHistory(.usdc)], on: .balance) - router.navigate(to: .currencyInfo(.usdc)) + router.navigate(to: .tipcard) - #expect(router[.settings] == AppRouter.navigationPath(.settingsMyAccount, .settingsAdvancedFeatures), - "settings path must survive the sheet swap") + #expect(router.requestedTabStack == .tips, "the Chat tab hosts the tips stack") + #expect(router.presentedSheets.isEmpty, "the tips sheet is for surfaces that have no tab bar") + #expect(router[.tips] == AppRouter.navigationPath(.tipcard)) + #expect( + router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc), .transactionHistory(.usdc)), + "the wallet tab is untouched behind the tab switch" + ) } @Test("Same-stack navigate replaces the path on that stack") func navigate_sameStack_replacesPath() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.setPath([.currencyInfo(.usdc), .transactionHistory(.usdc)], on: .balance) router.navigate(to: .currencyInfo(.usdf)) - #expect(router.presentedSheet == .balance) + #expect(router.presentedSheets.isEmpty) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdf))) } - @Test("Push notification routing to a settings destination from balance swaps to settings") - func navigate_fromBalanceToSettingsDestination_swapsToSettings() { + @Test("Push notification routing to a settings destination from the wallet tab presents settings") + func navigate_fromWalletTabToSettingsDestination_presentsSettings() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.setPath([.currencyInfo(.usdc)], on: .balance) router.navigate(to: .settingsApplicationLogs) @@ -70,15 +76,22 @@ struct AppRouterCrossStackTests { #expect(router.presentedSheet == .settings) #expect(router[.settings] == AppRouter.navigationPath(.settingsApplicationLogs)) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc)), - "balance preserved underneath") + "the wallet tab is preserved underneath") } @Test("Navigate is idempotent when target state already matches current state") func navigate_isIdempotent() { let router = AppRouter() router.navigate(to: .currencyInfo(.usdc)) + + // Stand in for HomeTabView consuming the request and reporting the + // selection back; without it the router can't know the tab is already up. + router.activeTabStack = .balance + router.requestedTabStack = nil + router.navigate(to: .currencyInfo(.usdc)) - #expect(router.presentedSheet == .balance) + + #expect(router.requestedTabStack == nil, "a redundant navigate must not re-request the tab") #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) } @@ -133,17 +146,28 @@ struct AppRouterCrossStackTests { @Test( "Stack maps to its sheet presentation", arguments: [ - (AppRouter.Stack.balance, AppRouter.SheetPresentation.balance), (AppRouter.Stack.settings, AppRouter.SheetPresentation.settings), (AppRouter.Stack.give, AppRouter.SheetPresentation.give), + (AppRouter.Stack.tips, AppRouter.SheetPresentation.tips), ] ) 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) + @Test( + "Stacks with no root sheet of their own", + arguments: [AppRouter.Stack.balance, .you, .buy, .addMoney, .sendAmount] + ) + func stack_withoutSheet_isNil(_ stack: AppRouter.Stack) { + #expect(stack.sheet == nil, "\(stack) is either a tab stack or nested-only") + } + + @Test("isTabHosted tracks the tab bar's stacks exactly") + func tabHostedStacks_matchHomeTabs() { + #expect( + Set(AppRouter.Stack.allCases.filter(\.isTabHosted)) == Set(HomeTab.allCases.compactMap(\.pushStack)), + "AppRouter.Stack.isTabHosted is what navigate(to:) routes on — it must track HomeTab.pushStack" + ) } } diff --git a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift index 5d16e2300..619ba356e 100644 --- a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift +++ b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift @@ -30,22 +30,22 @@ struct AppRouterNestedSheetTests { @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) + router.present(.give) + #expect(router.presentedSheets == [.give]) + #expect(router.presentedSheet == .give) + #expect(router.rootSheet == .give) } @Test("presentNested appends on top of root") func presentNested_appendsOnRoot() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) - #expect(router.presentedSheets == [.balance, .buy(Self.mintA)]) + #expect(router.presentedSheets == [.give, .buy(Self.mintA)]) #expect(router.presentedSheet == .buy(Self.mintA)) - #expect(router.rootSheet == .balance) + #expect(router.rootSheet == .give) } @Test("presentNested with empty stack is a no-op") @@ -58,25 +58,25 @@ struct AppRouterNestedSheetTests { @Test("presentNested idempotent when same sheet already on top") func presentNested_idempotent_onSameTop() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.presentNested(.buy(Self.mintA)) - #expect(router.presentedSheets == [.balance, .buy(Self.mintA)]) + #expect(router.presentedSheets == [.give, .buy(Self.mintA)]) } @Test("presentNested same case different payload swaps the top") func presentNested_sameCaseDifferentPayload_swaps() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.push(.usdcDepositEducation) router.presentNested(.buy(Self.mintB)) // Same case different payload → swap (not stack). - #expect(router.presentedSheets == [.balance, .buy(Self.mintB)]) + #expect(router.presentedSheets == [.give, .buy(Self.mintB)]) #expect(router[.buy].isEmpty, "swap to a different .buy payload must drop the displaced sheet's stack contents") } @@ -84,12 +84,12 @@ struct AppRouterNestedSheetTests { @Test("presentNested same case different payload — no prior pushed content — swaps cleanly") func presentNested_sameCaseDifferentPayload_noPriorPath_swaps() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.presentNested(.buy(Self.mintB)) - #expect(router.presentedSheets == [.balance, .buy(Self.mintB)]) + #expect(router.presentedSheets == [.give, .buy(Self.mintB)]) #expect(router[.buy].isEmpty, "no path was set; the new top should sit at root of the buy stack") } @@ -98,19 +98,19 @@ struct AppRouterNestedSheetTests { @Test("dismissSheet pops topmost when nested is up") func dismissSheet_withNested_popsTopmost() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.dismissSheet() - #expect(router.presentedSheets == [.balance]) - #expect(router.presentedSheet == .balance) + #expect(router.presentedSheets == [.give]) + #expect(router.presentedSheet == .give) } @Test("dismissSheet pops root when only root remains") func dismissSheet_onRootOnly_clearsAll() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.dismissSheet() @@ -121,11 +121,11 @@ struct AppRouterNestedSheetTests { @Test("dismissSheet sequence pops one level at a time") func dismissSheet_sequence_popsLevels() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.dismissSheet() - #expect(router.presentedSheets == [.balance]) + #expect(router.presentedSheets == [.give]) router.dismissSheet() #expect(router.presentedSheets.isEmpty) @@ -134,19 +134,19 @@ struct AppRouterNestedSheetTests { @Test("dismissSheet then push lands the destination on the underlying stack") func dismissSheet_thenPush_landsOnUnderlyingStack() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(Self.mintA)) + router.present(.give) + router.push(.currencyInfoForDeposit(Self.mintA)) router.presentNested(.buy(Self.mintA)) router.dismissSheet() router.push(.usdcDepositEducation) - #expect(router.presentedSheets == [.balance]) - #expect(router[.balance] == AppRouter.navigationPath( - .currencyInfo(Self.mintA), + #expect(router.presentedSheets == [.give]) + #expect(router[.give] == AppRouter.navigationPath( + .currencyInfoForDeposit(Self.mintA), .usdcDepositEducation ), - "the post-dismiss push must land on .balance on top of the existing CurrencyInfo") + "the post-dismiss push must land on .give on top of the existing CurrencyInfo") #expect(router[.buy].isEmpty) } @@ -155,7 +155,7 @@ struct AppRouterNestedSheetTests { @Test("present(.differentRoot) when nested is up clears everything and sets new root") func present_differentRoot_clearsAll() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.present(.settings) @@ -166,32 +166,32 @@ struct AppRouterNestedSheetTests { @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.present(.give) + router.push(.currencyInfoForDeposit(Self.mintA)) router.presentNested(.buy(Self.mintA)) - router.present(.balance) + router.present(.give) - #expect(router.presentedSheets == [.balance]) + #expect(router.presentedSheets == [.give]) // Root path is preserved because root wasn't dismissed. - #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(Self.mintA))) + #expect(router[.give] == AppRouter.navigationPath(.currencyInfoForDeposit(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 stack now flagged dismissed + router.present(.give) + router.push(.currencyInfoForDeposit(Self.mintA)) + router.dismissSheet() // .give stack now flagged dismissed router.present(.settings) router.presentNested(.buy(Self.mintA)) - // Now re-present .balance — should clear its stale path. - router.present(.balance) + // Now re-present .give — should clear its stale path. + router.present(.give) - #expect(router.presentedSheets == [.balance]) - #expect(router[.balance].isEmpty, + #expect(router.presentedSheets == [.give]) + #expect(router[.give].isEmpty, "presenting a previously dismissed root after nesting still clears its path") } @@ -200,7 +200,7 @@ struct AppRouterNestedSheetTests { @Test("dismissSheet + presentNested(.same) clears the nested sheet's path") func dismissNested_thenPresentNestedSame_clearsPath() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.push(.usdcDepositEducation) @@ -214,14 +214,14 @@ struct AppRouterNestedSheetTests { @Test("dismissSheet + presentNested(.differentPayload) clears the nested sheet's path") func dismissNested_thenPresentNestedDifferentPayload_clearsPath() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.push(.usdcDepositEducation) // stand-in for a leaf pushed during the buy flow router.dismissSheet() router.presentNested(.buy(Self.mintB)) - #expect(router.presentedSheets == [.balance, .buy(Self.mintB)]) + #expect(router.presentedSheets == [.give, .buy(Self.mintB)]) #expect(router[.buy].isEmpty, "re-opening .buy with a different mint must land at the amount-entry root") } @@ -229,13 +229,13 @@ struct AppRouterNestedSheetTests { @Test("nested swipe-down + reopen still clears path") func dismissNested_thenReopenAfterIntermediate_stillClearsPath() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.push(.usdcDepositEducation) router.dismissSheet() // .buy dismissed - router.dismissSheet() // .balance dismissed + router.dismissSheet() // .give dismissed - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) #expect(router[.buy].isEmpty) @@ -246,7 +246,7 @@ struct AppRouterNestedSheetTests { @Test("navigate(to:) when nested is up dismisses nested and sets target root") func navigate_dismissesNestedAndSetsRoot() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.navigate(to: .settingsApplicationLogs) @@ -255,16 +255,16 @@ struct AppRouterNestedSheetTests { #expect(router[.settings] == AppRouter.navigationPath(.settingsApplicationLogs)) } - @Test("navigate(to:) on same-root destination while nested is up pops nested") - func navigate_sameRoot_popsNested() { + @Test("navigate(to:) on a tab-stack destination while nested is up dismisses every sheet") + func navigate_tabStackDestination_dismissesEverySheet() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(Self.mintA)) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.navigate(to: .currencyInfo(Self.mintB)) - #expect(router.presentedSheets == [.balance]) + #expect(router.presentedSheets.isEmpty, "a tab stack is reached by bringing its tab forward") + #expect(router.requestedTabStack == .balance) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(Self.mintB))) } @@ -273,26 +273,26 @@ struct AppRouterNestedSheetTests { @Test("push lands on the nested sheet's stack when nested is up") func push_landsOnNestedStack() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(Self.mintA)) router.push(.usdcDepositEducation) #expect(router[.buy].count == 1, "pushes target the topmost stack") - #expect(router[.balance].isEmpty, "root stack stays clean") + #expect(router[.give].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.present(.give) 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") + #expect(router[.give].isEmpty, "give stack stays clean") } // MARK: - Buy sheet wiring diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift index 4514a285a..ca8f13725 100644 --- a/FlipcashTests/Navigation/AppRouterTests.swift +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -19,7 +19,7 @@ struct AppRouterTests { @Test("push appends destination to the stack") func push_appendsDestination() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.discoverCurrencies) #expect(router[.balance] == AppRouter.navigationPath(.discoverCurrencies)) } @@ -27,7 +27,7 @@ struct AppRouterTests { @Test("push appends in order across multiple calls") func push_appendsInOrder() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.push(.transactionHistory(.usdc)) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc), .transactionHistory(.usdc))) @@ -36,7 +36,7 @@ struct AppRouterTests { @Test("pop removes the top destination") func pop_removesTop() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.push(.transactionHistory(.usdc)) router.pop(on: .balance) @@ -50,10 +50,10 @@ struct AppRouterTests { #expect(router[.balance].isEmpty) } - @Test("popTopmost pops the topmost-sheet stack") + @Test("popTopmost pops the topmost stack — the active tab's, with no sheet up") func popTopmost_popsTopmostSheetStack() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.push(.transactionHistory(.usdc)) router.popTopmost() @@ -63,18 +63,18 @@ struct AppRouterTests { @Test("popTopmost on a nested sheet leaves the root stack untouched") func popTopmost_nested_doesNotTouchRoot() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(.usdc)) + router.present(.give) + router.push(.currencyInfoForDeposit(.usdc)) router.presentNested(.buy(.usdc)) router.push(.usdcDepositEducation) router.popTopmost() #expect(router[.buy].isEmpty) - #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + #expect(router[.give] == AppRouter.navigationPath(.currencyInfoForDeposit(.usdc))) } - @Test("popTopmost is a no-op with no sheet presented") + @Test("popTopmost is a no-op with no sheet presented and no active tab") func popTopmost_noSheet_isNoop() { let router = AppRouter() router.popTopmost() @@ -84,7 +84,7 @@ struct AppRouterTests { @Test("replaceTopmostAny swaps the top destination for a new value") func replaceTopmost_swapsTopDestination() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.push(.transactionHistory(.usdc)) @@ -96,28 +96,28 @@ struct AppRouterTests { @Test("replaceTopmostAny on a nested sheet leaves the root stack untouched") func replaceTopmost_nested_doesNotTouchRoot() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(.usdc)) + router.present(.give) + router.push(.currencyInfoForDeposit(.usdc)) router.presentNested(.buy(.usdc)) router.push(.usdcDepositEducation) router.replaceTopmostAny(AppRouter.Destination.usdcDepositAddress) #expect(router[.buy] == AppRouter.navigationPath(.usdcDepositAddress)) - #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + #expect(router[.give] == AppRouter.navigationPath(.currencyInfoForDeposit(.usdc))) } @Test("replaceTopmostAny on an empty stack appends the value") func replaceTopmost_emptyStack_appends() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.replaceTopmostAny(AppRouter.Destination.currencyInfo(.usdc)) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) } - @Test("replaceTopmostAny is a no-op with no sheet presented") + @Test("replaceTopmostAny is a no-op with no sheet presented and no active tab") func replaceTopmost_noSheet_isNoop() { let router = AppRouter() router.replaceTopmostAny(AppRouter.Destination.currencyInfo(.usdc)) @@ -128,7 +128,7 @@ struct AppRouterTests { @Test("popToRoot clears the stack") func popToRoot_clearsStack() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.push(.transactionHistory(.usdc)) router.popToRoot(on: .balance) @@ -138,7 +138,7 @@ struct AppRouterTests { @Test("popLast removes the requested number of items") func popLast_removesCount() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.push(.transactionHistory(.usdc)) router.push(.discoverCurrencies) @@ -149,7 +149,7 @@ struct AppRouterTests { @Test("popLast clamps to available depth") func popLast_clampsToDepth() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.popLast(10, on: .balance) #expect(router[.balance].isEmpty) @@ -158,7 +158,7 @@ struct AppRouterTests { @Test("setPath replaces the entire path") func setPath_replacesPath() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) router.setPath([.discoverCurrencies, .currencyCreationSummary], on: .balance) #expect(router[.balance] == AppRouter.navigationPath(.discoverCurrencies, .currencyCreationSummary)) @@ -183,18 +183,18 @@ struct AppRouterTests { // MARK: - Stack inference - @Test("push lands on the currently-presented sheet's stack") - func push_landsOnPresentedStack() { + @Test("push lands on the active tab's stack when no sheet is up") + func push_landsOnActiveTabStack() { let router = AppRouter() - router.present(.balance) + router.activeTabStack = .balance router.push(.currencyInfo(.usdc)) #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) #expect(router[.settings].isEmpty) #expect(router[.give].isEmpty) } - @Test("push is a no-op when no sheet is presented") - func push_noopWhenNoSheet() { + @Test("push is a no-op with no sheet presented and no active tab") + func push_noopWhenNoTopmostStack() { let router = AppRouter() router.push(.currencyInfo(.usdc)) #expect(router[.balance].isEmpty) @@ -211,16 +211,16 @@ struct AppRouterTests { // give flow's "Add More Cash" path). let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(.usdc)) - #expect(router[.balance].count == 1) + router.present(.settings) + router.push(.withdraw) + #expect(router[.settings].count == 1) router.present(.give) router.push(.currencyInfoForDeposit(.usdc)) - #expect(router[.balance].count == 1, "balance path preserved across swap") + #expect(router[.settings].count == 1, "settings path preserved across swap") #expect(router[.give].count == 1, "new push lands on the current sheet's stack") - #expect(router[.settings].isEmpty) + #expect(router[.balance].isEmpty) } @Test("pushAny lands on the currently-presented sheet's stack") @@ -233,7 +233,7 @@ struct AppRouterTests { #expect(router[.give].isEmpty) } - @Test("pushAny is a no-op when no sheet is presented") + @Test("pushAny is a no-op with no sheet presented and no active tab") func pushAny_noopWhenNoSheet() { let router = AppRouter() router.pushAny(WithdrawNavigationPath.enterAmount) @@ -265,14 +265,14 @@ struct AppRouterTests { @Test("present sets the sheet") func present_setsSheet() { let router = AppRouter() - router.present(.balance) - #expect(router.presentedSheet == .balance) + router.present(.give) + #expect(router.presentedSheet == .give) } @Test("dismissSheet clears the sheet") func dismissSheet_clearsSheet() { let router = AppRouter() - router.present(.balance) + router.present(.give) router.dismissSheet() #expect(router.presentedSheet == nil) } @@ -280,11 +280,11 @@ struct AppRouterTests { @Test("present is idempotent") func present_isIdempotent() { let router = AppRouter() - router.present(.balance) - router.setPath([.currencyInfo(.usdc)], on: .balance) - router.present(.balance) - #expect(router.presentedSheet == .balance) - #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + router.present(.give) + router.setPath([.currencyInfoForDeposit(.usdc)], on: .give) + router.present(.give) + #expect(router.presentedSheet == .give) + #expect(router[.give] == AppRouter.navigationPath(.currencyInfoForDeposit(.usdc))) } @Test("dismissSheet on no-sheet is a no-op") @@ -297,53 +297,53 @@ struct AppRouterTests { @Test("dismissSheet leaves the path intact for the dismiss-animation snapshot") func dismissSheet_leavesPathIntact() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(.usdc)) + router.present(.give) + router.push(.currencyInfoForDeposit(.usdc)) router.dismissSheet() - #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc)), + #expect(router[.give] == AppRouter.navigationPath(.currencyInfoForDeposit(.usdc)), "path should survive dismiss so the closing sheet animates with its current contents") } @Test("re-presenting a previously-dismissed sheet clears its stack path") func present_afterDismiss_clearsPath() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(.usdc)) + router.present(.give) + router.push(.currencyInfoForDeposit(.usdc)) router.dismissSheet() - router.present(.balance) + router.present(.give) - #expect(router[.balance].isEmpty, + #expect(router[.give].isEmpty, "re-opening after a dismiss must start at root") } @Test("re-presenting after dismiss + opening another sheet still clears on return") func present_afterDismissAndIntermediate_stillClearsOnReturn() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(.usdc)) + router.present(.give) + router.push(.currencyInfoForDeposit(.usdc)) router.dismissSheet() router.present(.settings) - router.present(.balance) + router.present(.give) - #expect(router[.balance].isEmpty, + #expect(router[.give].isEmpty, "the dismissed-marker survives across other presentations") } @Test("sheet swap (no dismiss between) preserves both stacks' paths") func present_swap_preservesPaths() { let router = AppRouter() - router.present(.balance) - router.push(.currencyInfo(.usdc)) + router.present(.give) + router.push(.currencyInfoForDeposit(.usdc)) router.setPath([.settingsMyAccount], on: .settings) router.present(.settings) - router.present(.balance) + router.present(.give) - #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc)), + #expect(router[.give] == AppRouter.navigationPath(.currencyInfoForDeposit(.usdc)), "swap-back must restore the original path") #expect(router[.settings] == AppRouter.navigationPath(.settingsMyAccount), "the swapped-from path must survive") diff --git a/FlipcashTests/Navigation/YouTabRoutingTests.swift b/FlipcashTests/Navigation/YouTabRoutingTests.swift index 1d1324810..5509e19c2 100644 --- a/FlipcashTests/Navigation/YouTabRoutingTests.swift +++ b/FlipcashTests/Navigation/YouTabRoutingTests.swift @@ -30,7 +30,6 @@ struct YouTabRoutingTests { @Test("a self tipcard link brings the You tab forward at its root") func showOwnTipCard_inTabUI_selectsYouTabAtRoot() { let router = AppRouter() - router.tabStacks = [.balance, .tips, .you] // Drilled into My Account behind a sheet — where a self link can land. router.setPath([.settingsMyAccount], on: .you) router.present(.settings) @@ -45,7 +44,6 @@ struct YouTabRoutingTests { @Test("a repeat self scan is absorbed once the You tab request is in flight") func showOwnTipCard_whileRequestPending_doesNotRefire() { let router = AppRouter() - router.tabStacks = [.balance, .tips, .you] router.showOwnTipCard() #expect(router.requestedTabStack == .you) @@ -61,7 +59,6 @@ struct YouTabRoutingTests { @Test("a self scan from a pushed You-tab screen returns to the card") func showOwnTipCard_fromPushedYouScreen_popsToRoot() { let router = AppRouter() - router.tabStacks = [.balance, .tips, .you] router.activeTabStack = .you router.push(.settingsMyAccount) @@ -71,17 +68,6 @@ struct YouTabRoutingTests { #expect(router.requestedTabStack == .you) } - @Test("without the tab UI, a self tipcard link opens My Tip Card in the tips sheet") - func showOwnTipCard_withoutTabs_navigatesToTipcard() { - let router = AppRouter() - - router.showOwnTipCard() - - #expect(router.presentedSheet == .tips) - #expect(router[.tips] == AppRouter.navigationPath(.tipcard)) - #expect(router.requestedTabStack == nil) - } - @Test("changing the display name pushes onto the You tab, and saving pops back") func changeDisplayName_pushesAndPopsOnYouStack() { let router = AppRouter() diff --git a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift index 4e99cb6aa..34cbcdeab 100644 --- a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift +++ b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift @@ -61,15 +61,20 @@ struct Regression_native_amount_mismatch { #expect(pin.exchangeRate == 1.35) } - // MARK: - Scenario D (sell) + // MARK: - Scenario D (convert) - @Test("Scenario D (sell): prepareSubmission computes quarks from the PINNED rate AND supply") - func scenarioD_sellPrepareSubmissionUsesPinnedRateAndSupply() async throws { + @Test("Scenario D (convert): prepareSubmission computes quarks from the PINNED rate AND supply") + func scenarioD_convertPrepareSubmissionUsesPinnedRateAndSupply() async throws { // Pinned: rate 1.35, supply 1M. Live cache: rate 1.37, supply 1.5M. let pinnedSupply: UInt64 = 1_000_000 * 10_000_000_000 let liveSupply: UInt64 = 1_500_000 * 10_000_000_000 - let sessionContainer = SessionContainer.mock + let sessionContainer = try SessionContainer.makeTest(holdings: [ + .init( + mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: liveSupply), + quarks: 100 * 10_000_000_000 + ), + ]) sessionContainer.ratesController.configureTestRates( balanceCurrency: .cad, rates: [Rate(fx: 1.37, currency: .cad)] @@ -81,12 +86,9 @@ struct Regression_native_amount_mismatch { .freshReserve(mint: .jeffy, supplyFromBonding: pinnedSupply) ]) - let metadata = StoredMintMetadata(MintMetadata.makeLaunchpad( - supplyFromBonding: liveSupply - )) - - let vm = CurrencySellViewModel( - currencyMetadata: metadata, + let sourceBalance = try #require(sessionContainer.session.balance(for: .jeffy)) + let vm = ConvertAmountViewModel( + sourceBalance: sourceBalance, session: sessionContainer.session, ratesController: sessionContainer.ratesController ) @@ -99,7 +101,7 @@ struct Regression_native_amount_mismatch { #expect(submission.amount.currencyRate.fx == Decimal(1.35)) #expect(submission.amount.currencyRate.fx != Decimal(1.37)) - // And the VerifiedState carried to Session.sell must be the pinned + // And the VerifiedState carried into the swap must be the pinned // proof — with pinned supply, not the live metadata supply. #expect(submission.pinnedState.exchangeRate == 1.35) #expect(submission.pinnedState.supplyFromBonding == pinnedSupply) @@ -158,7 +160,7 @@ struct Regression_native_amount_mismatch { ) let router = AppRouter() - router.present(.balance) + router.present(.give) router.presentNested(.buy(.jeffy)) // Paying with the held USDF, but no pin is cached — Next must bail with @@ -175,23 +177,25 @@ struct Regression_native_amount_mismatch { #expect(router[.buy].isEmpty, "A pinless selection must not push the summary") } - // MARK: - Scenario E (sell) + // MARK: - Scenario E (convert) - @Test("Scenario E (sell): prepareSubmission returns nil when no fresh pin is cached") - func scenarioE_sellPrepareSubmissionReturnsNilWhenNoPin() async { + @Test("Scenario E (convert): prepareSubmission returns nil when no fresh pin is cached") + func scenarioE_convertPrepareSubmissionReturnsNilWhenNoPin() async throws { // Live rate + live metadata supply are present; no pinned proof is. - let sessionContainer = SessionContainer.mock + let sessionContainer = try SessionContainer.makeTest(holdings: [ + .init( + mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: 1_000_000 * 10_000_000_000), + quarks: 100 * 10_000_000_000 + ), + ]) sessionContainer.ratesController.configureTestRates( balanceCurrency: .cad, rates: [Rate(fx: 1.35, currency: .cad)] ) - let metadata = StoredMintMetadata(MintMetadata.makeLaunchpad( - supplyFromBonding: 1_000_000 * 10_000_000_000 - )) - - let vm = CurrencySellViewModel( - currencyMetadata: metadata, + let sourceBalance = try #require(sessionContainer.session.balance(for: .jeffy)) + let vm = ConvertAmountViewModel( + sourceBalance: sourceBalance, session: sessionContainer.session, ratesController: sessionContainer.ratesController ) diff --git a/FlipcashTests/ResolveInitialBalanceTests.swift b/FlipcashTests/ResolveInitialBalanceTests.swift index 5729ab953..dab963f24 100644 --- a/FlipcashTests/ResolveInitialBalanceTests.swift +++ b/FlipcashTests/ResolveInitialBalanceTests.swift @@ -8,13 +8,11 @@ import Testing import FlipcashCore @testable import Flipcash -/// `includingDollars` is `BetaFlags.allowsDollarsGive` at every call site: false -/// on the old UI, which has no Dollars give affordance anywhere, true on the new. @MainActor @Suite("RatesController.resolveInitialBalance") struct ResolveInitialBalanceTests { - @Test("A Dollars-only account resolves to Dollars once Dollars can be given") + @Test("A Dollars-only account resolves to Dollars") func dollarsOnly_withDollars_resolvesToUSDF() throws { let container = try SessionContainer.makeTest(holdings: [ .init(mint: .usdf, quarks: 25_000_000), // $25 @@ -23,29 +21,12 @@ struct ResolveInitialBalanceTests { let resolved = container.ratesController.resolveInitialBalance( mint: nil, - session: container.session, - includingDollars: true + session: container.session ) #expect(resolved?.stored.mint == .usdf) } - @Test("A Dollars-only account resolves to nothing while Dollars can't be given") - func dollarsOnly_withoutDollars_resolvesToNil() throws { - let container = try SessionContainer.makeTest(holdings: [ - .init(mint: .usdf, quarks: 25_000_000), - ]) - container.ratesController.selectedTokenMint = nil - - let resolved = container.ratesController.resolveInitialBalance( - mint: nil, - session: container.session, - includingDollars: false - ) - - #expect(resolved == nil) - } - @Test("Dollars that displays as $0.00 can't fund anything, so it resolves to nothing") func dollarsDust_resolvesToNil() throws { let container = try SessionContainer.makeTest(holdings: [ @@ -55,8 +36,7 @@ struct ResolveInitialBalanceTests { let resolved = container.ratesController.resolveInitialBalance( mint: nil, - session: container.session, - includingDollars: true + session: container.session ) #expect(resolved == nil) @@ -69,8 +49,7 @@ struct ResolveInitialBalanceTests { let resolved = container.ratesController.resolveInitialBalance( mint: nil, - session: container.session, - includingDollars: true + session: container.session ) #expect(resolved?.stored.mint == .jeffy) @@ -86,8 +65,7 @@ struct ResolveInitialBalanceTests { let resolved = container.ratesController.resolveInitialBalance( mint: nil, - session: container.session, - includingDollars: true + session: container.session ) #expect(resolved?.stored.mint == .jeffy) @@ -100,8 +78,7 @@ struct ResolveInitialBalanceTests { let resolved = container.ratesController.resolveInitialBalance( mint: .usdf, - session: container.session, - includingDollars: true + session: container.session ) #expect(resolved?.stored.mint == .usdf) diff --git a/FlipcashTests/SessionTests.swift b/FlipcashTests/SessionTests.swift index 61829d460..8dab48d5a 100644 --- a/FlipcashTests/SessionTests.swift +++ b/FlipcashTests/SessionTests.swift @@ -569,7 +569,7 @@ struct SessionSellVerifiedStateTests { @Suite("Session.balances(for:) USDF inclusion") struct SessionBalancesUSDFInclusionTests { - @Test("USDF appears in balances(for:) even with zero quarks — pins the BalanceScreen normalization invariant") + @Test("USDF appears in balances(for:) even with zero quarks — pins the WalletScreen normalization invariant") func balances_includesUSDF_atZero() throws { let container = try SessionContainer.makeTest(holdings: [ .init(mint: .usdf, quarks: 0), @@ -711,36 +711,32 @@ struct SessionOfflineCacheTests { @Suite("Session.hasGiveableBalance") struct SessionHasGiveableBalanceTests { - // `includingDollars` is `BetaFlags.allowsDollarsGive`: false on the old UI, - // which has no Dollars give affordance, true on the new. - - @Test("Fresh account (USDF at zero) has no giveable balance", arguments: [false, true]) - func freshAccount_hasNone(includingDollars: Bool) throws { + @Test("Fresh account (USDF at zero) has no giveable balance") + func freshAccount_hasNone() throws { let container = try SessionContainer.makeTest(holdings: [ .init(mint: .usdf, quarks: 0), ]) - #expect(container.session.hasGiveableBalance(for: .oneToOne, includingDollars: includingDollars) == false) + #expect(container.session.hasGiveableBalance(for: .oneToOne) == false) } - @Test("USDF balance alone is giveable only once Dollars can be given") - func usdfOnly_isGiveableWithDollars() throws { + @Test("USDF balance alone is giveable") + func usdfOnly_isGiveable() throws { let container = try SessionContainer.makeTest(holdings: [ .init(mint: .usdf, quarks: 5 * 10_000_000_000), ]) - #expect(container.session.hasGiveableBalance(for: .oneToOne, includingDollars: true) == true) - #expect(container.session.hasGiveableBalance(for: .oneToOne, includingDollars: false) == false) + #expect(container.session.hasGiveableBalance(for: .oneToOne) == true) } - @Test("USDF dust that displays as $0.00 is not giveable", arguments: [false, true]) - func usdfDust_isNotGiveable(includingDollars: Bool) throws { + @Test("USDF dust that displays as $0.00 is not giveable") + func usdfDust_isNotGiveable() throws { let container = try SessionContainer.makeTest(holdings: [ .init(mint: .usdf, quarks: 1_000), // $0.001 ]) - #expect(container.session.hasGiveableBalance(for: .oneToOne, includingDollars: includingDollars) == false) + #expect(container.session.hasGiveableBalance(for: .oneToOne) == false) } - @Test("A funded non-USDF balance is giveable", arguments: [false, true]) - func fundedNonUSDF_isGiveable(includingDollars: Bool) throws { + @Test("A funded non-USDF balance is giveable") + func fundedNonUSDF_isGiveable() throws { let container = try SessionContainer.makeTest(holdings: [ .init(mint: .usdf, quarks: 0), .init( @@ -751,6 +747,6 @@ struct SessionHasGiveableBalanceTests { quarks: 10 * 10_000_000_000 ), ]) - #expect(container.session.hasGiveableBalance(for: .oneToOne, includingDollars: includingDollars) == true) + #expect(container.session.hasGiveableBalance(for: .oneToOne) == true) } } diff --git a/FlipcashTests/SwapProcessingViewModelTests.swift b/FlipcashTests/SwapProcessingViewModelTests.swift index 0882606e2..3e0923e74 100644 --- a/FlipcashTests/SwapProcessingViewModelTests.swift +++ b/FlipcashTests/SwapProcessingViewModelTests.swift @@ -12,12 +12,11 @@ import FlipcashCore @MainActor struct SwapProcessingViewModelTests { - @Test("SwapType exposes the reserves, currency-paid, sell, and convert cases") + @Test("SwapType exposes the reserves, currency-paid, and convert cases") func swapType_exposesExpectedCases() { - #expect(SwapType.allCases.count == 4) + #expect(SwapType.allCases.count == 3) #expect(SwapType.allCases.contains(.buyWithReserves)) #expect(SwapType.allCases.contains(.buyWithCurrency)) - #expect(SwapType.allCases.contains(.sell)) #expect(SwapType.allCases.contains(.convert)) } @@ -33,7 +32,6 @@ struct SwapProcessingViewModelTests { } #expect(makeViewModel(.buyWithReserves).navigationTitle == "Buying TestCoin") #expect(makeViewModel(.buyWithCurrency).navigationTitle == "Buying TestCoin") - #expect(makeViewModel(.sell).navigationTitle == "Selling TestCoin") // Convert spans two currencies, so the title names neither. #expect(makeViewModel(.convert).navigationTitle == "Converting") } diff --git a/FlipcashTests/TestSupport/MockSession.swift b/FlipcashTests/TestSupport/MockSession.swift index 2ca86f3b2..738d6381b 100644 --- a/FlipcashTests/TestSupport/MockSession.swift +++ b/FlipcashTests/TestSupport/MockSession.swift @@ -189,12 +189,11 @@ final class MockSession: var extraBalances: [StoredBalance] = [] /// Whether a non-Dollars balance is on hand. Dollars is weighed separately, - /// from `usdfReserveBalance`, so the gate's Dollars routing can be exercised. + /// from `usdfReserveBalance`, so a Dollars-only holding can be exercised. var giveableBalanceExists = false - func hasGiveableBalance(for rate: Rate, includingDollars: Bool) -> Bool { + func hasGiveableBalance(for rate: Rate) -> Bool { if giveableBalanceExists { return true } - guard includingDollars else { return false } return usdfReserveBalance?.computeExchangedValue(with: rate).hasDisplayableValue() ?? false } diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/hamburger.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/hamburger.imageset/Contents.json deleted file mode 100644 index 9bfbb6e7e..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/hamburger.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "hamburger.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/hamburger.imageset/hamburger.pdf b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/hamburger.imageset/hamburger.pdf deleted file mode 100644 index 211f08511..000000000 Binary files a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/hamburger.imageset/hamburger.pdf and /dev/null differ diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index cf972985f..27e39c7c0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -95,7 +95,6 @@ public enum Asset: String, Sendable { case graphicPushPermission case graphicPoolQuestion case graphicPoolPlaceholder - case hamburger case photo case deleteBubble case graphicWallet