From db2ff28efc8ef69a1f7ce898e9013ce8de089757 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 20:57:39 -0400 Subject: [PATCH 01/12] fix(chat): tint the selected filter chip instead of filling it white A solid white chip was the brightest thing on the dark chat list. Selected chips now take white at 16% over the background with the main text colour; unselected chips have no fill and use the secondary text colour, per the shared chat-archive spec. --- .../Sources/FlipcashUI/Views/ButtonStyles/Chip.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift index c75bd873f..8b253025e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift @@ -84,17 +84,19 @@ public struct SelectableChipButtonStyle: ButtonStyle { public func makeBody(configuration: Configuration) -> some View { configuration.label .font(.appTextSmall) - .foregroundStyle(isSelected ? Color.backgroundMain : Color.textMain) + .foregroundStyle(isSelected ? Color.textMain : Color.textSecondary) .padding(.horizontal, 12) .padding(.vertical, 7) - .background(Capsule().fill(isSelected ? Color.textMain : Color.backgroundRow)) + // A tint, not a solid fill: solid white was the brightest thing on the dark screen. + .background(Capsule().fill(isSelected ? Color.white.opacity(0.16) : Color.clear)) .opacity(configuration.isPressed ? 0.7 : 1) .contentShape(Capsule()) } } public extension ButtonStyle where Self == SelectableChipButtonStyle { - /// A filter chip filled with the main text colour when selected, the row fill otherwise. + /// A filter chip with a faint white tint and the main text colour when selected, and no fill + /// with the secondary text colour otherwise. static func selectableChip(isSelected: Bool) -> SelectableChipButtonStyle { SelectableChipButtonStyle(isSelected: isSelected) } From 85247ab54e67076603edbd703cef835c8dbc231b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 20:57:39 -0400 Subject: [PATCH 02/12] fix(chat): restyle the archive undo toast to the shared design The toast is now a full-width frosted pill inset 12pt from each edge, with a white 10% hairline, an 18pt archive icon, the message at 14pt, and Undo in its own pill filled white at 12%. A swipe down dismisses it, and the message is announced to VoiceOver each time a toast appears, including when a new archive replaces the current one. The 4s lifetime and single-toast behaviour are unchanged. --- .../Core/Screens/Main/Tips/ArchivedRow.swift | 48 +++++++++++++++---- .../Main/Tips/TipConversationsScreen.swift | 15 ++++-- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift index 3bf2ee313..84aafa631 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift @@ -42,24 +42,54 @@ struct ArchivedRow: View { } } -/// "Chat archived" with a tappable Undo. `ToastLabel` is text-only, so this is its own small view in -/// the same capsule treatment; the screen dismisses it with a timer. +/// "Chat archived" with a tappable Undo, in the undo-toast design shared with Android: a full-width +/// frosted pill. The screen owns its lifetime; a swipe down calls `onDismiss`. struct ArchiveUndoToast: View { let onUndo: () -> Void + let onDismiss: () -> Void + + @State private var dragOffset: CGFloat = 0 var body: some View { - HStack(spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "archivebox") + .font(.system(size: 18)) + .foregroundStyle(Color.textSecondary) + .accessibilityHidden(true) Text("Chat archived") + .font(.default(size: 14, weight: .medium)) .foregroundStyle(Color.textMain) - Button("Undo", action: onUndo) - .foregroundStyle(Color.textMain) - .bold() - .accessibilityIdentifier("chat-archive-undo") + Spacer(minLength: 0) + Button(action: onUndo) { + Text("Undo") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.white.opacity(0.12), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("chat-archive-undo") } - .font(.appTextSmall) - .padding(.horizontal, 14) + .padding(.leading, 16) + .padding(.trailing, 8) .padding(.vertical, 8) .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) + .padding(.horizontal, 12) + .offset(y: dragOffset) + .gesture( + DragGesture() + .onChanged { dragOffset = max(0, $0.translation.height) } + .onEnded { value in + if value.translation.height > 24 || value.predictedEndTranslation.height > 60 { + onDismiss() + } else { + withAnimation(.spring) { dragOffset = 0 } + } + } + ) } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 616304c79..a3d97cc3c 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -115,11 +115,14 @@ struct TipConversationsScreen: View { } .overlay(alignment: .bottom) { if undoTarget != nil { - ArchiveUndoToast { - if let id = undoTarget { conversationController.unarchive(id) } - undoTarget = nil - } - .padding(.bottom, 16) + ArchiveUndoToast( + onUndo: { + if let id = undoTarget { conversationController.unarchive(id) } + undoTarget = nil + }, + onDismiss: { undoTarget = nil } + ) + .padding(.bottom, 12) .transition(.opacity.combined(with: .move(edge: .bottom))) } } @@ -127,6 +130,8 @@ struct TipConversationsScreen: View { // Dismisses itself, like the reaction error toast; a new archive restarts the clock. .task(id: undoTarget) { guard undoTarget != nil else { return } + // Here rather than in the toast, so a replacing archive announces again. + AccessibilityNotification.Announcement("Chat archived").post() try? await Task.sleep(for: .seconds(4)) if !Task.isCancelled { undoTarget = nil } } From d6a3c215c7122d54773d77893b15a54f5dccedba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 21:26:51 -0400 Subject: [PATCH 03/12] fix(chat): outline the filter chips in both states A 1pt white 10% hairline, matching the undo toast's border, so unselected chips read as chips rather than loose labels. --- FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift index 8b253025e..393505845 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift @@ -89,14 +89,15 @@ public struct SelectableChipButtonStyle: ButtonStyle { .padding(.vertical, 7) // A tint, not a solid fill: solid white was the brightest thing on the dark screen. .background(Capsule().fill(isSelected ? Color.white.opacity(0.16) : Color.clear)) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) .opacity(configuration.isPressed ? 0.7 : 1) .contentShape(Capsule()) } } public extension ButtonStyle where Self == SelectableChipButtonStyle { - /// A filter chip with a faint white tint and the main text colour when selected, and no fill - /// with the secondary text colour otherwise. + /// A hairline-outlined filter chip with a faint white tint and the main text colour when + /// selected, and no fill with the secondary text colour otherwise. static func selectableChip(isSelected: Bool) -> SelectableChipButtonStyle { SelectableChipButtonStyle(isSelected: isSelected) } From a34a09b6210d6be5155ed78e8d158f968d425295 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 23:38:06 -0400 Subject: [PATCH 04/12] refactor(ui): extract FloatingToast and use it for the developer toast The undo toast's pill (frosted fill, white 10% outline, optional icon and action pill, optional swipe-down dismiss) moves into FlipcashUI as FloatingToast. ArchiveUndoToast now wraps it, and the You tab's version-unlock countdown uses it in place of ToastLabel. The countdown toast is taller than ToastLabel, so it now sits on the footer's top edge rather than at a fixed offset, keeping the version text uncovered. It keeps the you-version-toast identifier on the message text, which the UI tests match as static text. --- .../Core/Screens/Main/Tips/ArchivedRow.swift | 48 +------- .../Core/Screens/Main/You/YouScreen.swift | 6 +- .../Views/Containers/FloatingToast.swift | 112 ++++++++++++++++++ 3 files changed, 121 insertions(+), 45 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift index 84aafa631..cc1464986 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift @@ -42,54 +42,18 @@ struct ArchivedRow: View { } } -/// "Chat archived" with a tappable Undo, in the undo-toast design shared with Android: a full-width -/// frosted pill. The screen owns its lifetime; a swipe down calls `onDismiss`. +/// "Chat archived" with a tappable Undo. The screen owns its lifetime; a swipe down calls `onDismiss`. struct ArchiveUndoToast: View { let onUndo: () -> Void let onDismiss: () -> Void - @State private var dragOffset: CGFloat = 0 - var body: some View { - HStack(spacing: 10) { - Image(systemName: "archivebox") - .font(.system(size: 18)) - .foregroundStyle(Color.textSecondary) - .accessibilityHidden(true) - Text("Chat archived") - .font(.default(size: 14, weight: .medium)) - .foregroundStyle(Color.textMain) - Spacer(minLength: 0) - Button(action: onUndo) { - Text("Undo") - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background(Color.white.opacity(0.12), in: Capsule()) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("chat-archive-undo") - } - .padding(.leading, 16) - .padding(.trailing, 8) - .padding(.vertical, 8) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) - .padding(.horizontal, 12) - .offset(y: dragOffset) - .gesture( - DragGesture() - .onChanged { dragOffset = max(0, $0.translation.height) } - .onEnded { value in - if value.translation.height > 24 || value.predictedEndTranslation.height > 60 { - onDismiss() - } else { - withAnimation(.spring) { dragOffset = 0 } - } - } + FloatingToast( + "Chat archived", + systemImage: "archivebox", + action: .init("Undo", accessibilityIdentifier: "chat-archive-undo", handler: onUndo), + onDismiss: onDismiss ) } } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 662154fa6..cdf663672 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -479,13 +479,13 @@ struct YouScreen: View { // out from under the finger. .overlay(alignment: .top) { if let message = versionUnlock.message { - ToastLabel(message) - .offset(y: -22) + FloatingToast(message, messageIdentifier: "you-version-toast") + // Sits on the footer's top edge, so the taller pill never covers the version. + .alignmentGuide(.top) { $0[.bottom] + 4 } .transition( .offset(x: 0, y: 20) .combined(with: .opacity.animation(.easeOutFastest)) ) - .accessibilityIdentifier("you-version-toast") } } .animation(.springFaster, value: versionUnlock.message) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift new file mode 100644 index 000000000..8fd96f91b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift @@ -0,0 +1,112 @@ +// +// FloatingToast.swift +// FlipcashUI +// + +import SwiftUI + +/// The undo-toast design shared with Android: a full-width frosted pill with an optional leading +/// icon and an optional trailing action. The caller owns placement, lifetime and transitions. +public struct FloatingToast: View { + + /// A trailing button drawn in its own pill. + public struct Action { + let title: String + let accessibilityIdentifier: String? + let handler: () -> Void + + /// An action titled `title` that runs `handler` when tapped. + public init(_ title: String, accessibilityIdentifier: String? = nil, handler: @escaping () -> Void) { + self.title = title + self.accessibilityIdentifier = accessibilityIdentifier + self.handler = handler + } + } + + private let message: String + private let systemImage: String? + private let messageIdentifier: String? + private let action: Action? + private let onDismiss: (() -> Void)? + + @State private var dragOffset: CGFloat = 0 + + /// A toast showing `message`; a swipe down calls `onDismiss` when one is given. + /// + /// `messageIdentifier` lands on the message text alone, so UI tests can match it as static text. + public init( + _ message: String, + systemImage: String? = nil, + messageIdentifier: String? = nil, + action: Action? = nil, + onDismiss: (() -> Void)? = nil + ) { + self.message = message + self.systemImage = systemImage + self.messageIdentifier = messageIdentifier + self.action = action + self.onDismiss = onDismiss + } + + public var body: some View { + HStack(spacing: 10) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 18)) + .foregroundStyle(Color.textSecondary) + .accessibilityHidden(true) + } + Text(message) + .font(.default(size: 14, weight: .medium)) + .foregroundStyle(Color.textMain) + .accessibilityIdentifier(messageIdentifier ?? "") + Spacer(minLength: 0) + if let action { + Button(action: action.handler) { + Text(action.title) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.white.opacity(0.12), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(action.accessibilityIdentifier ?? "") + } + } + .padding(.leading, 16) + .padding(.trailing, action == nil ? 16 : 8) + .padding(.vertical, 8) + // The height a one-line toast has with an action, so toasts without one match it. + .frame(minHeight: 44) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) + .padding(.horizontal, 12) + .offset(y: dragOffset) + .gesture(dismissGesture, isEnabled: onDismiss != nil) + } + + private var dismissGesture: some Gesture { + DragGesture() + .onChanged { dragOffset = max(0, $0.translation.height) } + .onEnded { value in + if value.translation.height > 24 || value.predictedEndTranslation.height > 60 { + onDismiss?() + } else { + withAnimation(.spring) { dragOffset = 0 } + } + } + } +} + +// MARK: - Previews - + +#Preview { + VStack(spacing: 16) { + FloatingToast("Chat archived", systemImage: "archivebox", action: .init("Undo") {}) + FloatingToast("You are now 3 steps away from being a developer") + } + .frame(maxHeight: .infinity) + .background(Color.backgroundMain) +} From e681dcd53442f2964cd0d9a2e45d5e51d6602628 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 23:57:15 -0400 Subject: [PATCH 05/12] feat(ui): let FloatingToast fit its content on the You tab The archive toast keeps the full-width pill, inset 12pt. The developer toast on the You tab uses the new .fit width: it hugs its message, caps at the floating tab bar's width, and passes taps through to the version footer so the countdown keeps counting. --- .../Core/Screens/Main/You/YouScreen.swift | 7 ++++- .../Views/Containers/FloatingToast.swift | 29 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index cdf663672..d705205fb 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -479,9 +479,14 @@ struct YouScreen: View { // out from under the finger. .overlay(alignment: .top) { if let message = versionUnlock.message { - FloatingToast(message, messageIdentifier: "you-version-toast") + FloatingToast(message, messageIdentifier: "you-version-toast", width: .fit) + // Back out to the screen edges, so the toast caps at the tab bar's width + // rather than the content column's. + .padding(.horizontal, -Self.horizontalInset) // Sits on the footer's top edge, so the taller pill never covers the version. .alignmentGuide(.top) { $0[.bottom] + 4 } + // Taps land on the version underneath, so the countdown keeps counting. + .allowsHitTesting(false) .transition( .offset(x: 0, y: 20) .combined(with: .opacity.animation(.easeOutFastest)) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift index 8fd96f91b..a8272ea66 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift @@ -5,10 +5,18 @@ import SwiftUI -/// The undo-toast design shared with Android: a full-width frosted pill with an optional leading -/// icon and an optional trailing action. The caller owns placement, lifetime and transitions. +/// The undo-toast design shared with Android: a frosted pill with an optional leading icon and an +/// optional trailing action. A longer message wraps. The caller owns placement, lifetime and transitions. public struct FloatingToast: View { + /// How wide the pill is drawn. + public enum Width { + /// Spans the screen, inset 12pt from each edge, as the shared design specifies. + case fill + /// Hugs its content, never wider than the floating tab bar. + case fit + } + /// A trailing button drawn in its own pill. public struct Action { let title: String @@ -27,10 +35,15 @@ public struct FloatingToast: View { private let systemImage: String? private let messageIdentifier: String? private let action: Action? + private let width: Width private let onDismiss: (() -> Void)? @State private var dragOffset: CGFloat = 0 + /// The native floating tab bar's inset from each screen edge, measured on iOS 26; keeping the + /// same inset caps a `.fit` toast at the bar's width. + private static let tabBarInset: CGFloat = 21 + /// A toast showing `message`; a swipe down calls `onDismiss` when one is given. /// /// `messageIdentifier` lands on the message text alone, so UI tests can match it as static text. @@ -39,12 +52,14 @@ public struct FloatingToast: View { systemImage: String? = nil, messageIdentifier: String? = nil, action: Action? = nil, + width: Width = .fill, onDismiss: (() -> Void)? = nil ) { self.message = message self.systemImage = systemImage self.messageIdentifier = messageIdentifier self.action = action + self.width = width self.onDismiss = onDismiss } @@ -59,8 +74,12 @@ public struct FloatingToast: View { Text(message) .font(.default(size: 14, weight: .medium)) .foregroundStyle(Color.textMain) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier(messageIdentifier ?? "") - Spacer(minLength: 0) + if width == .fill { + Spacer(minLength: 0) + } if let action { Button(action: action.handler) { Text(action.title) @@ -82,7 +101,7 @@ public struct FloatingToast: View { .frame(minHeight: 44) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) - .padding(.horizontal, 12) + .padding(.horizontal, width == .fill ? 12 : Self.tabBarInset) .offset(y: dragOffset) .gesture(dismissGesture, isEnabled: onDismiss != nil) } @@ -105,7 +124,7 @@ public struct FloatingToast: View { #Preview { VStack(spacing: 16) { FloatingToast("Chat archived", systemImage: "archivebox", action: .init("Undo") {}) - FloatingToast("You are now 3 steps away from being a developer") + FloatingToast("You are now 3 steps away from being a developer", width: .fit) } .frame(maxHeight: .infinity) .background(Color.backgroundMain) From 809de4e03372080804c6d76cc11618cd9af90a01 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 23:57:15 -0400 Subject: [PATCH 06/12] fix(chat): draw the archive swipe action as an outline Swipe actions fill SF Symbols by default; the shared design uses the outlined archivebox. --- Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index a3d97cc3c..b046e49c1 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -216,7 +216,9 @@ struct TipConversationsScreen: View { conversationController.archive(conversation.id) undoTarget = conversation.id } label: { + // Swipe actions fill symbols by default; the shared design uses the outline. Image(systemName: "archivebox") + .environment(\.symbolVariants, .none) } .tint(.backgroundRow) // A swipe action is also a VoiceOver custom action, next to the mute one. From c0a3a9e62a26785b160919094e2ce2ad7782fb84 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 00:07:06 -0400 Subject: [PATCH 07/12] feat(ui): grow the archive toast out of the tab bar HomeTabView publishes the floating tab bar's inset while the bar shows. FloatingToast caps its width at the bar's, and floatingToastTransition() swells the toast up from behind the bar, or slides it in from the bottom edge when no bar is showing. SwiftUI has no glass morph out of the native UITabBar, and the iOS 26.1 tabViewBottomAccessory cross-fades and wraps its content in a second glass capsule, so this approximates the morph. --- .../Core/Screens/Main/Home/HomeTabView.swift | 6 +- .../Main/Tips/TipConversationsScreen.swift | 4 +- .../Views/Containers/FloatingToast.swift | 66 +++++++++++++++++-- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 3f7f9288d..c037ca7dc 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -197,6 +197,8 @@ struct HomeTabView: View { Tab(value: tab) { tabContent(for: tab) .toolbar(isTabBarHidden ? .hidden : .visible, for: .tabBar) + // The system bar's inset, measured on a 402pt-wide iPhone. + .environment(\.floatingTabBar, isTabBarHidden ? nil : FloatingTabBar(horizontalInset: 21)) } label: { tabLabel(for: tab) .accessibilityLabel(tab.title) @@ -246,6 +248,7 @@ struct HomeTabView: View { } private static let pillBottomMargin: CGFloat = 8 + private static let legacyPillInset: CGFloat = 42 /// The room the legacy pill occupies above the safe area. The pill is an /// overlay, so unlike the iOS 26 system bar it adds nothing to the safe @@ -259,6 +262,7 @@ struct HomeTabView: View { tabContent(for: selection) .frame(maxWidth: .infinity, maxHeight: .infinity) .transition(.opacity) + .environment(\.floatingTabBar, isTabBarHidden ? nil : FloatingTabBar(horizontalInset: Self.legacyPillInset)) if !isTabBarHidden { HomeTabBar( @@ -270,7 +274,7 @@ struct HomeTabView: View { // Figma insets the pill ~42pt from each edge (318pt wide on the // 402pt frame); a fixed margin keeps the floating look across // device widths. - .padding(.horizontal, 42) + .padding(.horizontal, Self.legacyPillInset) .padding(.bottom, Self.pillBottomMargin) .transition(.move(edge: .bottom).combined(with: .opacity)) } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index b046e49c1..5a73313a6 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -123,10 +123,10 @@ struct TipConversationsScreen: View { onDismiss: { undoTarget = nil } ) .padding(.bottom, 12) - .transition(.opacity.combined(with: .move(edge: .bottom))) + .floatingToastTransition() } } - .animation(.default, value: undoTarget) + .animation(.spring(duration: 0.4, bounce: 0.2), value: undoTarget) // Dismisses itself, like the reaction error toast; a new archive restarts the clock. .task(id: undoTarget) { guard undoTarget != nil else { return } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift index a8272ea66..628142eef 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift @@ -9,11 +9,11 @@ import SwiftUI /// optional trailing action. A longer message wraps. The caller owns placement, lifetime and transitions. public struct FloatingToast: View { - /// How wide the pill is drawn. + /// How wide the pill is drawn. Either way it is never wider than the floating tab bar. public enum Width { - /// Spans the screen, inset 12pt from each edge, as the shared design specifies. + /// As wide as the floating tab bar, or 12pt from each screen edge when no bar shows. case fill - /// Hugs its content, never wider than the floating tab bar. + /// Hugs its content. case fit } @@ -40,9 +40,7 @@ public struct FloatingToast: View { @State private var dragOffset: CGFloat = 0 - /// The native floating tab bar's inset from each screen edge, measured on iOS 26; keeping the - /// same inset caps a `.fit` toast at the bar's width. - private static let tabBarInset: CGFloat = 21 + @Environment(\.floatingTabBar) private var tabBar /// A toast showing `message`; a swipe down calls `onDismiss` when one is given. /// @@ -101,7 +99,7 @@ public struct FloatingToast: View { .frame(minHeight: 44) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) - .padding(.horizontal, width == .fill ? 12 : Self.tabBarInset) + .padding(.horizontal, tabBar?.horizontalInset ?? 12) .offset(y: dragOffset) .gesture(dismissGesture, isEnabled: onDismiss != nil) } @@ -119,6 +117,60 @@ public struct FloatingToast: View { } } +// MARK: - Tab bar - + +/// The floating tab bar a toast sits above, published by the screen that draws the bar. +public struct FloatingTabBar: Equatable, Sendable { + /// The bar's inset from each screen edge. + public let horizontalInset: CGFloat + + /// A bar inset `horizontalInset` from each screen edge. + public init(horizontalInset: CGFloat) { + self.horizontalInset = horizontalInset + } +} + +extension EnvironmentValues { + /// The floating tab bar below this view, or `nil` when none is showing. + @Entry public var floatingTabBar: FloatingTabBar? = nil +} + +extension View { + /// Brings a bottom-anchored toast in out of the floating tab bar when one is showing, and up + /// from the bottom edge of the screen when not. + public func floatingToastTransition() -> some View { + modifier(FloatingToastTransition()) + } +} + +private struct FloatingToastTransition: ViewModifier { + @Environment(\.floatingTabBar) private var tabBar + + func body(content: Content) -> some View { + content.transition(tabBar == nil ? Self.fromScreenEdge : Self.fromTabBar) + } + + private static let fromScreenEdge: AnyTransition = .move(edge: .bottom).combined(with: .opacity) + + /// Starts tucked behind the bar, which draws over the screen's content, and swells up out of it. + private static let fromTabBar: AnyTransition = .modifier( + active: TabBarEmergence(progress: 0), + identity: TabBarEmergence(progress: 1) + ) +} + +private struct TabBarEmergence: ViewModifier { + let progress: CGFloat + + func body(content: Content) -> some View { + content + .scaleEffect(x: 0.92 + 0.08 * progress, y: 0.5 + 0.5 * progress, anchor: .bottom) + .offset(y: 56 * (1 - progress)) + .blur(radius: 6 * (1 - progress)) + .opacity(progress) + } +} + // MARK: - Previews - #Preview { From bb300ff883e3e52377f04f1f2ce05b752f6fd81f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 00:11:44 -0400 Subject: [PATCH 08/12] feat(ui): draw FloatingToast on the tab bar's glass The toast grows out of the floating tab bar, so it takes the bar's surface: capsuleGlassBackground(), which is Liquid Glass on iOS 26 and the ultra-thin material below. The white hairline goes too; neither tab bar has one. --- .../Sources/FlipcashUI/Views/Containers/FloatingToast.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift index 628142eef..1b2c2688f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift @@ -5,7 +5,7 @@ import SwiftUI -/// The undo-toast design shared with Android: a frosted pill with an optional leading icon and an +/// The undo-toast design shared with Android: a pill on the tab bar's glass with an optional leading icon and an /// optional trailing action. A longer message wraps. The caller owns placement, lifetime and transitions. public struct FloatingToast: View { @@ -97,8 +97,8 @@ public struct FloatingToast: View { .padding(.vertical, 8) // The height a one-line toast has with an action, so toasts without one match it. .frame(minHeight: 44) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) + // The floating tab bar's surface, so a toast growing out of the bar reads as part of it. + .capsuleGlassBackground() .padding(.horizontal, tabBar?.horizontalInset ?? 12) .offset(y: dragOffset) .gesture(dismissGesture, isEnabled: onDismiss != nil) From 6c2184875a00c01e966920003755156b021966c6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 05:32:27 -0400 Subject: [PATCH 09/12] fix(ui): draw the FloatingToast icon in the main text colour --- .../Sources/FlipcashUI/Views/Containers/FloatingToast.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift index 1b2c2688f..bf5c2012c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift @@ -66,7 +66,7 @@ public struct FloatingToast: View { if let systemImage { Image(systemName: systemImage) .font(.system(size: 18)) - .foregroundStyle(Color.textSecondary) + .foregroundStyle(Color.textMain) .accessibilityHidden(true) } Text(message) From 50db2951138364ffd14bd3ff157c868efc455959 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 05:44:15 -0400 Subject: [PATCH 10/12] fix(chat): draw the unarchive swipe action as an outline --- Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift index 71a1135ba..4ae769dc9 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift @@ -34,7 +34,9 @@ struct ArchivedChatsScreen: View { Button { conversationController.unarchive(conversation.id) } label: { + // Swipe actions fill symbols by default; the shared design uses the outline. Image(systemName: "tray.and.arrow.up") + .environment(\.symbolVariants, .none) } .tint(.backgroundRow) .accessibilityLabel("Unarchive chat") From a9b264a6804441c93f847695da6622e01dd4c7b7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:18:27 -0400 Subject: [PATCH 11/12] fix(chat): lift the archive undo toast above the iOS 18 tab bar --- .../Screens/Main/Tips/TipConversationsScreen.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 5a73313a6..95e5b52b9 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -42,6 +42,15 @@ struct TipConversationsScreen: View { /// A selected filter keeps the chips in view: they explain the list. private var showsChips: Bool { chipsRevealed || filter != .all } + /// The iOS 26 tab bar sits in the safe area; the legacy pill floats over the + /// content, so the toast has to clear it too or it lands behind the pill. + private var undoToastBottomPadding: CGFloat { + if #available(iOS 26, *) { + return 12 + } + return 12 + HomeTabView.legacyPillClearance + } + var body: some View { let projection = self.projection let conversations = rows(projection) @@ -122,7 +131,7 @@ struct TipConversationsScreen: View { }, onDismiss: { undoTarget = nil } ) - .padding(.bottom, 12) + .padding(.bottom, undoToastBottomPadding) .floatingToastTransition() } } From 3bd60cb26cc9184b6f8dce35848c40509caca54b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 09:06:02 -0400 Subject: [PATCH 12/12] refactor(ui): show toasts through an app-wide ToastController Screens drew their own toast overlays and timers, so each one had to work out the tab bar's clearance itself; the chats screen missed the iOS 18 pill until a9b264a6. Screens now call `toasts.show(_:)` on a ToastController held on Container, and HomeTabView hosts the one toast: above the tab bar when it shows, at the screen's bottom edge when it is hidden. A new toast replaces the one up, and each is announced to VoiceOver. A toast with an action takes touches and swipes down to dismiss; one without lets touches through, which keeps the version-footer countdown counting when the toast covers it. VersionTapUnlock now returns the line to show instead of owning a message and timer. --- .claude/docs/architecture.md | 4 + Flipcash/Core/Container.swift | 3 + .../Core/Screens/Main/Home/HomeTabView.swift | 8 ++ .../Core/Screens/Main/Tips/ArchivedRow.swift | 15 +-- .../Main/Tips/TipConversationsScreen.swift | 35 +----- .../Screens/Main/You/VersionTapUnlock.swift | 40 ++---- .../Core/Screens/Main/You/YouScreen.swift | 31 ++--- FlipcashTests/VersionTapUnlockTests.swift | 48 ++++---- .../Views/Containers/FloatingToast.swift | 7 +- .../Views/Containers/ToastController.swift | 116 ++++++++++++++++++ 10 files changed, 185 insertions(+), 122 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift diff --git a/.claude/docs/architecture.md b/.claude/docs/architecture.md index f61d126f7..eadf7cc6e 100644 --- a/.claude/docs/architecture.md +++ b/.claude/docs/architecture.md @@ -58,6 +58,10 @@ Top-level sheets (`Balance`, `Settings`, `Give`, `Discover`) each own a `Navigat Every router mutation logs one INFO entry under `flipcash.router` — filter by that label to trace any navigation interaction. +## Toasts: ToastController + +Floating toasts go through `ToastController` (FlipcashUI), held on `Container` and injected via `@Environment(ToastController.self)`. Call `toasts.show(.init(...))`; **don't add a screen-level toast overlay or `@State` timer.** `HomeTabView` hosts the one toast with `.toastHost(_:)`, which sits it above the floating tab bar (native bar on iOS 26+, legacy pill below) or at the screen's bottom edge when the bar is hidden. A new toast replaces the one showing. A toast with an action takes touches and swipes down to dismiss; one without lets touches through. Sheets cover the host, so a toast dispatched while a sheet is up runs out its time hidden behind it. The bill screen's balance toast (`Session.toast`) is a separate system. + ## Key Architectural Concepts 1. **Quarks** - Smallest unit of any currency (like cents for dollars) diff --git a/Flipcash/Core/Container.swift b/Flipcash/Core/Container.swift index 422aa208f..a2cd67745 100644 --- a/Flipcash/Core/Container.swift +++ b/Flipcash/Core/Container.swift @@ -20,6 +20,7 @@ class Container { let preferences: Preferences let notificationController: NotificationController let databaseStore: DatabaseStore + let toasts: ToastController @ObservationIgnored lazy var sessionAuthenticator = SessionAuthenticator(container: self) @ObservationIgnored lazy var deepLinkController = DeepLinkController(sessionAuthenticator: sessionAuthenticator) @@ -40,6 +41,7 @@ class Container { self.preferences = Preferences() self.notificationController = NotificationController() self.databaseStore = DatabaseStore() + self.toasts = ToastController() _ = sessionAuthenticator } @@ -53,6 +55,7 @@ class Container { .environment(betaFlags) .environment(preferences) .environment(notificationController) + .environment(toasts) } static func configureFirebase() { diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index c037ca7dc..b9cbddf66 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -196,6 +196,8 @@ struct HomeTabView: View { ForEach(HomeTab.allCases) { tab in Tab(value: tab) { tabContent(for: tab) + // Every tab stays alive, so only the selected one draws the toast. + .toastHost(container.toasts, isEnabled: selection == tab) .toolbar(isTabBarHidden ? .hidden : .visible, for: .tabBar) // The system bar's inset, measured on a 402pt-wide iPhone. .environment(\.floatingTabBar, isTabBarHidden ? nil : FloatingTabBar(horizontalInset: 21)) @@ -261,6 +263,12 @@ struct HomeTabView: View { ZStack(alignment: .bottom) { tabContent(for: selection) .frame(maxWidth: .infinity, maxHeight: .infinity) + // The pill adds nothing to the safe area, so the toast clears it itself, and it draws + // below the pill so it grows out from behind it. + .toastHost( + container.toasts, + bottomPadding: isTabBarHidden ? 12 : 12 + Self.legacyPillClearance + ) .transition(.opacity) .environment(\.floatingTabBar, isTabBarHidden ? nil : FloatingTabBar(horizontalInset: Self.legacyPillInset)) diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift index cc1464986..1c2e3db9f 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift @@ -42,18 +42,13 @@ struct ArchivedRow: View { } } -/// "Chat archived" with a tappable Undo. The screen owns its lifetime; a swipe down calls `onDismiss`. -struct ArchiveUndoToast: View { - - let onUndo: () -> Void - let onDismiss: () -> Void - - var body: some View { - FloatingToast( +extension ToastController.Toast { + /// "Chat archived", with an Undo that runs `undo`. + static func archivedChat(undo: @escaping () -> Void) -> Self { + .init( "Chat archived", systemImage: "archivebox", - action: .init("Undo", accessibilityIdentifier: "chat-archive-undo", handler: onUndo), - onDismiss: onDismiss + action: .init("Undo", accessibilityIdentifier: "chat-archive-undo", handler: undo) ) } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 95e5b52b9..f539dfafb 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -16,7 +16,7 @@ struct TipConversationsScreen: View { @State private var muteTarget: MuteTarget? @State private var filter: ChatListFilter = .all - @State private var undoTarget: ConversationID? + @Environment(ToastController.self) private var toasts /// Set by a pull down that starts at the top of the list; cleared when the screen goes away. @State private var chipsRevealed = false /// The chip row's height: while hidden, the list rests scrolled down by exactly this much. @@ -42,15 +42,6 @@ struct TipConversationsScreen: View { /// A selected filter keeps the chips in view: they explain the list. private var showsChips: Bool { chipsRevealed || filter != .all } - /// The iOS 26 tab bar sits in the safe area; the legacy pill floats over the - /// content, so the toast has to clear it too or it lands behind the pill. - private var undoToastBottomPadding: CGFloat { - if #available(iOS 26, *) { - return 12 - } - return 12 + HomeTabView.legacyPillClearance - } - var body: some View { let projection = self.projection let conversations = rows(projection) @@ -122,28 +113,6 @@ struct TipConversationsScreen: View { } } } - .overlay(alignment: .bottom) { - if undoTarget != nil { - ArchiveUndoToast( - onUndo: { - if let id = undoTarget { conversationController.unarchive(id) } - undoTarget = nil - }, - onDismiss: { undoTarget = nil } - ) - .padding(.bottom, undoToastBottomPadding) - .floatingToastTransition() - } - } - .animation(.spring(duration: 0.4, bounce: 0.2), value: undoTarget) - // Dismisses itself, like the reaction error toast; a new archive restarts the clock. - .task(id: undoTarget) { - guard undoTarget != nil else { return } - // Here rather than in the toast, so a replacing archive announces again. - AccessibilityNotification.Announcement("Chat archived").post() - try? await Task.sleep(for: .seconds(4)) - if !Task.isCancelled { undoTarget = nil } - } .sheet(item: $muteTarget) { target in MuteChatSheet(conversationID: target.id, isPresented: isPickingMuteDuration) } @@ -223,7 +192,7 @@ struct TipConversationsScreen: View { private func archiveAction(for conversation: Conversation) -> some View { Button { conversationController.archive(conversation.id) - undoTarget = conversation.id + toasts.show(.archivedChat { conversationController.unarchive(conversation.id) }) } label: { // Swipe actions fill symbols by default; the shared design uses the outline. Image(systemName: "archivebox") diff --git a/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift b/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift index 8e5e210e1..72faf425c 100644 --- a/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift +++ b/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift @@ -10,20 +10,13 @@ import FlipcashCore /// few taps say so out loud. /// /// The counter is silent until the unlock is within reach, so a stray tap on -/// the version string never announces anything. Owns the message it shows and -/// the delay that takes it away again, so the footer only has to draw it. +/// the version string never announces anything. @MainActor @Observable final class VersionTapUnlock { - /// The line to show above the version footer, or `nil` when there is - /// nothing to say. - private(set) var message: String? - private var tapCount: Int = 0 - @ObservationIgnored private var clearTask: Task? - /// Taps needed to flip beta access, matching the count this easter egg has /// always used. private static let tapsToToggle: Int = 10 @@ -31,44 +24,29 @@ final class VersionTapUnlock { /// How many taps out the countdown starts speaking up. private static let countdownFrom: Int = 3 - private static let messageDuration: Int = 2000 - // MARK: - Taps - - /// Registers a tap on the version footer, counting toward the toggle and - /// updating ``message``. + /// Registers a tap on the version footer, counting toward the toggle, and + /// returns the line to show for it, or `nil` when there is nothing to say. /// /// - Parameters: /// - isUnlocked: Beta access as it stands, which decides whether the taps /// are counting toward showing the beta rows or hiding them again. /// - toggle: Flips beta access. Called on the tap that completes the count. - func registerTap(isUnlocked: Bool, toggle: () -> Void) { + func registerTap(isUnlocked: Bool, toggle: () -> Void) -> String? { tapCount += 1 let remaining = Self.tapsToToggle - tapCount if remaining <= 0 { tapCount = 0 toggle() - show(isUnlocked ? "Beta features are hidden again" : "You are now a developer!") + return isUnlocked ? "Beta features are hidden again" : "You are now a developer!" } else if remaining <= Self.countdownFrom { let steps = remaining == 1 ? "step" : "steps" - show( - isUnlocked - ? "You are now \(remaining) \(steps) away from hiding beta features" - : "You are now \(remaining) \(steps) away from being a developer" - ) - } - } - - // MARK: - Message - - - private func show(_ message: String) { - self.message = message - clearTask?.cancel() - clearTask = Task { [weak self] in - try? await Task.delay(milliseconds: Self.messageDuration) - guard !Task.isCancelled else { return } - self?.message = nil + return isUnlocked + ? "You are now \(remaining) \(steps) away from hiding beta features" + : "You are now \(remaining) \(steps) away from being a developer" } + return nil } } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index d705205fb..94cf32646 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -42,6 +42,7 @@ struct YouScreen: View { /// The version footer's beta-access easter egg — the tap count and the line /// it shows for the last few taps. @State private var versionUnlock = VersionTapUnlock() + @Environment(ToastController.self) private var toasts @State private var isShowingDownloadOptions = false /// The format tapped in the download sheet, held until the sheet is gone so @@ -457,9 +458,14 @@ struct YouScreen: View { private var versionFooter: some View { Button { - versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { + let message = versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { betaFlags.setAccessGranted(!betaFlags.accessGranted) } + if let message { + // Two seconds, so consecutive taps read as one countdown. Without an action the toast + // lets taps through, so it never blocks the version string at the bottom of the list. + toasts.show(.init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2))) + } } label: { Text("Version \(AppMeta.version) • Build \(AppMeta.build)") .lineLimit(1) @@ -470,30 +476,7 @@ struct YouScreen: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - // On the button rather than the composed view: an identifier applied - // outside the overlay propagates onto the toast too, and an element - // carrying an identifier is no longer addressable by its label. .accessibilityIdentifier("you-version-footer") - // Overlaid rather than stacked: the countdown speaks on consecutive - // taps, and a toast that took up space would walk the version string - // out from under the finger. - .overlay(alignment: .top) { - if let message = versionUnlock.message { - FloatingToast(message, messageIdentifier: "you-version-toast", width: .fit) - // Back out to the screen edges, so the toast caps at the tab bar's width - // rather than the content column's. - .padding(.horizontal, -Self.horizontalInset) - // Sits on the footer's top edge, so the taller pill never covers the version. - .alignmentGuide(.top) { $0[.bottom] + 4 } - // Taps land on the version underneath, so the countdown keeps counting. - .allowsHitTesting(false) - .transition( - .offset(x: 0, y: 20) - .combined(with: .opacity.animation(.easeOutFastest)) - ) - } - } - .animation(.springFaster, value: versionUnlock.message) } // MARK: - Content - diff --git a/FlipcashTests/VersionTapUnlockTests.swift b/FlipcashTests/VersionTapUnlockTests.swift index 4e8c2bfdf..05a7b0fc1 100644 --- a/FlipcashTests/VersionTapUnlockTests.swift +++ b/FlipcashTests/VersionTapUnlockTests.swift @@ -11,57 +11,63 @@ import Testing @Suite("Version footer tap unlock") struct VersionTapUnlockTests { - /// Taps `count` times against a locked state, reporting how many taps - /// toggled and the message left standing. - private func tap(_ unlock: VersionTapUnlock, times count: Int, isUnlocked: Bool = false) -> Int { + /// Taps `count` times, reporting how many taps toggled and the line the + /// last tap asked to show. + private func tap( + _ unlock: VersionTapUnlock, + times count: Int, + isUnlocked: Bool = false + ) -> (toggles: Int, message: String?) { var toggles = 0 + var message: String? for _ in 0..