From 2ed9f9ee2e97f540e84a17e6684174c11e985b40 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:23:34 -0400 Subject: [PATCH 1/8] feat(nav): make AppRouter push target tab-aware The topmost-stack mutators (push, pushAny, popTopmost, replaceTopmostAny, popToRoot) targeted presentedSheet?.stack and no-op'd when no sheet was up. In the v2 tab UI a tab is the active surface without being a sheet, so those pushes would silently drop. Add an activeTabStack fallback (set by HomeTabView per selected tab) via a topmostStack helper. No behaviour change in v1, where activeTabStack is nil and a sheet is always the target. --- Flipcash/Core/Navigation/AppRouter.swift | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index ff8dd38ec..01f9dbeb8 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -58,6 +58,19 @@ 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. + var activeTabStack: 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 + /// active tab's stack. + private var topmostStack: Stack? { presentedSheet?.stack ?? activeTabStack } + /// Stacks whose owning sheet was explicitly dismissed (close button, /// swipe-down, or programmatic `dismissSheet`) since the last presentation /// of that stack. The next `present(_:)` or `presentNested(_:)` on a sheet @@ -90,7 +103,7 @@ final class AppRouter { /// /// Cross-stack navigation is `navigate(to:)`'s job, not `push`'s. func push(_ destination: Destination) { - guard let stack = presentedSheet?.stack else { + guard let stack = topmostStack else { logger.warning("Push attempted with no sheet presented", metadata: [ "destination": "\(destination)", ]) @@ -106,7 +119,7 @@ final class AppRouter { /// mixed types without nesting `NavigationStack`s. No-op with a warning /// if no sheet is presented. func pushAny(_ value: H) { - guard let stack = presentedSheet?.stack else { + guard let stack = topmostStack else { logger.warning("Push (sub-flow) attempted with no sheet presented", metadata: [ "type": "\(type(of: value))", ]) @@ -131,7 +144,7 @@ final class AppRouter { /// without hand-stamping which stack it lives on (the Phantom flow /// screen, for instance, can ride on `.buy`, `.balance`, or `.discover`). func popTopmost() { - guard let stack = presentedSheet?.stack else { return } + guard let stack = topmostStack else { return } pop(on: stack) } @@ -146,7 +159,7 @@ final class AppRouter { /// /// No-op with a warning if no sheet is presented. func replaceTopmostAny(_ value: H) { - guard let stack = presentedSheet?.stack else { + guard let stack = topmostStack else { logger.warning("replaceTopmostAny attempted with no sheet presented", metadata: [ "type": "\(type(of: value))", ]) @@ -173,7 +186,7 @@ final class AppRouter { /// must unwind to the host stack's root without hand-stamping which stack they're on (e.g. the /// withdraw flow, reachable from the Wallet, a deeplinked chat, or the recipient picker). func popToRoot() { - guard let stack = presentedSheet?.stack else { return } + guard let stack = topmostStack else { return } popToRoot(on: stack) } From 6b3772b21ce6790966e0c814120315cc129ed688 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:23:35 -0400 Subject: [PATCH 2/8] refactor(scan): hoist the rootSheet host into a reusable modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the app-level router.rootSheet presentation (RoutedSheet swap + tip-resume + dismiss-on-bill hooks) into RootSheetHostModifier so exactly one live view can own it. Add ScanScreen(isEmbedded:) — default false keeps the v1 root owning the host unchanged; when embedded as a v2 tab, HomeTabView owns it and ScanScreen suppresses its own to avoid double-presentation. --- Flipcash/Core/Screens/Main/ScanScreen.swift | 130 +++++++++++++------- 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index e0ad86373..6292a0f4e 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -18,8 +18,14 @@ 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) + ScanScreenContent(container: container, sessionContainer: sessionContainer, isEmbedded: isEmbedded) } } @@ -60,11 +66,15 @@ private struct ScanScreenContent: View { private let sessionContainer: SessionContainer + /// See ``ScanScreen/isEmbedded``. + private let isEmbedded: Bool + // MARK: - Init - - init(container: Container, sessionContainer: SessionContainer) { + init(container: Container, sessionContainer: SessionContainer, isEmbedded: Bool = false) { self.sessionContainer = sessionContainer self.session = sessionContainer.session + self.isEmbedded = isEmbedded self.viewModel = ScanViewModel( container: container, @@ -164,48 +174,11 @@ private struct ScanScreenContent: View { } } } - // 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 in ScanScreen's ZStack, 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() - } + // 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. + .modifier(RootSheetHostModifier(enabled: !isEmbedded)) // Reset button state on bill dismissal — `sendButtonState` outlives individual bills. .onChange(of: session.billState.bill) { _, newBill in guard newBill == nil else { return } @@ -452,3 +425,72 @@ private struct RoutedSheet: View { } } } + +// 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 + } + } +} From 802649b0a400ea7451a06f1429c4828d5ac84538 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:23:35 -0400 Subject: [PATCH 3/8] feat(wallet): BalanceScreen isEmbedded (full-screen tab) mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add BalanceScreen(isEmbedded:) which suppresses the sheet-only close button so the screen can serve as the full-screen v2 Wallet tab, keeping its own NavigationStack(path: $router[.balance]) and every existing push destination. Defaults to false — the v1 sheet presentation is unchanged. --- .../Core/Screens/Main/BalanceScreen.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift index 4590dff2a..889f9695a 100644 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ b/Flipcash/Core/Screens/Main/BalanceScreen.swift @@ -17,8 +17,13 @@ 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) + BalanceScreenContent(sessionContainer: sessionContainer, isEmbedded: isEmbedded) } } @@ -80,10 +85,14 @@ private struct BalanceScreenContent: View { return (amount, isPositive) } + /// See ``BalanceScreen/isEmbedded``. + private let isEmbedded: Bool + // MARK: - Init - - init(sessionContainer: SessionContainer) { + 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 @@ -126,8 +135,10 @@ private struct BalanceScreenContent: View { .toolbarTitleDisplayMode(.inline) .appRouterDestinations() .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: router.dismissSheet) + if !isEmbedded { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(action: router.dismissSheet) + } } } .onChange(of: notificationController.pushWillPresent) { _, _ in From 7216cfac2deed044071c2c5c57be203f89ce68fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:23:35 -0400 Subject: [PATCH 4/8] feat(ui): v2 tab-bar shell behind BetaFlags.newUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the v2 tab-bar UI, gated by the new developer BetaFlags.newUI (default off; v1 stays the default). ContainerScreen branches the logged-in root between HomeTabView (v2) and ScanScreen (v1). HomeTabView hosts four tabs — Scan, Wallet, Chat, Tip Card — behind a floating pill HomeTabBar (sliding indicator, ported from Android's NavigationBarV2), launches on Wallet, and owns the app-level rootSheet host so present() works from any tab. Only the selected tab is mounted (a switch, not TabView — the deployment target predates the tab-bar-hiding APIs) so the Scan camera stops via onDisappear. Tab icons are SF Symbols for now — close stand-ins for the Figma nav glyphs, swappable for exported assets. First slice of the Android v2 UI port (PR #1209); token cards, onboarding funnel, and header polish follow. --- Flipcash/Core/ContainerScreen.swift | 15 +- Flipcash/Core/Controllers/BetaFlags.swift | 6 + Flipcash/Core/Screens/Main/Home/HomeTab.swift | 43 ++++++ .../Core/Screens/Main/Home/HomeTabBar.swift | 61 ++++++++ .../Core/Screens/Main/Home/HomeTabView.swift | 138 ++++++++++++++++++ 5 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/HomeTab.swift create mode 100644 Flipcash/Core/Screens/Main/Home/HomeTabBar.swift create mode 100644 Flipcash/Core/Screens/Main/Home/HomeTabView.swift diff --git a/Flipcash/Core/ContainerScreen.swift b/Flipcash/Core/ContainerScreen.swift index 43f0a34c6..3a799d476 100644 --- a/Flipcash/Core/ContainerScreen.swift +++ b/Flipcash/Core/ContainerScreen.swift @@ -11,6 +11,7 @@ import FlipcashUI struct ContainerScreen: View { @Environment(SessionAuthenticator.self) var sessionAuthenticator + @Environment(BetaFlags.self) var betaFlags var body: some View { VStack { @@ -33,10 +34,16 @@ struct ContainerScreen: View { .transition(.opacity) case .loggedIn(let sessionContainer): - ScanScreen() - .modifier(OnrampHostModifier()) - .injectingEnvironment(from: sessionContainer) - .transition(.opacity) + Group { + if betaFlags.hasEnabled(.newUI) { + HomeTabView() + } else { + ScanScreen() + } + } + .modifier(OnrampHostModifier()) + .injectingEnvironment(from: sessionContainer) + .transition(.opacity) } } } diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index 4a12dfc0b..903836f61 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -114,6 +114,7 @@ extension BetaFlags { case vibrateOnScan case enableCoinbase + case newUI var id: String { localizedTitle @@ -125,6 +126,8 @@ extension BetaFlags { return "Vibrate on scan" case .enableCoinbase: return "Enable Coinbase" + case .newUI: + return "New tab-bar UI" } } @@ -134,6 +137,8 @@ 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" } } @@ -142,6 +147,7 @@ extension BetaFlags { switch self { case .vibrateOnScan: return .developer case .enableCoinbase: return .developer + case .newUI: return .developer } } } diff --git a/Flipcash/Core/Screens/Main/Home/HomeTab.swift b/Flipcash/Core/Screens/Main/Home/HomeTab.swift new file mode 100644 index 000000000..f730fdec4 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/HomeTab.swift @@ -0,0 +1,43 @@ +// +// HomeTab.swift +// Flipcash +// + +import SwiftUI + +/// The tabs of the v2 tab-bar UI, in display order (left → right). The app +/// launches on `.wallet` (wallet-first), mirroring the Android v2 UI. +/// +/// Icons are SF Symbols for now — close stand-ins for the Figma nav glyphs +/// (`ic_nav_scan`/`wallet`/`chat`/`tipcard`); swap for exported assets when the +/// vectors land. +enum HomeTab: Int, CaseIterable, Identifiable, Hashable { + case scan + case wallet + case chat + case tipCard + + var id: Int { rawValue } + + /// The launch tab — wallet-first, per the v2 design. + static let initial: HomeTab = .wallet + + var systemImage: String { + switch self { + case .scan: return "viewfinder" + case .wallet: return "wallet.pass.fill" + case .chat: return "bubble.left.and.bubble.right.fill" + case .tipCard: return "giftcard.fill" + } + } + + /// VoiceOver label for the tab's button. + var accessibilityLabel: String { + switch self { + case .scan: return "Scan" + case .wallet: return "Wallet" + case .chat: return "Chat" + case .tipCard: return "Tip Card" + } + } +} diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift new file mode 100644 index 000000000..1079bdc41 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift @@ -0,0 +1,61 @@ +// +// HomeTabBar.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// The floating pill tab bar for the v2 UI. A dark translucent capsule holds one +/// button per ``HomeTab``, with a white selection pill that slides to the active +/// tab. Ported from Android's `NavigationBarV2` (Figma frame 9013-5434): black +/// 62%-alpha capsule, white-20% sliding indicator, icons at full/half opacity. +struct HomeTabBar: View { + + @Binding var selection: HomeTab + + private let tabs = HomeTab.allCases + + /// Vertical padding inside the capsule around each item. + private let itemVerticalPadding: CGFloat = 8 + private let iconSize: CGFloat = 24 + + private var itemHeight: CGFloat { iconSize + itemVerticalPadding * 2 } + + var body: some View { + GeometryReader { proxy in + let itemWidth = proxy.size.width / CGFloat(tabs.count) + let selectedIndex = tabs.firstIndex(of: selection) ?? 0 + + ZStack(alignment: .leading) { + // Selected-state pill, drawn behind the icons, sliding to the active tab. + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(width: itemWidth, height: itemHeight) + .offset(x: itemWidth * CGFloat(selectedIndex)) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: selection) + + HStack(spacing: 0) { + ForEach(tabs) { tab in + Button { + selection = tab + } label: { + Image(systemName: tab.systemImage) + .font(.system(size: iconSize * 0.72, weight: .semibold)) + .foregroundStyle(Color.white) + .opacity(selection == tab ? 1 : 0.5) + .frame(width: itemWidth, height: itemHeight) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(tab.accessibilityLabel) + .accessibilityAddTraits(selection == tab ? [.isSelected] : []) + } + } + } + } + .frame(height: itemHeight) + .padding(4) + .background(Color.black.opacity(0.62), in: Capsule()) + } +} diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift new file mode 100644 index 000000000..36c099cc2 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -0,0 +1,138 @@ +// +// HomeTabView.swift +// Flipcash +// + +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 behind a +/// floating pill `HomeTabBar`, launches on Wallet, and owns the app-level +/// `router.rootSheet` host so `router.present(_:)` works from any tab. +/// +/// Only the selected tab is mounted (a plain `switch`, not a `TabView` — the +/// deployment target predates the tab-bar-hiding APIs): switching tabs unmounts +/// the previous one, so the Scan camera stops via its own `onDisappear`. The +/// selected tab's push target is published to the router via `activeTabStack`, +/// since a tab is the active surface without being a sheet. +struct HomeTabView: View { + + @Environment(AppRouter.self) private var router + + @State private var selection: HomeTab = .initial + + var body: some View { + ZStack(alignment: .bottom) { + tabContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + + HomeTabBar(selection: $selection) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + .background(Color.backgroundMain) + .animation(.easeInOut(duration: 0.2), value: selection) + // 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)) + .onAppear { router.activeTabStack = selection.pushStack } + .onChange(of: selection) { _, tab in router.activeTabStack = tab.pushStack } + .onDisappear { router.activeTabStack = nil } + } + + @ViewBuilder private var tabContent: some View { + switch selection { + case .scan: + ScanScreen(isEmbedded: true) + case .wallet: + BalanceScreen(isEmbedded: true) + case .chat: + ChatTab() + case .tipCard: + TipCardTab(onSetUp: { selection = .chat }) + } + } +} + +private 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). + var pushStack: AppRouter.Stack? { + switch self { + case .wallet: return .balance + case .chat: return .tips + case .scan, .tipCard: return nil + } + } +} + +// MARK: - Chat tab - + +/// The Chat tab — the tip conversations surface. Mirrors `TipsSheetRoot` (the +/// `.tips` sheet) as embedded tab chrome: the same `NavigationStack` bound to +/// `router[.tips]` and the same profile-creation state, minus the sheet's close +/// button. +private struct ChatTab: View { + + @Environment(AppRouter.self) private var router + @State private var creationState = ProfileCreationState() + + var body: some View { + @Bindable var router = router + NavigationStack(path: $router[.tips]) { + TipsScreen() + .appRouterDestinations() + } + .environment(creationState) + } +} + +// MARK: - Tip Card tab - + +/// The Tip Card tab — the user's own shareable tip card. Only tippable profiles +/// have a card; before then it prompts the user over to the Chat tab, where the +/// add-your-name flow lives (avoiding a duplicate profile-creation push here). +private struct TipCardTab: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let onSetUp: () -> Void + + var body: some View { + NavigationStack { + if sessionContainer.session.profile?.isTippable == true { + TipcardScreen() + } else { + TipCardSetupPrompt(onSetUp: onSetUp) + } + } + } +} + +/// Shown on the Tip Card tab before the user has a tippable profile. +private struct TipCardSetupPrompt: View { + + let onSetUp: () -> Void + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 12) { + Text("Set Up Your Tip Card") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + Text("Add your name to get a tip card you can share and receive tips.") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + + BubbleButton(text: "Get Started") { onSetUp() } + .padding(.top, 8) + } + .padding(.horizontal, 40) + } + } +} From d3d429665865b8b5c9adf99a1f1c10aa24a7f022 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:31:57 -0400 Subject: [PATCH 5/8] feat(ui): Figma nav icons and Liquid Glass tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the SF Symbol placeholders with the Figma tab-bar glyphs (Nav* template imagesets — scan/wallet/chat/tipcard — converted from the same vectors as Android's ic_nav_*, tinted white at the call site), and swap the flat black capsule for the app's Liquid Glass surface (glassEffect on iOS 26, ultraThinMaterial below), matching the design. --- Flipcash/Core/Screens/Main/Home/HomeTab.swift | 16 +++++----- .../Core/Screens/Main/Home/HomeTabBar.swift | 31 ++++++++++++++----- .../NavChat.imageset/Contents.json | 16 ++++++++++ .../NavChat.imageset/nav_chat.svg | 4 +++ .../NavScan.imageset/Contents.json | 16 ++++++++++ .../NavScan.imageset/nav_scan.svg | 5 +++ .../NavTipCard.imageset/Contents.json | 16 ++++++++++ .../NavTipCard.imageset/nav_tipcard.svg | 16 ++++++++++ .../NavWallet.imageset/Contents.json | 16 ++++++++++ .../NavWallet.imageset/nav_wallet.svg | 4 +++ 10 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/Contents.json create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/nav_chat.svg create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/Contents.json create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/nav_scan.svg create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/Contents.json create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/nav_tipcard.svg create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/Contents.json create mode 100644 Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/nav_wallet.svg diff --git a/Flipcash/Core/Screens/Main/Home/HomeTab.swift b/Flipcash/Core/Screens/Main/Home/HomeTab.swift index f730fdec4..7fed04ef0 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTab.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTab.swift @@ -8,9 +8,8 @@ import SwiftUI /// The tabs of the v2 tab-bar UI, in display order (left → right). The app /// launches on `.wallet` (wallet-first), mirroring the Android v2 UI. /// -/// Icons are SF Symbols for now — close stand-ins for the Figma nav glyphs -/// (`ic_nav_scan`/`wallet`/`chat`/`tipcard`); swap for exported assets when the -/// vectors land. +/// Icons are the Figma tab-bar glyphs (`Nav*` template imagesets, from the same +/// vectors as Android's `ic_nav_*`), tinted white at the call site. enum HomeTab: Int, CaseIterable, Identifiable, Hashable { case scan case wallet @@ -22,12 +21,13 @@ enum HomeTab: Int, CaseIterable, Identifiable, Hashable { /// The launch tab — wallet-first, per the v2 design. static let initial: HomeTab = .wallet - var systemImage: String { + /// The asset-catalog name of the tab's template glyph. + var iconName: String { switch self { - case .scan: return "viewfinder" - case .wallet: return "wallet.pass.fill" - case .chat: return "bubble.left.and.bubble.right.fill" - case .tipCard: return "giftcard.fill" + case .scan: return "NavScan" + case .wallet: return "NavWallet" + case .chat: return "NavChat" + case .tipCard: return "NavTipCard" } } diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift index 1079bdc41..74029c7f9 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift @@ -6,10 +6,10 @@ import SwiftUI import FlipcashUI -/// The floating pill tab bar for the v2 UI. A dark translucent capsule holds one +/// The floating pill tab bar for the v2 UI. A Liquid Glass capsule holds one /// button per ``HomeTab``, with a white selection pill that slides to the active -/// tab. Ported from Android's `NavigationBarV2` (Figma frame 9013-5434): black -/// 62%-alpha capsule, white-20% sliding indicator, icons at full/half opacity. +/// tab. Ported from Android's `NavigationBarV2` (Figma frame 9013-5434): +/// translucent capsule, white-20% sliding indicator, icons at full/half opacity. struct HomeTabBar: View { @Binding var selection: HomeTab @@ -18,7 +18,7 @@ struct HomeTabBar: View { /// Vertical padding inside the capsule around each item. private let itemVerticalPadding: CGFloat = 8 - private let iconSize: CGFloat = 24 + private let iconSize: CGFloat = 26 private var itemHeight: CGFloat { iconSize + itemVerticalPadding * 2 } @@ -40,8 +40,11 @@ struct HomeTabBar: View { Button { selection = tab } label: { - Image(systemName: tab.systemImage) - .font(.system(size: iconSize * 0.72, weight: .semibold)) + Image(tab.iconName) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: iconSize, height: iconSize) .foregroundStyle(Color.white) .opacity(selection == tab ? 1 : 0.5) .frame(width: itemWidth, height: itemHeight) @@ -56,6 +59,20 @@ struct HomeTabBar: View { } .frame(height: itemHeight) .padding(4) - .background(Color.black.opacity(0.62), in: Capsule()) + .capsuleGlassBackground() + } +} + +private extension View { + /// The app's Liquid Glass surface clipped to a capsule — Liquid Glass on + /// iOS 26, an ultra-thin material below (mirrors `glassBackground(cornerRadius:)`, + /// which only offers a rounded-rect). + @ViewBuilder + func capsuleGlassBackground() -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.interactive(), in: Capsule()) + } else { + background(.ultraThinMaterial, in: Capsule()) + } } } diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/Contents.json b/Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/Contents.json new file mode 100644 index 000000000..4e6ce22c8 --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nav_chat.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/nav_chat.svg b/Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/nav_chat.svg new file mode 100644 index 000000000..4944333fc --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavChat.imageset/nav_chat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/Contents.json b/Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/Contents.json new file mode 100644 index 000000000..62f705563 --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nav_scan.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/nav_scan.svg b/Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/nav_scan.svg new file mode 100644 index 000000000..0b6c9f781 --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavScan.imageset/nav_scan.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/Contents.json b/Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/Contents.json new file mode 100644 index 000000000..195c88606 --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nav_tipcard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/nav_tipcard.svg b/Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/nav_tipcard.svg new file mode 100644 index 000000000..57da2e710 --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavTipCard.imageset/nav_tipcard.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/Contents.json b/Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/Contents.json new file mode 100644 index 000000000..413c138e8 --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nav_wallet.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/nav_wallet.svg b/Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/nav_wallet.svg new file mode 100644 index 000000000..4396c88cd --- /dev/null +++ b/Flipcash/Supporting Files/Assets.xcassets/NavWallet.imageset/nav_wallet.svg @@ -0,0 +1,4 @@ + + + + From 6e90bc56247d00e37ae571b70dc056dcb128d190 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 12 Aug 2026 10:32:24 -0400 Subject: [PATCH 6/8] feat(ui): bare camera on the v2 Scan tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide the scanner's v1 chrome when embedded in the tab-bar UI — the Flipcash brand + settings top bar and the bottom navigation are replaced by the app-level tab bar, so the Scan tab is just the camera. v1 (non-embedded) is unchanged. --- Flipcash/Core/Screens/Main/ScanScreen.swift | 33 +++++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 6292a0f4e..446434fa8 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -274,20 +274,27 @@ private struct ScanScreenContent: View { @ViewBuilder private func interfaceView() -> some View { VStack { - ScanTopBar( - onBrand: { router.present(.downloadApp) }, - onSettings: { router.present(.settings) } - ) + // 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() - 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) } - ) + 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) } From 98a9b98a543c94813d3312134ac2746550756c01 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 12 Aug 2026 10:43:03 -0400 Subject: [PATCH 7/8] feat(ui): v2 camera-permission copy on the Scan tab When embedded in the tab-bar UI, the permission prompt reads 'Start your camera to scan a Tip Card' instead of the v1 cash-grab wording. v1 (non-embedded) copy is unchanged. --- Flipcash/Core/Screens/Main/CameraPromptView.swift | 13 +++++++++---- Flipcash/Core/Screens/Main/ScanScreen.swift | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Main/CameraPromptView.swift b/Flipcash/Core/Screens/Main/CameraPromptView.swift index 57167dd98..ef16e8b9f 100644 --- a/Flipcash/Core/Screens/Main/CameraPromptView.swift +++ b/Flipcash/Core/Screens/Main/CameraPromptView.swift @@ -43,11 +43,14 @@ nonisolated enum CameraPrompt: Equatable { } } - /// The explanatory text shown above the action button. - var message: String { + /// The explanatory text shown above the action button. `embedded` is the v2 + /// tab-bar UI, where the scanner is framed around tip cards rather than the + /// v1 cash-grab wording. + func message(embedded: Bool) -> String { switch self { case .requestPermission: - "Flipcash uses your camera to scan and grab cash" + embedded ? "Start your camera to scan a Tip Card" + : "Flipcash uses your camera to scan and grab cash" case .openSettings: "You need to turn on Camera in Settings to scan Codes" case .startCamera: @@ -73,11 +76,13 @@ nonisolated enum CameraPrompt: Equatable { struct CameraPromptView: View { let prompt: CameraPrompt + /// Whether shown inside the v2 tab-bar UI (affects the permission copy). + var embedded: Bool = false let action: () -> Void var body: some View { VStack(spacing: 40) { - Text(prompt.message) + Text(prompt.message(embedded: embedded)) .frame(maxWidth: 260) .multilineTextAlignment(.center) diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 446434fa8..677862e93 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -104,7 +104,7 @@ private struct ScanScreenContent: View { // in front of the BillCanvas, otherwise it // will swallow all touch events if let cameraPrompt { - CameraPromptView(prompt: cameraPrompt) { + CameraPromptView(prompt: cameraPrompt, embedded: isEmbedded) { performCameraPromptAction(cameraPrompt) } .zIndex(1) From ef015d570155baf8d186c08ad3f64fbed06141ad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 12 Aug 2026 10:52:57 -0400 Subject: [PATCH 8/8] feat(ui): v2 copy for the remaining camera prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the tip-card framing to the other two prompts when embedded in the v2 tab-bar UI: denied → 'Turn on Camera in Settings to scan a Tip Card'; granted but paused → 'Start your camera to scan a Tip Card'. v1 copy unchanged. --- Flipcash/Core/Screens/Main/CameraPromptView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Main/CameraPromptView.swift b/Flipcash/Core/Screens/Main/CameraPromptView.swift index ef16e8b9f..45a14e3a3 100644 --- a/Flipcash/Core/Screens/Main/CameraPromptView.swift +++ b/Flipcash/Core/Screens/Main/CameraPromptView.swift @@ -52,9 +52,11 @@ nonisolated enum CameraPrompt: Equatable { embedded ? "Start your camera to scan a Tip Card" : "Flipcash uses your camera to scan and grab cash" case .openSettings: - "You need to turn on Camera in Settings to scan Codes" + embedded ? "Turn on Camera in Settings to scan a Tip Card" + : "You need to turn on Camera in Settings to scan Codes" case .startCamera: - "You need to start your camera to grab cash" + embedded ? "Start your camera to scan a Tip Card" + : "You need to start your camera to grab cash" } }