From a6dbe8c46ec49fe9d9169c5bb181a2fe43939a35 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sun, 16 Aug 2026 12:14:13 +0100 Subject: [PATCH] Banners: mention Stations-tab refresh; add connection-problem banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FuelBoard/ContentView.swift | 107 +++++++++++++++++-------- FuelBoard/en.lproj/Localizable.strings | 9 ++- 2 files changed, 82 insertions(+), 34 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index f70c49c..1934340 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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. diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 1996378..0750d2b 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -161,5 +161,10 @@ /* Offline data banner */ "Offline data from %@" = "Offline data from %@"; -"Pull to refresh to update prices" = "Pull to refresh to update prices"; -"Offline data from %@. Pull to refresh to update prices" = "Offline data from %@. Pull to refresh to update prices"; +"Offline data" = "Offline data"; +"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";