Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions Flipcash/Core/ContainerScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import FlipcashUI
struct ContainerScreen: View {

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

var body: some View {
VStack {
Expand All @@ -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)
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ extension BetaFlags {

case vibrateOnScan
case enableCoinbase
case newUI

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

Expand All @@ -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"
}
}

Expand All @@ -142,6 +147,7 @@ extension BetaFlags {
switch self {
case .vibrateOnScan: return .developer
case .enableCoinbase: return .developer
case .newUI: return .developer
}
}
}
Expand Down
23 changes: 18 additions & 5 deletions Flipcash/Core/Navigation/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)",
])
Expand All @@ -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<H: Hashable>(_ 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))",
])
Expand All @@ -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)
}

Expand All @@ -146,7 +159,7 @@ final class AppRouter {
///
/// No-op with a warning if no sheet is presented.
func replaceTopmostAny<H: Hashable>(_ 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))",
])
Expand All @@ -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)
}

Expand Down
19 changes: 15 additions & 4 deletions Flipcash/Core/Screens/Main/BalanceScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions Flipcash/Core/Screens/Main/CameraPromptView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,20 @@ 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"
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"
}
}

Expand All @@ -73,11 +78,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)

Expand Down
43 changes: 43 additions & 0 deletions Flipcash/Core/Screens/Main/Home/HomeTab.swift
Original file line number Diff line number Diff line change
@@ -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 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
case chat
case tipCard

var id: Int { rawValue }

/// The launch tab — wallet-first, per the v2 design.
static let initial: HomeTab = .wallet

/// The asset-catalog name of the tab's template glyph.
var iconName: String {
switch self {
case .scan: return "NavScan"
case .wallet: return "NavWallet"
case .chat: return "NavChat"
case .tipCard: return "NavTipCard"
}
}

/// 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"
}
}
}
78 changes: 78 additions & 0 deletions Flipcash/Core/Screens/Main/Home/HomeTabBar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// HomeTabBar.swift
// Flipcash
//

import SwiftUI
import FlipcashUI

/// 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):
/// translucent 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 = 26

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(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)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.accessibilityLabel)
.accessibilityAddTraits(selection == tab ? [.isSelected] : [])
}
}
}
}
.frame(height: itemHeight)
.padding(4)
.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())
}
}
}
Loading