diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index 3fc423d84..837492261 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -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 { diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 93bab37c6..6ff6c3ee9 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -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: diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 1ea855775..650b245c9 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 .give, .downloadApp, .tips: + case .give, .downloadApp, .tips, .switchAccount: // Root-only sheets; `presentNested` logs a warning if one // lands here. EmptyView() @@ -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 { diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 8aa97f77c..74c683255 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -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 } @@ -39,6 +42,7 @@ extension AppRouter { case .downloadApp: .downloadApp case .sendAmount: .sendAmount case .tips: .tips + case .switchAccount: .switchAccount } } @@ -53,6 +57,7 @@ extension AppRouter { case .downloadApp: .downloadApp case .sendAmount: .sendAmount case .tips: .tips + case .switchAccount: .switchAccount } } @@ -63,6 +68,7 @@ extension AppRouter { case downloadApp case sendAmount case tips + case switchAccount } var description: String { @@ -73,6 +79,7 @@ extension AppRouter { case .downloadApp: "downloadApp" case .sendAmount: "sendAmount" case .tips: "tips" + case .switchAccount: "switchAccount" } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift index f3ad7ef86..33675cb3c 100644 --- a/Flipcash/Core/Navigation/AppRouter+Stack.swift +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -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. @@ -40,6 +41,7 @@ extension AppRouter { case .sendAmount: nil case .tips: .tips case .you: nil + case .switchAccount: .switchAccount } } @@ -61,6 +63,7 @@ extension AppRouter { case .sendAmount: false case .tips: true case .you: true + case .switchAccount: false } } @@ -74,6 +77,7 @@ extension AppRouter { case .sendAmount: "sendAmount" case .tips: "tips" case .you: "you" + case .switchAccount: "switchAccount" } } } diff --git a/Flipcash/Core/Navigation/RootSheetHost.swift b/Flipcash/Core/Navigation/RootSheetHost.swift index 8b24e1448..b6c3824b1 100644 --- a/Flipcash/Core/Navigation/RootSheetHost.swift +++ b/Flipcash/Core/Navigation/RootSheetHost.swift @@ -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() } } } diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift index 5062e746e..9bb45c4e2 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift @@ -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 @@ -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``. @@ -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] : []) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index ac4709093..1c770fd01 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -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 @@ -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, @@ -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 @@ -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 @@ -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 @@ -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) } @@ -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 { @@ -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() { diff --git a/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift b/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift new file mode 100644 index 000000000..3213368d6 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift @@ -0,0 +1,64 @@ +// +// TabBarItemLocator.swift +// Flipcash +// + +import UIKit + +/// Maps a point in a `UITabBar` back to the `HomeTab` whose item is under it. +/// +/// UIKit exposes no API from a touch to a `UITabBarItem`, so this reads the +/// bar's item buttons (the `UIControl`s in its view tree) in left-to-right +/// order, which is the order the items were given in. Anything that does not +/// line up — a different button count, a point between items — resolves to +/// nil rather than guessing. +@MainActor +enum TabBarItemLocator { + + /// The item buttons' frames in the bar's coordinate space, left to right, + /// one per item. + /// + /// iOS 26 draws every item twice — a plain copy and a selected copy the + /// glass lens reveals — at the same frame, so buttons that share a frame + /// collapse to one. Earlier systems have a single button per item. + static func itemFrames(in bar: UITabBar) -> [CGRect] { + var controls: [UIView] = [] + var queue = bar.subviews + while !queue.isEmpty { + let view = queue.removeFirst() + if view is UIControl { + controls.append(view) + } else { + queue.append(contentsOf: view.subviews) + } + } + + var frames: [CGRect] = [] + for control in controls where !control.isHidden && control.bounds.width > 0 { + let frame = bar.convert(control.bounds, from: control) + let isDuplicate = frames.contains { abs($0.midX - frame.midX) < 1 && abs($0.midY - frame.midY) < 1 } + if !isDuplicate { + frames.append(frame) + } + } + return frames.sorted { $0.minX < $1.minX } + } + + /// The tab whose item holds `point` (in the bar's coordinates), or nil + /// when the bar's buttons do not pair up with `tabs` or none holds the point. + /// + /// iOS 26 lays the buttons out wider than their pitch — 114pt buttons every + /// 85pt on a 402pt bar — so neighbours overlap and a press near a boundary + /// falls inside two. The nearer centre wins, which is the item the press + /// looks like it is on. + static func tab(at point: CGPoint, in bar: UITabBar, tabs: [HomeTab]) -> HomeTab? { + let frames = itemFrames(in: bar) + guard frames.count == tabs.count else { return nil } + + let holding = frames.indices.filter { frames[$0].contains(point) } + guard let index = holding.min(by: { abs(frames[$0].midX - point.x) < abs(frames[$1].midX - point.x) }) + else { return nil } + + return tabs[index] + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift index 18d1d5ba5..c8242ed50 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift @@ -53,7 +53,7 @@ struct SettingsAdvancedFeaturesScreen: View { // Sits with the other beta tool rather than on My Account: it is // a way out of this account, next to Log Out, not a detail of it. - if betaFlags.accessGranted { + if betaFlags.canSwitchAccounts { SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { router.push(.settingsAccountSelection) } diff --git a/FlipcashTests/Navigation/SwitchAccountSheetTests.swift b/FlipcashTests/Navigation/SwitchAccountSheetTests.swift new file mode 100644 index 000000000..a40a7338e --- /dev/null +++ b/FlipcashTests/Navigation/SwitchAccountSheetTests.swift @@ -0,0 +1,66 @@ +// +// SwitchAccountSheetTests.swift +// FlipcashTests +// + +import SwiftUI +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Switch Account sheet") +struct SwitchAccountSheetTests { + + @Test("the sheet and its stack map to each other") + func switchAccount_sheetAndStackAreInverses() { + #expect(AppRouter.SheetPresentation.switchAccount.stack == .switchAccount) + #expect(AppRouter.Stack.switchAccount.sheet == .switchAccount) + #expect(AppRouter.SheetPresentation.switchAccount.caseKind == .switchAccount) + #expect(AppRouter.SheetPresentation.switchAccount.description == "switchAccount") + #expect(AppRouter.Stack.switchAccount.description == "switchAccount") + } + + @Test("the switcher is a sheet, not a tab — nothing in the tab bar hosts it") + func switchAccount_isNotTabHosted() { + #expect(AppRouter.Stack.switchAccount.isTabHosted == false) + #expect(HomeTab.allCases.allSatisfy { $0.pushStack != .switchAccount }) + } + + @Test("a long press from a tab presents the switcher as the root sheet") + func present_fromTab_becomesRootSheet() { + let router = AppRouter() + router.activeTabStack = .you + + router.present(.switchAccount) + + #expect(router.rootSheet == .switchAccount) + #expect(router.presentedSheet == .switchAccount) + #expect(router[.switchAccount].isEmpty) + } + + @Test("closing the switcher leaves the tab where it was") + func dismiss_returnsToTab() { + let router = AppRouter() + router.activeTabStack = .you + router.push(.settingsMyAccount) + router.present(.switchAccount) + + router.dismissSheet() + + #expect(router.presentedSheet == nil) + #expect(router.activeTabStack == .you) + #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount)) + } + + @Test("a self tipcard scan dismisses the switcher on its way to the You tab") + func showOwnTipCard_dismissesSwitcher() { + let router = AppRouter() + router.present(.switchAccount) + + router.showOwnTipCard() + + #expect(router.presentedSheet == nil) + #expect(router.requestedTabStack == .you) + } +} diff --git a/FlipcashTests/Navigation/TabBarItemLocatorTests.swift b/FlipcashTests/Navigation/TabBarItemLocatorTests.swift new file mode 100644 index 000000000..8188ffcda --- /dev/null +++ b/FlipcashTests/Navigation/TabBarItemLocatorTests.swift @@ -0,0 +1,81 @@ +// +// TabBarItemLocatorTests.swift +// FlipcashTests +// + +import UIKit +import Testing +@testable import Flipcash + +/// Drives a real, windowed `UITabBarController` so the item lookup is checked +/// against the bar UIKit actually builds on this OS, not a stand-in. +@MainActor +@Suite("Tab bar item locator") +struct TabBarItemLocatorTests { + + private func makeWindowedTabBar() async -> UITabBar { + let controller = UITabBarController() + controller.viewControllers = HomeTab.allCases.map { tab in + let child = UIViewController() + child.tabBarItem = UITabBarItem(title: tab.accessibilityLabel, image: nil, tag: tab.rawValue) + return child + } + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + window.rootViewController = controller + window.makeKeyAndVisible() + for _ in 0..<3 { + controller.view.layoutIfNeeded() + controller.tabBar.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(40)) + } + return controller.tabBar + } + + @Test("one item frame per tab, in display order") + func itemFrames_oneLaidOutFramePerTab() async { + let bar = await makeWindowedTabBar() + let frames = TabBarItemLocator.itemFrames(in: bar) + #expect(frames.count == HomeTab.allCases.count) + let lefts = frames.map(\.minX) + #expect(lefts == lefts.sorted()) + #expect(frames.allSatisfy { $0.width > 0 && $0.height > 0 }) + } + + @Test("a point inside the last item resolves to the You tab") + func tab_atLastItemCenter_isYou() async { + let bar = await makeWindowedTabBar() + let frames = TabBarItemLocator.itemFrames(in: bar) + guard let last = frames.last, let first = frames.first else { + Issue.record("no item frames found") + return + } + let lastCenter = CGPoint(x: last.midX, y: last.midY) + let firstCenter = CGPoint(x: first.midX, y: first.midY) + #expect(TabBarItemLocator.tab(at: lastCenter, in: bar, tabs: HomeTab.allCases) == .tipCard) + #expect(TabBarItemLocator.tab(at: firstCenter, in: bar, tabs: HomeTab.allCases) == .scan) + } + + @Test("a point where two items overlap resolves to the nearer one") + func tab_inOverlap_resolvesToNearerCentre() async { + let bar = await makeWindowedTabBar() + let frames = TabBarItemLocator.itemFrames(in: bar) + guard frames.count == HomeTab.allCases.count, frames[2].intersects(frames[3]) else { + // Only iOS 26 lays the items out wider than their pitch; on a bar + // whose items do not overlap there is nothing to disambiguate. + return + } + + // Just inside the last item's half of the shared strip. + let midpoint = (frames[2].midX + frames[3].midX) / 2 + let point = CGPoint(x: midpoint + 1, y: frames[3].midY) + #expect(frames[2].contains(point) && frames[3].contains(point)) + #expect(TabBarItemLocator.tab(at: point, in: bar, tabs: HomeTab.allCases) == .tipCard) + } + + @Test("a point outside every item resolves to nothing") + func tab_outsideItems_isNil() async { + let bar = await makeWindowedTabBar() + let offBar = CGPoint(x: -50, y: -50) + #expect(TabBarItemLocator.tab(at: offBar, in: bar, tabs: HomeTab.allCases) == nil) + } +}