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 3f7f9288d..b9cbddf66 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -196,7 +196,11 @@ 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)) } label: { tabLabel(for: tab) .accessibilityLabel(tab.title) @@ -246,6 +250,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 @@ -258,7 +263,14 @@ 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)) if !isTabBarHidden { HomeTabBar( @@ -270,7 +282,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/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") diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift index 3bf2ee313..1c2e3db9f 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift @@ -42,24 +42,13 @@ 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. -struct ArchiveUndoToast: View { - - let onUndo: () -> Void - - var body: some View { - HStack(spacing: 12) { - Text("Chat archived") - .foregroundStyle(Color.textMain) - Button("Undo", action: onUndo) - .foregroundStyle(Color.textMain) - .bold() - .accessibilityIdentifier("chat-archive-undo") - } - .font(.appTextSmall) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.ultraThinMaterial, in: Capsule()) +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: undo) + ) } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 616304c79..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. @@ -113,23 +113,6 @@ struct TipConversationsScreen: View { } } } - .overlay(alignment: .bottom) { - if undoTarget != nil { - ArchiveUndoToast { - if let id = undoTarget { conversationController.unarchive(id) } - undoTarget = nil - } - .padding(.bottom, 16) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .animation(.default, value: undoTarget) - // Dismisses itself, like the reaction error toast; a new archive restarts the clock. - .task(id: undoTarget) { - guard undoTarget != nil else { return } - try? await Task.sleep(for: .seconds(4)) - if !Task.isCancelled { undoTarget = nil } - } .sheet(item: $muteTarget) { target in MuteChatSheet(conversationID: target.id, isPresented: isPickingMuteDuration) } @@ -209,9 +192,11 @@ 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") + .environment(\.symbolVariants, .none) } .tint(.backgroundRow) // A swipe action is also a VoiceOver custom action, next to the mute one. 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 662154fa6..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,25 +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 { - ToastLabel(message) - .offset(y: -22) - .transition( - .offset(x: 0, y: 20) - .combined(with: .opacity.animation(.easeOutFastest)) - ) - .accessibilityIdentifier("you-version-toast") - } - } - .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..