Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions Flipcash/Core/Screens/Main/Home/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions Flipcash/Core/Screens/Main/You/YouScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
81 changes: 81 additions & 0 deletions FlipcashTests/ToastControllerTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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?
Expand Down Expand Up @@ -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()
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Loading