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:
+67
-32
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user