From 39f64020771313fbbefabbffe3396ec9a51856b3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 22:00:26 -0400 Subject: [PATCH 1/4] feat(navigation): add the switch-account sheet A root-only `.switchAccount` sheet and stack on AppRouter, wrapping AccountSelectionScreen in its own NavigationStack with a close button. The stack is not tab-hosted, so the cross-stack invariants hold. The dismiss-then-switch closure the Settings push already used moves to `AccountSelectionScreen.switchAccountAction(router:sessionAuthenticator:)` so the push and the sheet run the same code. --- .../AppRouter+DestinationView.swift | 13 ++-- .../Navigation/AppRouter+NestedSheet.swift | 48 +++++++++++++- .../AppRouter+SheetPresentation.swift | 7 ++ .../Core/Navigation/AppRouter+Stack.swift | 4 ++ Flipcash/Core/Navigation/RootSheetHost.swift | 2 + .../Navigation/SwitchAccountSheetTests.swift | 66 +++++++++++++++++++ 6 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 FlipcashTests/Navigation/SwitchAccountSheetTests.swift 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/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) + } +} From ff9936df029bdb4ab4041789639d5cd469429b18 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 22:00:27 -0400 Subject: [PATCH 2/4] feat(home): long-press the You tab to open the account switcher Behind `BetaFlags.canSwitchAccounts`, the same gate as the Switch Accounts row in Settings. On both bars the press still selects the You tab on release. Legacy pill (iOS 18 to 25): a simultaneous LongPressGesture on each tab button. A high-priority gesture held the button's tap until the press failed, which made quick taps flaky. iOS 26 TabView: the TabBarSelectedIcons probe already finds the UITabBar, so it adds one UILongPressGestureRecognizer per bar with cancelsTouchesInView off. TabBarItemLocator maps the press point back to a HomeTab from the bar's button frames. iOS 26 draws every item twice at the same frame (a plain copy and the one the glass lens reveals), so coinciding frames collapse to one before the count is compared against the tabs. The locator test drives a real windowed UITabBarController so that layout is what gets checked. --- Flipcash/Core/Controllers/BetaFlags.swift | 4 ++ .../Core/Screens/Main/Home/HomeTabBar.swift | 14 ++++ .../Core/Screens/Main/Home/HomeTabView.swift | 62 ++++++++++++++++-- .../Screens/Main/Home/TabBarItemLocator.swift | 55 ++++++++++++++++ .../SettingsAdvancedFeaturesScreen.swift | 2 +- .../Navigation/TabBarItemLocatorTests.swift | 64 +++++++++++++++++++ 6 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift create mode 100644 FlipcashTests/Navigation/TabBarItemLocatorTests.swift 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/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..2b20ffc91 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,18 @@ 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. + private func handleLongPress(on tab: HomeTab) { + switch tab { + case .tipCard: + guard betaFlags.canSwitchAccounts else { return } + 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 +199,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 +240,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 +340,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 +361,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 { 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 +406,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 +428,30 @@ 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)) + // 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) + } + + // 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..ea8fe189a --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift @@ -0,0 +1,55 @@ +// +// 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 contains `point` (in the bar's coordinates), or nil + /// when the bar's buttons do not pair up with `tabs` or none holds the point. + 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 } + guard let index = frames.firstIndex(where: { $0.contains(point) }) 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/TabBarItemLocatorTests.swift b/FlipcashTests/Navigation/TabBarItemLocatorTests.swift new file mode 100644 index 000000000..cdb01c2d1 --- /dev/null +++ b/FlipcashTests/Navigation/TabBarItemLocatorTests.swift @@ -0,0 +1,64 @@ +// +// 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 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) + } +} From ca7ac16b22c839d1498a09c9e5c8ccffc3317324 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 22:46:37 -0400 Subject: [PATCH 3/4] fix(home): make the You tab long press fire on the iOS 26 tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `UILongPressGestureRecognizer` added to the native `UITabBar` never began: the bar's own `_UIContinuousSelectionGestureRecognizer` — the one tracking a finger sliding across the items — won the conflict. Give the recognizer a delegate that allows simultaneous recognition, so it runs alongside the bar's own recognizers and holding an item still selects it on release. The same press also exposed a latent locator bug. iOS 26 lays the items out wider than their pitch (114pt buttons every 85pt on a 402pt bar), so adjacent frames overlap and a press near a boundary falls inside two; taking the first containing frame could resolve to the wrong tab. The nearest centre now wins. --- .../Core/Screens/Main/Home/HomeTabView.swift | 16 +++++++++++++++- .../Screens/Main/Home/TabBarItemLocator.swift | 13 +++++++++++-- .../Navigation/TabBarItemLocatorTests.swift | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 2b20ffc91..32b2a5ec6 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -375,7 +375,7 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable { probe.apply() } - final class Probe: UIViewController { + final class Probe: UIViewController, UIGestureRecognizerDelegate { var tabs: [HomeTab] var profileImages: TabBarProfilePhoto.ItemImages? var onLongPress: (HomeTab) -> Void @@ -437,6 +437,10 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable { 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 @@ -450,6 +454,16 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable { 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 diff --git a/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift b/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift index ea8fe189a..3213368d6 100644 --- a/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift +++ b/Flipcash/Core/Screens/Main/Home/TabBarItemLocator.swift @@ -44,12 +44,21 @@ enum TabBarItemLocator { return frames.sorted { $0.minX < $1.minX } } - /// The tab whose item contains `point` (in the bar's coordinates), or nil + /// 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 } - guard let index = frames.firstIndex(where: { $0.contains(point) }) 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/FlipcashTests/Navigation/TabBarItemLocatorTests.swift b/FlipcashTests/Navigation/TabBarItemLocatorTests.swift index cdb01c2d1..8188ffcda 100644 --- a/FlipcashTests/Navigation/TabBarItemLocatorTests.swift +++ b/FlipcashTests/Navigation/TabBarItemLocatorTests.swift @@ -55,6 +55,23 @@ struct TabBarItemLocatorTests { #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() From 2479ec4d9e32b310fd02301a22ea0425cf9f776f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 22:58:08 -0400 Subject: [PATCH 4/4] feat(home): buzz when the You tab hold opens the account switcher `Haptics.medium()` from the one place both bars converge, `handleLongPress`, placed after the `canSwitchAccounts` gate so a hold that opens nothing stays silent. --- Flipcash/Core/Screens/Main/Home/HomeTabView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 32b2a5ec6..1c770fd01 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -145,10 +145,13 @@ 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