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";