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 statusMessage = ""
/// When non-nil, the app is showing the BUNDLED offline snapshot (the
/// no-network last resort) and the banner labels it honestly with the
/// snapshot's date ("Offline data from 15 Aug"). Cleared as soon as a
/// live fetch succeeds.
@State private var offlineDataDate: String?
/// What data is on screen, driving which (if any) status banner shows
/// above the tabs:
/// - `.live`: fetched or cached data nothing to say.
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot
/// 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 showWidgetMock = false
@State private var selectedTab = 0
@@ -145,8 +153,28 @@ struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
if let offlineDataDate {
offlineBanner(date: offlineDataDate)
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()
}
TabView(selection: $selectedTab) {
stationsTab
@@ -213,13 +241,18 @@ struct ContentView: View {
default: selectedTab = 0
}
}
// `-forceOfflineDump` simulates the no-network last resort for the
// screenshot harness: serve the bundled real dump and label it
// with the offline banner. The auto-refresh below is skipped so
// the banner stays visible (a live fetch would clear it).
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
// failure legs for the screenshot harness. The auto-refresh below
// is skipped so the banner stays up (a live fetch would clear it).
if args.contains("-forceOfflineDump") {
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
// permission prompts (location, notifications, and the data/local
@@ -250,8 +283,8 @@ struct ContentView: View {
monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy).
// Skipped under `-forceOfflineDump` so the banner stays up.
if !args.contains("-forceOfflineDump") {
// Skipped under the force-* hooks so the banner stays up.
if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") {
Task { await refresh() }
}
} else {
@@ -387,34 +420,42 @@ struct ContentView: View {
// so reading storage here could push the OLD style.
updateLiveActivity(priceDisplayStyleOverride: newValue)
}
} // VStack: offline banner + TabView
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: offlineDataDate)
} // VStack: status banner + TabView
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
}
/// The offline-data strip: shown (pinned above the tabs) whenever the app
/// is serving the bundled no-network snapshot. Tapping it retries the
/// live fetch pull-to-refresh without needing the list gesture.
private func offlineBanner(date: String) -> some View {
/// The banner title for the bundled-snapshot case: date when the stamp
/// parsed, plain "Offline data" otherwise.
private func offlineTitle(date: String) -> String {
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 {
Task { await refresh(force: true) }
} label: {
HStack(spacing: 10) {
Image(systemName: "wifi.slash")
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.orange)
.foregroundStyle(tint)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: NSLocalizedString("Offline data from %@", comment: ""), date))
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text("Pull to refresh to update prices")
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.orange)
.foregroundStyle(tint)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
@@ -423,14 +464,14 @@ struct ContentView: View {
.fill(Color(.secondarySystemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
.stroke(tint.opacity(0.35), lineWidth: 1)
)
)
.padding(.horizontal, 12)
.padding(.bottom, 6)
}
.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))
}
@@ -569,8 +610,8 @@ struct ContentView: View {
FuelStore.saveFavourites(refreshedFavourites)
WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
// Live data restored the offline banner no longer applies.
offlineDataDate = nil
// Live data restored any status banner no longer applies.
dataStatus = .live
} catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty {
@@ -579,14 +620,16 @@ struct ContentView: View {
// labels the bundled snapshot honestly with its own date.
if let dump = BundledDumpProvider.stations {
stations = dump
offlineDataDate = FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp)
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
} else {
stations = SampleFuelProvider.sampleStations
offlineDataDate = nil
dataStatus = .live
}
} 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()
offlineDataDate = nil
dataStatus = .connectionProblem
}
}
// Keep monitor geofences in sync with the freshest data.