Banners: mention Stations-tab refresh; add connection-problem banner

Fix 1 — the offline banner's subtitle claimed 'pull to refresh' while that
gesture only exists on the Stations tab, yet the banner pins across ALL
tabs. Copy now says where the gesture lives: 'Pull to refresh on the
Stations tab'. The banner itself stays tappable everywhere (tap = retry),
so the retry path is never trapped on one tab.

Fix 2 — fetch-failed-with-cache case showed stale saved prices with NO
feedback (looked like a working live app). New second banner state
'Check your internet connection / Tap to try again' (red wifi.exclamation
mark, tap = retry) now appears whenever the fetch fails but a saved cache
is on screen. It clears on the next successful fetch.

- ContentView: offlineDataDate:String? -> dataStatus enum (.live /
  .offlineDump(date) / .connectionProblem) driving a shared statusBanner
  (icon/tint/title/subtitle); -forceConnectionProblem QA launch hook;
  auto-refresh skipped under either force-* hook
- Localizable.strings: subtitle key updated, +5 new keys (incl. date-less
  'Offline data' fallback + a11y labels)
- Tests unchanged (101 green) — pure view-layer change
This commit is contained in:
FuelBoard Contributor
2026-08-16 12:14:13 +01:00
parent bfeb494c03
commit a6dbe8c46e
2 changed files with 82 additions and 34 deletions
+75 -32
View File
@@ -32,11 +32,19 @@ struct ContentView: View {
} }
@State private var isLoading = false @State private var isLoading = false
@State private var statusMessage = "" @State private var statusMessage = ""
/// When non-nil, the app is showing the BUNDLED offline snapshot (the /// What data is on screen, driving which (if any) status banner shows
/// no-network last resort) and the banner labels it honestly with the /// above the tabs:
/// snapshot's date ("Offline data from 15 Aug"). Cleared as soon as a /// - `.live`: fetched or cached data nothing to say.
/// live fetch succeeds. /// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot
@State private var offlineDataDate: String? /// the banner labels it honestly with the snapshot's own date.
/// - `.connectionProblem`: fetch failed but a saved cache is showing
/// the banner says to check connectivity (tap = retry).
enum DataSourceStatus: Equatable {
case live
case offlineDump(date: String)
case connectionProblem
}
@State private var dataStatus: DataSourceStatus = .live
@State private var showOnboarding = false @State private var showOnboarding = false
@State private var showWidgetMock = false @State private var showWidgetMock = false
@State private var selectedTab = 0 @State private var selectedTab = 0
@@ -145,8 +153,28 @@ struct ContentView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if let offlineDataDate { switch dataStatus {
offlineBanner(date: offlineDataDate) 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()
} }
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
stationsTab stationsTab
@@ -213,13 +241,18 @@ struct ContentView: View {
default: selectedTab = 0 default: selectedTab = 0
} }
} }
// `-forceOfflineDump` simulates the no-network last resort for the // `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
// screenshot harness: serve the bundled real dump and label it // failure legs for the screenshot harness. The auto-refresh below
// with the offline banner. The auto-refresh below is skipped so // is skipped so the banner stays up (a live fetch would clear it).
// the banner stays visible (a live fetch would clear it).
if args.contains("-forceOfflineDump") { if args.contains("-forceOfflineDump") {
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
offlineDataDate = FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
}
if args.contains("-forceConnectionProblem") {
stations = FuelStore.loadStations().isEmpty
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
: FuelStore.loadStations()
dataStatus = .connectionProblem
} }
// Onboarding runs first on a fresh install it owns the initial // Onboarding runs first on a fresh install it owns the initial
// permission prompts (location, notifications, and the data/local // permission prompts (location, notifications, and the data/local
@@ -250,8 +283,8 @@ struct ContentView: View {
monitor.setEnabled(alertsEnabled) monitor.setEnabled(alertsEnabled)
updateLiveActivity() updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy). // Refresh only when the cache is stale (twice-a-day policy).
// Skipped under `-forceOfflineDump` so the banner stays up. // Skipped under the force-* hooks so the banner stays up.
if !args.contains("-forceOfflineDump") { if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") {
Task { await refresh() } Task { await refresh() }
} }
} else { } else {
@@ -387,34 +420,42 @@ struct ContentView: View {
// so reading storage here could push the OLD style. // so reading storage here could push the OLD style.
updateLiveActivity(priceDisplayStyleOverride: newValue) updateLiveActivity(priceDisplayStyleOverride: newValue)
} }
} // VStack: offline banner + TabView } // VStack: status banner + TabView
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: offlineDataDate) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
} }
/// The offline-data strip: shown (pinned above the tabs) whenever the app /// The banner title for the bundled-snapshot case: date when the stamp
/// is serving the bundled no-network snapshot. Tapping it retries the /// parsed, plain "Offline data" otherwise.
/// live fetch pull-to-refresh without needing the list gesture. private func offlineTitle(date: String) -> String {
private func offlineBanner(date: String) -> some View { date.isEmpty
? NSLocalizedString("Offline data", comment: "")
: 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.
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
Button { Button {
Task { await refresh(force: true) } Task { await refresh(force: true) }
} label: { } label: {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "wifi.slash") Image(systemName: icon)
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 17, weight: .semibold))
.foregroundStyle(.orange) .foregroundStyle(tint)
.frame(width: 30) .frame(width: 30)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(String(format: NSLocalizedString("Offline data from %@", comment: ""), date)) Text(title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text("Pull to refresh to update prices") Text(subtitle)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(.orange) .foregroundStyle(tint)
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -423,14 +464,14 @@ struct ContentView: View {
.fill(Color(.secondarySystemGroupedBackground)) .fill(Color(.secondarySystemGroupedBackground))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.orange.opacity(0.35), lineWidth: 1) .stroke(tint.opacity(0.35), lineWidth: 1)
) )
) )
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 6) .padding(.bottom, 6)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(String(format: NSLocalizedString("Offline data from %@. Pull to refresh to update prices", comment: ""), date)) .accessibilityLabel(accessibilityLabel)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
} }
@@ -569,8 +610,8 @@ struct ContentView: View {
FuelStore.saveFavourites(refreshedFavourites) FuelStore.saveFavourites(refreshedFavourites)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
// Live data restored the offline banner no longer applies. // Live data restored any status banner no longer applies.
offlineDataDate = nil dataStatus = .live
} catch { } catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data." statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty { if FuelStore.loadStations().isEmpty {
@@ -579,14 +620,16 @@ struct ContentView: View {
// labels the bundled snapshot honestly with its own date. // labels the bundled snapshot honestly with its own date.
if let dump = BundledDumpProvider.stations { if let dump = BundledDumpProvider.stations {
stations = dump stations = dump
offlineDataDate = FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
} else { } else {
stations = SampleFuelProvider.sampleStations stations = SampleFuelProvider.sampleStations
offlineDataDate = nil dataStatus = .live
} }
} else { } else {
// Saved prices are still on screen but the fetch failed, so
// say so: a stale cache must not look like a live app.
stations = FuelStore.loadStations() stations = FuelStore.loadStations()
offlineDataDate = nil dataStatus = .connectionProblem
} }
} }
// Keep monitor geofences in sync with the freshest data. // Keep monitor geofences in sync with the freshest data.
+7 -2
View File
@@ -161,5 +161,10 @@
/* Offline data banner */ /* Offline data banner */
"Offline data from %@" = "Offline data from %@"; "Offline data from %@" = "Offline data from %@";
"Pull to refresh to update prices" = "Pull to refresh to update prices"; "Offline data" = "Offline data";
"Offline data from %@. Pull to refresh to update prices" = "Offline data from %@. Pull to refresh to update prices"; "Pull to refresh on the Stations tab" = "Pull to refresh on the Stations tab";
"Offline data from %@. Pull to refresh on the Stations tab" = "Offline data from %@. Pull to refresh on the Stations tab";
"Offline data. Pull to refresh on the Stations tab" = "Offline data. Pull to refresh on the Stations tab";
"Check your internet connection" = "Check your internet connection";
"Tap to try again" = "Tap to try again";
"Check your internet connection. Tap to try again" = "Check your internet connection. Tap to try again";