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
4 changes: 4 additions & 0 deletions .claude/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Flipcash/Core/Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -40,6 +41,7 @@ class Container {
self.preferences = Preferences()
self.notificationController = NotificationController()
self.databaseStore = DatabaseStore()
self.toasts = ToastController()

_ = sessionAuthenticator
}
Expand All @@ -53,6 +55,7 @@ class Container {
.environment(betaFlags)
.environment(preferences)
.environment(notificationController)
.environment(toasts)
}

static func configureFirebase() {
Expand Down
14 changes: 13 additions & 1 deletion Flipcash/Core/Screens/Main/Home/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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))
}
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
27 changes: 8 additions & 19 deletions Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
23 changes: 4 additions & 19 deletions Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 9 additions & 31 deletions Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,65 +10,43 @@ 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<Void, Never>?

/// Taps needed to flip beta access, matching the count this easter egg has
/// always used.
private static let tapsToToggle: Int = 10

/// 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
}
}
26 changes: 7 additions & 19 deletions Flipcash/Core/Screens/Main/You/YouScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 -
Expand Down
48 changes: 27 additions & 21 deletions FlipcashTests/VersionTapUnlockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<count {
unlock.registerTap(isUnlocked: isUnlocked) { toggles += 1 }
message = unlock.registerTap(isUnlocked: isUnlocked) { toggles += 1 }
}
return toggles
return (toggles, message)
}

@Test("The first taps say nothing")
func earlyTaps_areSilent() {
let unlock = VersionTapUnlock()
#expect(tap(unlock, times: 6) == 0)
#expect(unlock.message == nil)
let result = tap(unlock, times: 6)
#expect(result.toggles == 0)
#expect(result.message == nil)
}

@Test("The countdown starts three taps out")
func countdown_startsThreeTapsOut() {
let unlock = VersionTapUnlock()

_ = tap(unlock, times: 7)
#expect(unlock.message == "You are now 3 steps away from being a developer")

_ = tap(unlock, times: 1)
#expect(unlock.message == "You are now 2 steps away from being a developer")
#expect(tap(unlock, times: 7).message == "You are now 3 steps away from being a developer")
#expect(tap(unlock, times: 1).message == "You are now 2 steps away from being a developer")
}

@Test("The last tap before the unlock is singular")
func countdown_lastTapIsSingular() {
let unlock = VersionTapUnlock()
_ = tap(unlock, times: 9)
#expect(unlock.message == "You are now 1 step away from being a developer")
#expect(tap(unlock, times: 9).message == "You are now 1 step away from being a developer")
}

@Test("The tenth tap unlocks and says so")
func tenthTap_unlocks() {
let unlock = VersionTapUnlock()
#expect(tap(unlock, times: 10) == 1)
#expect(unlock.message == "You are now a developer!")
let result = tap(unlock, times: 10)
#expect(result.toggles == 1)
#expect(result.message == "You are now a developer!")
}

@Test("Ten more taps lock it again")
func tenMoreTaps_lockAgain() {
let unlock = VersionTapUnlock()

#expect(tap(unlock, times: 10) == 1)
#expect(tap(unlock, times: 9, isUnlocked: true) == 0)
#expect(unlock.message == "You are now 1 step away from hiding beta features")
#expect(tap(unlock, times: 10).toggles == 1)

let nearlyLocked = tap(unlock, times: 9, isUnlocked: true)
#expect(nearlyLocked.toggles == 0)
#expect(nearlyLocked.message == "You are now 1 step away from hiding beta features")

#expect(tap(unlock, times: 1, isUnlocked: true) == 1)
#expect(unlock.message == "Beta features are hidden again")
let locked = tap(unlock, times: 1, isUnlocked: true)
#expect(locked.toggles == 1)
#expect(locked.message == "Beta features are hidden again")
}
}
Loading
Loading