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).
This commit is contained in:
FuelBoard Contributor
2026-08-19 07:48:10 +01:00
parent 2205a585f1
commit 57dffb6c5e
2 changed files with 133 additions and 51 deletions
+67 -32
View File
@@ -70,6 +70,10 @@ struct ContentView: View {
@State private var selectedTab = 0 @State private var selectedTab = 0
@State private var locationManager = LocationManager() @State private var locationManager = LocationManager()
@StateObject private var monitor = ProximityMonitor() @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 /// 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 /// bounds it ("best price within X miles"); in Closest mode the radius is
@@ -461,32 +465,43 @@ struct ContentView: View {
rootTabView rootTabView
} }
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: tipStore.outcome)
} }
@ViewBuilder @ViewBuilder
private var statusBannerView: some View { private var statusBannerView: some View {
switch dataStatus { if let outcome = tipStore.outcome {
case .offlineDump(let date): // Tip outcomes ride the SAME banner chrome as the network/offline
let title = offlineTitle(date: date) // strip auto-dismissed by TipStore, tap anywhere to dismiss.
statusBanner( tipStatusBanner(outcome)
icon: "wifi.slash", .transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity),
tint: .orange, removal: .opacity))
title: title, } else {
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""), switch dataStatus {
accessibilityLabel: date.isEmpty case .offlineDump(let date):
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "") let title = offlineTitle(date: date)
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date) statusBanner(
) icon: "wifi.slash",
case .connectionProblem: tint: .orange,
statusBanner( title: title,
icon: "wifi.exclamationmark", subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
tint: .red, accessibilityLabel: date.isEmpty
title: NSLocalizedString("Check your internet connection", comment: ""), ? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
subtitle: NSLocalizedString("Tap to try again", comment: ""), : String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "") )
) .transition(.move(edge: .top).combined(with: .opacity))
case .live: case .connectionProblem:
EmptyView() 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) : String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
} }
/// Shared status-strip chrome: a tappable card pinned above the tabs. /// The offline/connection strip: same chrome as the tip banner, but the
/// Tapping retries the live fetch from ANY screen no pull gesture /// tap retries the live fetch from ANY screen no pull gesture needed,
/// needed, so the offline banner isn't trapped on the Stations tab. /// 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 { 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) } 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) { HStack(spacing: 10) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 17, weight: .semibold))
@@ -533,12 +566,14 @@ struct ContentView: View {
Text(title) Text(title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text(subtitle) if let subtitle {
.font(.caption) Text(subtitle)
.foregroundStyle(.secondary) .font(.caption)
.foregroundStyle(.secondary)
}
} }
Spacer() Spacer()
Image(systemName: "arrow.clockwise") Image(systemName: trailingIcon)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint) .foregroundStyle(tint)
} }
@@ -557,7 +592,6 @@ struct ContentView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
.transition(.move(edge: .top).combined(with: .opacity))
} }
/// Pushes the current best-in-radius station into the Live Activity. /// 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. /// within the compiler's type-check budget.
private var settingsTab: some View { private var settingsTab: some View {
SettingsView( SettingsView(
tipStore: tipStore,
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle, priceDisplayStyle: $priceDisplayStyle,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
+66 -19
View File
@@ -13,6 +13,9 @@ import WidgetKit
/// freshness) is debug-only too moved here so release builds don't expose /// freshness) is debug-only too moved here so release builds don't expose
/// relay plumbing. /// relay plumbing.
struct SettingsView: View { 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 distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle @Binding var priceDisplayStyle: PriceDisplayStyle
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
@@ -53,9 +56,6 @@ struct SettingsView: View {
/// Clears the armed fence (monitor.clearDebugFence). /// Clears the armed fence (monitor.clearDebugFence).
var onClearDebugFence: () -> Void = {} var onClearDebugFence: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
@State private var testAlertMessage: String? @State private var testAlertMessage: String?
/// Hidden developer flag the Debug section only appears when on. Toggled /// Hidden developer flag the Debug section only appears when on. Toggled
/// by tapping the About Version row five times (NOT a user-facing switch). /// by tapping the About Version row five times (NOT a user-facing switch).
@@ -359,13 +359,6 @@ struct SettingsView: View {
Text("About") Text("About")
} }
if let message = tipStore.message {
Section {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.onAppear { .onAppear {
@@ -610,9 +603,19 @@ final class TipStore: ObservableObject {
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A 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 products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false @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<Void, Never>?
/// Listens for transactions that complete OUTSIDE the direct purchase() /// Listens for transactions that complete OUTSIDE the direct purchase()
/// call Ask to Buy approvals, payments finished on another device /// call Ask to Buy approvals, payments finished on another device
@@ -630,6 +633,7 @@ final class TipStore: ObservableObject {
deinit { deinit {
updatesTask?.cancel() updatesTask?.cancel()
dismissTask?.cancel()
} }
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async { private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
@@ -638,7 +642,30 @@ final class TipStore: ObservableObject {
// Only acknowledge our own consumables (future products get their own). // Only acknowledge our own consumables (future products get their own).
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return } guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
await transaction.finish() 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 { 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 // Connect yet), still allow the attempt so the user sees a clear
// outcome rather than a dead button. // outcome rather than a dead button.
guard let product = products[tier.id] else { 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 return
} }
@@ -680,19 +711,35 @@ final class TipStore: ObservableObject {
// Consume the consumable otherwise StoreKit re-delivers // Consume the consumable otherwise StoreKit re-delivers
// it through Transaction.updates on every launch. // it through Transaction.updates on every launch.
await transaction.finish() 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: 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: case .userCancelled:
message = nil // silent the user just closed the sheet dismissOutcome() // silent the user just closed the sheet
case .pending: 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: @unknown default:
message = nil dismissOutcome()
} }
} catch { } 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
)
} }
} }
} }