From fb154c726fb8085dce5f733a3a47885deeb09008 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 15:41:19 -0400 Subject: [PATCH] fix(ui): drop toasts under sheets and swap the countdown in place Matches Android's FloatingToastHostState. A sheet or the bill covering the host dismisses the toast showing, and show() drops a toast while covered or while any sheet or dialog is presented, so it can't time out unseen with its Undo out of reach. show(_:inPlace:) keeps the showing toast's view, so the developer-mode countdown changes text without replaying the entrance. --- .claude/docs/architecture.md | 2 +- .../Core/Screens/Main/Home/HomeTabView.swift | 3 + .../Core/Screens/Main/You/YouScreen.swift | 7 +- FlipcashTests/ToastControllerTests.swift | 81 +++++++++++++++++++ .../Views/Containers/ToastController.swift | 35 +++++++- 5 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 FlipcashTests/ToastControllerTests.swift diff --git a/.claude/docs/architecture.md b/.claude/docs/architecture.md index eadf7cc6e..31ba90575 100644 --- a/.claude/docs/architecture.md +++ b/.claude/docs/architecture.md @@ -60,7 +60,7 @@ Every router mutation logs one INFO entry under `flipcash.router` — filter by ## 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. +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. A sheet, dialog or the bill covers the host: `HomeTabView` sets `isCovered` from router sheets and the bill, which dismisses the toast showing, and `show` drops any toast dispatched while something is presented. For a message that updates as the user acts (a countdown), pass `inPlace: true` so it swaps text without replaying the entrance. The bill screen's balance toast (`Session.toast`) is a separate system. ## Key Architectural Concepts diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index b9cbddf66..bbe034dda 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -106,6 +106,9 @@ struct HomeTabView: View { tipCardPresentation.collapse() } .onDisappear { router.activeTabStack = nil } + .onChange(of: router.presentedSheet != nil || sessionContainer.session.isShowingBill, initial: true) { _, covered in + container.toasts.isCovered = covered + } .onChange(of: sessionContainer.session.profile?.username, initial: true) { _, username in sessionContainer.chatArchive.setViewerUsername(username) } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 94cf32646..fd487c994 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -462,9 +462,12 @@ struct YouScreen: View { betaFlags.setAccessGranted(!betaFlags.accessGranted) } if let message { - // Two seconds, so consecutive taps read as one countdown. Without an action the toast + // Two seconds and swapped in place, 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))) + toasts.show( + .init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2)), + inPlace: true + ) } } label: { Text("Version \(AppMeta.version) • Build \(AppMeta.build)") diff --git a/FlipcashTests/ToastControllerTests.swift b/FlipcashTests/ToastControllerTests.swift new file mode 100644 index 000000000..684c4de89 --- /dev/null +++ b/FlipcashTests/ToastControllerTests.swift @@ -0,0 +1,81 @@ +// +// ToastControllerTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashUI + +@MainActor +@Suite("Toast controller") +struct ToastControllerTests { + + @Test("A new toast replaces the one showing") + func replaces() { + let toasts = ToastController() + toasts.show(.init("First")) + toasts.show(.init("Second")) + + #expect(toasts.current?.message == "Second") + } + + @Test("Covering the host dismisses the toast showing") + func coverDismisses() { + let toasts = ToastController() + toasts.show(.init("Chat archived")) + + toasts.isCovered = true + + #expect(toasts.current == nil) + } + + @Test("A toast shown while covered is dropped, and uncovering does not bring it back") + func coveredDrops() { + let toasts = ToastController() + toasts.isCovered = true + toasts.show(.init("Chat archived")) + #expect(toasts.current == nil) + + toasts.isCovered = false + + #expect(toasts.current == nil) + } + + @Test("An in-place toast keeps the slot of the one showing, so the entrance does not replay") + func inPlaceKeepsSlot() throws { + let toasts = ToastController() + toasts.show(.init("3 steps"), inPlace: true) + let first = try #require(toasts.current) + + toasts.show(.init("2 steps"), inPlace: true) + let second = try #require(toasts.current) + + #expect(second.message == "2 steps") + #expect(second.slot == first.slot) + #expect(second.id != first.id) + } + + @Test("A toast not shown in place takes a new slot") + func replacementTakesNewSlot() throws { + let toasts = ToastController() + toasts.show(.init("First")) + let first = try #require(toasts.current) + + toasts.show(.init("Second")) + + #expect(toasts.current?.slot != first.slot) + } + + @Test("Dismissing a replaced toast leaves the current one up") + func staleDismissIgnored() throws { + let toasts = ToastController() + toasts.show(.init("First")) + let first = try #require(toasts.current) + toasts.show(.init("Second")) + + toasts.dismiss(first.id) + + #expect(toasts.current?.message == "Second") + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift index 1bc4752bb..ca0597213 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift @@ -4,6 +4,7 @@ // import SwiftUI +import UIKit import Observation /// The app-wide toast: any screen dispatches one, and a single host draws it above the floating tab bar, @@ -16,6 +17,8 @@ public final class ToastController { /// lets touches through to the screen beneath it. public struct Toast: Identifiable { public let id = UUID() + // Shared by toasts swapped in place, so the host keeps the view rather than replaying the entrance. + fileprivate(set) var slot = UUID() let message: String let systemImage: String? let messageIdentifier: String? @@ -46,11 +49,25 @@ public final class ToastController { /// The toast on screen, or `nil` when none is. public private(set) var current: Toast? + /// Whether a sheet or other modal layer is drawn over the host. Covering dismisses the toast on screen, + /// and a toast shown while covered is dropped: hidden beneath the layer it would time out unseen, with + /// its action out of reach. + public var isCovered = false { + didSet { if isCovered { current = nil } } + } + /// A controller with no toast showing. public init() {} - /// Shows `toast` in place of any toast already up, and announces it to VoiceOver. - public func show(_ toast: Toast) { + /// Shows `toast` in place of any toast already up, and announces it to VoiceOver. Drops it while the + /// host is covered. + /// + /// With `inPlace`, a toast already up swaps its content without replaying the entrance, for a message + /// that updates as the user acts (a countdown). Its timer still restarts. + public func show(_ toast: Toast, inPlace: Bool = false) { + guard !isCovered, !Self.isPresentingOverApp else { return } + var toast = toast + if inPlace, let current { toast.slot = current.slot } current = toast AccessibilityNotification.Announcement(toast.message).post() } @@ -60,6 +77,16 @@ public final class ToastController { guard current?.id == id else { return } current = nil } + + // Catches sheets and dialogs presented outside the router, which never set `isCovered`. + private static var isPresentingOverApp: Bool { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController? + .presentedViewController != nil + } } extension View { @@ -104,13 +131,13 @@ private struct ToastHost: View { .allowsHitTesting(toast.action != nil) .padding(.bottom, bottomPadding) .floatingToastTransition() - .id(toast.id) + .id(toast.slot) .task(id: toast.id) { try? await Task.sleep(for: toast.duration) if !Task.isCancelled { controller.dismiss(toast.id) } } } } - .animation(.spring(duration: 0.4, bounce: 0.2), value: controller.current?.id) + .animation(.spring(duration: 0.4, bounce: 0.2), value: controller.current?.slot) } }