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 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,