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
4 changes: 4 additions & 0 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ class BetaFlags {
accessGranted || Option.allCases.contains { $0.availability == .publicBeta }
}

/// Whether the account switcher is reachable — the Settings row and the
/// You-tab long press share this gate, so the two never disagree.
var canSwitchAccounts: Bool { accessGranted }

/// Enables or disables a beta flag and persists the change to disk.
func set(_ option: Option, enabled: Bool) {
if enabled {
Expand Down
13 changes: 5 additions & 8 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,13 @@ struct DestinationView: View {

case .settingsAccountSelection:
// The action closure dismisses the settings sheet and switches accounts.
// Captured at the modifier site so the AppRouter stays pure-navigation.
// Built at the modifier site so the AppRouter stays pure-navigation.
AccountSelectionScreen(
sessionAuthenticator: container.sessionAuthenticator,
action: { [appRouter = sessionContainer.appRouter, sessionAuthenticator = container.sessionAuthenticator] account in
Task { @MainActor in
appRouter.dismissSheet()
try? await Task.delay(milliseconds: 250)
sessionAuthenticator.switchAccount(to: account.account.mnemonic)
}
}
action: AccountSelectionScreen.switchAccountAction(
router: sessionContainer.appRouter,
sessionAuthenticator: container.sessionAuthenticator
)
)

case .settingsApplicationLogs:
Expand Down
48 changes: 47 additions & 1 deletion Flipcash/Core/Navigation/AppRouter+NestedSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ private struct NestedSheetRootView: View {
case .addMoney:
AddMoneySheetRoot()

case .give, .downloadApp, .tips:
case .give, .downloadApp, .tips, .switchAccount:
// Root-only sheets; `presentNested` logs a warning if one
// lands here.
EmptyView()
Expand Down Expand Up @@ -130,6 +130,52 @@ struct TipsSheetRoot: View {
}
}

/// Root view for the `.switchAccount` sheet — the account switcher, reached by
/// long-pressing the You tab. Owns the `NavigationStack` bound to
/// `router[.switchAccount]`, which only ever holds the root.
struct SwitchAccountSheetRoot: View {

@Environment(AppRouter.self) private var router
@Environment(Container.self) private var container

var body: some View {
@Bindable var router = router
NavigationStack(path: $router[.switchAccount]) {
AccountSelectionScreen(
sessionAuthenticator: container.sessionAuthenticator,
action: AccountSelectionScreen.switchAccountAction(
router: router,
sessionAuthenticator: container.sessionAuthenticator
)
)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
CloseButton(action: router.dismissSheet)
}
}
}
}
}

extension AccountSelectionScreen {

/// The row action for switching accounts from inside a sheet: closes the
/// sheet, waits out its slide-down, then hands the chosen account to the
/// authenticator. Shared by the Settings push and the You-tab sheet.
static func switchAccountAction(
router: AppRouter,
sessionAuthenticator: SessionAuthenticator
) -> (AccountDescription) -> Void {
{ account in
Task { @MainActor in
router.dismissSheet()
try? await Task.delay(milliseconds: 250)
sessionAuthenticator.switchAccount(to: account.account.mnemonic)
}
}
}
}

/// Root view for the `.addMoney` sheet — the content-sized
/// `AddMoneyStartScreen` prompt.
struct AddMoneySheetRoot: View {
Expand Down
7 changes: 7 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ extension AppRouter {
case sendAmount(SendTarget)
/// My Tipcard, or the invitation to create a profile when there isn't one.
case tips
/// The account switcher, opened by long-pressing the You tab. Settings
/// reaches the same screen as a push on its own stack.
case switchAccount

var id: Self { self }

Expand All @@ -39,6 +42,7 @@ extension AppRouter {
case .downloadApp: .downloadApp
case .sendAmount: .sendAmount
case .tips: .tips
case .switchAccount: .switchAccount
}
}

Expand All @@ -53,6 +57,7 @@ extension AppRouter {
case .downloadApp: .downloadApp
case .sendAmount: .sendAmount
case .tips: .tips
case .switchAccount: .switchAccount
}
}

Expand All @@ -63,6 +68,7 @@ extension AppRouter {
case downloadApp
case sendAmount
case tips
case switchAccount
}

var description: String {
Expand All @@ -73,6 +79,7 @@ extension AppRouter {
case .downloadApp: "downloadApp"
case .sendAmount: "sendAmount"
case .tips: "tips"
case .switchAccount: "switchAccount"
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+Stack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ extension AppRouter {
case sendAmount
case tips
case you
case switchAccount

/// The sheet a stack is presented in. Cross-stack navigation uses
/// this to know which top-level modal to surface.
Expand All @@ -40,6 +41,7 @@ extension AppRouter {
case .sendAmount: nil
case .tips: .tips
case .you: nil
case .switchAccount: .switchAccount
}
}

Expand All @@ -61,6 +63,7 @@ extension AppRouter {
case .sendAmount: false
case .tips: true
case .you: true
case .switchAccount: false
}
}

Expand All @@ -74,6 +77,7 @@ extension AppRouter {
case .sendAmount: "sendAmount"
case .tips: "tips"
case .you: "you"
case .switchAccount: "switchAccount"
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Core/Navigation/RootSheetHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ private struct RoutedSheet: View {
// 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)
case .switchAccount:
SwitchAccountSheetRoot()
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions Flipcash/Core/Screens/Main/Home/HomeTabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ struct HomeTabBar: View {
/// carries a picture. Nil keeps the glyph.
var profileSlot: ProfileTabSlot?

/// Called when a tab's button is held. The tap still selects the tab on
/// release, so a long press lands on the tab it was made on.
var onLongPress: ((HomeTab) -> Void)?

private let tabs = HomeTab.allCases

// Figma tab bar (node 8966:1557): 32pt icons in 50pt-tall items (9pt above
Expand All @@ -31,6 +35,9 @@ struct HomeTabBar: View {

private static var itemHeight: CGFloat { iconSize + itemVerticalPadding * 2 }

/// Matches `UILongPressGestureRecognizer`'s default, which the iOS 26 bar uses.
private static let longPressDuration: TimeInterval = 0.5

/// The pill's overall height. It floats over the tab content instead of
/// sitting in the safe area, so a tab that scrolls has to leave room for it
/// itself — see ``HomeTabView/legacyPillClearance``.
Expand Down Expand Up @@ -75,6 +82,13 @@ struct HomeTabBar: View {
.contentShape(Capsule())
}
.buttonStyle(.plain)
// Simultaneous, not high-priority: a high-priority long
// press holds the button's tap hostage until it fails,
// which made quick taps unreliable.
.simultaneousGesture(
LongPressGesture(minimumDuration: Self.longPressDuration)
.onEnded { _ in onLongPress?(tab) }
)
.accessibilityLabel(tab.accessibilityLabel)
.accessibilityValue((badgeCounts[tab] ?? 0) > 0 ? "\(badgeCounts[tab] ?? 0) unread" : "")
.accessibilityAddTraits(selection == tab ? [.isSelected] : [])
Expand Down
81 changes: 74 additions & 7 deletions Flipcash/Core/Screens/Main/Home/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct HomeTabView: View {
@Environment(AppRouter.self) private var router
@Environment(SessionContainer.self) private var sessionContainer
@Environment(Container.self) private var container
@Environment(BetaFlags.self) private var betaFlags

@State private var selection: HomeTab = .initial

Expand Down Expand Up @@ -142,6 +143,21 @@ struct HomeTabView: View {
}
}

/// Holding the You tab opens the account switcher, behind the same gate as
/// the Switch Accounts row in Settings. The other tabs have no hold action.
///
/// The haptic sits after the gate, so a hold that opens nothing stays silent.
private func handleLongPress(on tab: HomeTab) {
switch tab {
case .tipCard:
guard betaFlags.canSwitchAccounts else { return }
Haptics.medium()
router.present(.switchAccount)
case .scan, .wallet, .chat:
break
}
}

/// Brings the tab the router asked for forward and clears the request.
private func selectRequestedTab() {
guard let requested = router.requestedTabStack,
Expand Down Expand Up @@ -186,7 +202,11 @@ struct HomeTabView: View {
// handing it the pair is what makes the icons fill under the finger
// rather than when the drag commits — the binding does not change until
// the finger lifts.
.background(TabBarSelectedIcons(tabs: HomeTab.allCases, profileImages: profileItemImages))
.background(TabBarSelectedIcons(
tabs: HomeTab.allCases,
profileImages: profileItemImages,
onLongPress: handleLongPress(on:)
))
}

/// The unselected icon for a tab. The filled counterpart is handed to UIKit
Expand Down Expand Up @@ -223,7 +243,8 @@ struct HomeTabView: View {
HomeTabBar(
selection: $selection,
badgeCounts: [.chat: chatBadgeCount],
profileSlot: profileSlot
profileSlot: profileSlot,
onLongPress: handleLongPress(on:)
)
// Figma insets the pill ~42pt from each edge (318pt wide on the
// 402pt frame); a fixed margin keeps the floating look across
Expand Down Expand Up @@ -322,7 +343,8 @@ private struct TipCardTab: View {
}

/// Hands each tab bar item its selected glyph, which SwiftUI's `Tab` has no API
/// for.
/// for, and listens for a long press on the bar, which `Tab` has no API for
/// either.
///
/// The point is *when* the swap happens. SwiftUI can only pick a glyph from the
/// selection binding, and the system does not write that back until a drag of
Expand All @@ -342,23 +364,29 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable {
/// enclosing body observes it landing and this representable is updated.
let profileImages: TabBarProfilePhoto.ItemImages?

/// Called with the tab whose item was held.
let onLongPress: (HomeTab) -> Void

func makeUIViewController(context: Context) -> Probe {
Probe(tabs: tabs, profileImages: profileImages)
Probe(tabs: tabs, profileImages: profileImages, onLongPress: onLongPress)
}

func updateUIViewController(_ probe: Probe, context: Context) {
probe.tabs = tabs
probe.profileImages = profileImages
probe.onLongPress = onLongPress
probe.apply()
}

final class Probe: UIViewController {
final class Probe: UIViewController, UIGestureRecognizerDelegate {
var tabs: [HomeTab]
var profileImages: TabBarProfilePhoto.ItemImages?
var onLongPress: (HomeTab) -> Void

init(tabs: [HomeTab], profileImages: TabBarProfilePhoto.ItemImages?) {
init(tabs: [HomeTab], profileImages: TabBarProfilePhoto.ItemImages?, onLongPress: @escaping (HomeTab) -> Void) {
self.tabs = tabs
self.profileImages = profileImages
self.onLongPress = onLongPress
super.init(nibName: nil, bundle: nil)
}

Expand All @@ -381,10 +409,11 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable {
/// of the runloop, because the bar's items do not exist yet on the pass
/// where this controller is first added.
func apply() {
guard let items = resolvedTabBar?.items, items.count == tabs.count else {
guard let bar = resolvedTabBar, let items = bar.items, items.count == tabs.count else {
scheduleRetry()
return
}
installLongPress(on: bar)

for (item, tab) in zip(items, tabs) {
if tab == .tipCard, let profileImages {
Expand All @@ -402,6 +431,44 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable {
}
}

// MARK: - Long press -

/// The bar the recognizer is on, so a rebuilt bar gets its own and the
/// same bar is never given two.
private weak var longPressTarget: UITabBar?

private func installLongPress(on bar: UITabBar) {
guard longPressTarget !== bar else { return }
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
// Without a delegate the bar's own `_UIContinuousSelectionGestureRecognizer`
// — the one that tracks a finger sliding across the items — wins the
// conflict and this one never begins.
recognizer.delegate = self
// Let the bar keep its touches: the press still selects the held tab
// on release, the same as the legacy pill.
recognizer.cancelsTouchesInView = false
bar.addGestureRecognizer(recognizer)
longPressTarget = bar
}

@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
guard recognizer.state == .began, let bar = recognizer.view as? UITabBar else { return }
guard let tab = TabBarItemLocator.tab(at: recognizer.location(in: bar), in: bar, tabs: tabs) else { return }
onLongPress(tab)
}

/// Runs alongside the bar's own recognizers rather than instead of them,
/// so holding an item still selects it and a slide across the bar still
/// tracks.
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
) -> Bool {
true
}

// MARK: - Retry -

private var hasRetryScheduled = false

private func scheduleRetry() {
Expand Down
Loading
Loading