From 57dffb6c5e65fab392b7c45e72a959abff3e3018 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 07:35:50 +0100 Subject: [PATCH] tip: show outcome in the shared status banner (auto-dismiss) Tip purchase results now ride the SAME banner chrome as the network/offline strip above the tabs instead of a hard-to-see Settings footnote. TipStore is lifted to ContentView (the banner owner) and shared down to Settings. Each outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss. Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle: trailingIcon:action:) used by both the network strip (retry) and tips (x). --- FuelBoard/ContentView.swift | 99 ++++++++++++++++++++++++------------ FuelBoard/SettingsView.swift | 85 ++++++++++++++++++++++++------- 2 files changed, 133 insertions(+), 51 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 7563435..bedb216 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -70,6 +70,10 @@ struct ContentView: View { @State private var selectedTab = 0 @State private var locationManager = LocationManager() @StateObject private var monitor = ProximityMonitor() + /// Owns tip purchases; the single instance is shared down to Settings. + /// Its transient outcome rides the SAME status-banner chrome as the + /// network/offline strip (ContentView renders it above the tabs). + @StateObject private var tipStore = TipStore() /// The pool the list draws from. In Cheapest mode the chosen miles radius /// bounds it ("best price within X miles"); in Closest mode the radius is @@ -461,32 +465,43 @@ struct ContentView: View { rootTabView } .animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: tipStore.outcome) } @ViewBuilder private var statusBannerView: some View { - switch dataStatus { - case .offlineDump(let date): - let title = offlineTitle(date: date) - statusBanner( - icon: "wifi.slash", - tint: .orange, - title: title, - subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""), - accessibilityLabel: date.isEmpty - ? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "") - : String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date) - ) - case .connectionProblem: - statusBanner( - icon: "wifi.exclamationmark", - tint: .red, - title: NSLocalizedString("Check your internet connection", comment: ""), - subtitle: NSLocalizedString("Tap to try again", comment: ""), - accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "") - ) - case .live: - EmptyView() + if let outcome = tipStore.outcome { + // Tip outcomes ride the SAME banner chrome as the network/offline + // strip — auto-dismissed by TipStore, tap anywhere to dismiss. + tipStatusBanner(outcome) + .transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity)) + } else { + switch dataStatus { + case .offlineDump(let date): + let title = offlineTitle(date: date) + statusBanner( + icon: "wifi.slash", + tint: .orange, + title: title, + subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""), + accessibilityLabel: date.isEmpty + ? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "") + : String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date) + ) + .transition(.move(edge: .top).combined(with: .opacity)) + case .connectionProblem: + statusBanner( + icon: "wifi.exclamationmark", + tint: .red, + title: NSLocalizedString("Check your internet connection", comment: ""), + subtitle: NSLocalizedString("Tap to try again", comment: ""), + accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "") + ) + .transition(.move(edge: .top).combined(with: .opacity)) + case .live: + EmptyView() + } } } @@ -517,13 +532,31 @@ struct ContentView: View { : String(format: NSLocalizedString("Offline data from %@", comment: ""), date) } - /// Shared status-strip chrome: a tappable card pinned above the tabs. - /// Tapping retries the live fetch from ANY screen — no pull gesture - /// needed, so the offline banner isn't trapped on the Stations tab. + /// The offline/connection strip: same chrome as the tip banner, but the + /// tap retries the live fetch from ANY screen — no pull gesture needed, + /// so the offline banner isn't trapped on the Stations tab. private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View { - Button { + statusBannerCard(icon: icon, tint: tint, title: title, subtitle: subtitle, + trailingIcon: "arrow.clockwise", accessibilityLabel: accessibilityLabel) { Task { await refresh(force: true) } - } label: { + } + } + + /// A tip outcome, shown through the exact same banner chrome. Unlike the + /// network banner there's no retry — tapping dismisses (auto-dismiss in + /// TipStore also fires after ~3.5 s). + private func tipStatusBanner(_ outcome: TipStore.TipOutcome) -> some View { + statusBannerCard(icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil, + trailingIcon: "xmark", accessibilityLabel: outcome.message) { + withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() } + } + } + + /// Shared status-strip chrome: a tappable card pinned above the tabs. + private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?, + trailingIcon: String, accessibilityLabel: String, + action: @escaping () -> Void) -> some View { + Button(action: action) { HStack(spacing: 10) { Image(systemName: icon) .font(.system(size: 17, weight: .semibold)) @@ -533,12 +566,14 @@ struct ContentView: View { Text(title) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } } Spacer() - Image(systemName: "arrow.clockwise") + Image(systemName: trailingIcon) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(tint) } @@ -557,7 +592,6 @@ struct ContentView: View { } .buttonStyle(.plain) .accessibilityLabel(accessibilityLabel) - .transition(.move(edge: .top).combined(with: .opacity)) } /// Pushes the current best-in-radius station into the Live Activity. @@ -645,6 +679,7 @@ struct ContentView: View { /// within the compiler's type-check budget. private var settingsTab: some View { SettingsView( + tipStore: tipStore, distanceUnit: $distanceUnit, priceDisplayStyle: $priceDisplayStyle, alertsFuel: alertsFuel, diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 1cba09b..9582da5 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -13,6 +13,9 @@ import WidgetKit /// freshness) is debug-only too — moved here so release builds don't expose /// relay plumbing. struct SettingsView: View { + /// Shared tip-purchase store — owned by ContentView so its outcome can + /// ride the app-wide status banner (above the tabs), not a local card. + @ObservedObject var tipStore: TipStore @Binding var distanceUnit: DistanceUnit @Binding var priceDisplayStyle: PriceDisplayStyle /// The fuel + radius currently configured for alerts (mirrors the Alerts @@ -53,9 +56,6 @@ struct SettingsView: View { /// Clears the armed fence (monitor.clearDebugFence). var onClearDebugFence: () -> Void = {} - @StateObject private var tipStore = TipStore() - @State private var showTipAlert = false - @State private var tipAlertMessage = "" @State private var testAlertMessage: String? /// Hidden developer flag — the Debug section only appears when on. Toggled /// by tapping the About → Version row five times (NOT a user-facing switch). @@ -359,13 +359,6 @@ struct SettingsView: View { Text("About") } - if let message = tipStore.message { - Section { - Text(message) - .font(.footnote) - .foregroundStyle(.secondary) - } - } } .navigationTitle("Settings") .onAppear { @@ -610,9 +603,19 @@ final class TipStore: ObservableObject { accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A ] + /// A banner outcome: the exact copy to show plus its icon + tint. + struct TipOutcome: Equatable { + let message: String + let icon: String + let tint: Color + } + @Published private(set) var products: [String: Product] = [:] @Published private(set) var purchaseInProgress = false - @Published private(set) var message: String? + @Published private(set) var outcome: TipOutcome? + + /// Auto-dismiss timer for the outcome banner (replaced on each new outcome). + private var dismissTask: Task? /// Listens for transactions that complete OUTSIDE the direct purchase() /// call — Ask to Buy approvals, payments finished on another device @@ -630,6 +633,7 @@ final class TipStore: ObservableObject { deinit { updatesTask?.cancel() + dismissTask?.cancel() } private func handle(_ update: VerificationResult) async { @@ -638,7 +642,30 @@ final class TipStore: ObservableObject { // Only acknowledge our own consumables (future products get their own). guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return } await transaction.finish() - message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "") + showOutcome( + NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""), + icon: "checkmark.circle.fill", + tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158 + ) + } + + /// Sets the banner outcome and schedules its auto-dismiss (~3.5 s). + /// Re-triggering cancels the previous timer so rapid taps don't collide. + private func showOutcome(_ message: String, icon: String, tint: Color) { + dismissTask?.cancel() + outcome = TipOutcome(message: message, icon: icon, tint: tint) + dismissTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_500_000_000) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil } + } + } + + /// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss. + func dismissOutcome() { + dismissTask?.cancel() + dismissTask = nil + withAnimation(.easeOut(duration: 0.2)) { outcome = nil } } func displayPrice(for tier: TipTier) -> String { @@ -667,7 +694,11 @@ final class TipStore: ObservableObject { // Connect yet), still allow the attempt so the user sees a clear // outcome rather than a dead button. guard let product = products[tier.id] else { - message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "") + showOutcome( + NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""), + icon: "info.circle.fill", + tint: Color(.secondaryLabel) + ) return } @@ -680,19 +711,35 @@ final class TipStore: ObservableObject { // Consume the consumable — otherwise StoreKit re-delivers // it through Transaction.updates on every launch. await transaction.finish() - message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "") + showOutcome( + NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""), + icon: "checkmark.circle.fill", + tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158 + ) case .unverified: - message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "") + showOutcome( + NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""), + icon: "exclamationmark.triangle.fill", + tint: .orange + ) } case .userCancelled: - message = nil // silent — the user just closed the sheet + dismissOutcome() // silent — the user just closed the sheet case .pending: - message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "") + showOutcome( + NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""), + icon: "hourglass", + tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF + ) @unknown default: - message = nil + dismissOutcome() } } catch { - message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription) + showOutcome( + String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription), + icon: "xmark.circle.fill", + tint: .red + ) } } }