diff --git a/FuelBoard/BundledDumpProvider.swift b/FuelBoard/BundledDumpProvider.swift index eb88080..14e256c 100644 --- a/FuelBoard/BundledDumpProvider.swift +++ b/FuelBoard/BundledDumpProvider.swift @@ -15,4 +15,14 @@ enum BundledDumpProvider { guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil } return try? FuelPriceProvider.decodeStations(from: asset.data) } + + /// The GOV.UK `data_updated` stamp from the bundled envelope (ISO 8601). + /// Labels the offline-data banner honestly ("Offline data from 15 Aug") + /// instead of presenting the build's snapshot as live. + static var dataUpdatedStamp: String? { + guard let asset = NSDataAsset(name: "FuelBoardDump"), + let obj = try? JSONSerialization.jsonObject(with: asset.data) + as? [String: Any] else { return nil } + return obj["data_updated"] as? String + } } diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index e27ce02..f70c49c 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -32,6 +32,11 @@ 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? @State private var showOnboarding = false @State private var showWidgetMock = false @State private var selectedTab = 0 @@ -139,7 +144,11 @@ struct ContentView: View { } var body: some View { - TabView(selection: $selectedTab) { + VStack(spacing: 0) { + if let offlineDataDate { + offlineBanner(date: offlineDataDate) + } + TabView(selection: $selectedTab) { stationsTab .tabItem { Label("Stations", systemImage: "fuelpump.fill") } .tag(0) @@ -204,6 +213,14 @@ 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). + if args.contains("-forceOfflineDump") { + stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations + offlineDataDate = FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) + } // Onboarding runs first on a fresh install — it owns the initial // permission prompts (location, notifications, and the data/local // network probe on the Data page). Location tracking and the first @@ -233,7 +250,10 @@ struct ContentView: View { monitor.setEnabled(alertsEnabled) updateLiveActivity() // Refresh only when the cache is stale (twice-a-day policy). - Task { await refresh() } + // Skipped under `-forceOfflineDump` so the banner stays up. + if !args.contains("-forceOfflineDump") { + Task { await refresh() } + } } else { showOnboarding = true } @@ -367,6 +387,51 @@ 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) + } + + /// 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 { + Button { + Task { await refresh(force: true) } + } label: { + HStack(spacing: 10) { + Image(systemName: "wifi.slash") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.orange) + .frame(width: 30) + VStack(alignment: .leading, spacing: 2) { + Text(String(format: NSLocalizedString("Offline data from %@", comment: ""), date)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text("Pull to refresh to update prices") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.orange) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.orange.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)) + .transition(.move(edge: .top).combined(with: .opacity)) } /// Pushes the current best-in-radius station into the Live Activity. @@ -504,14 +569,24 @@ 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 } catch { statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data." if FuelStore.loadStations().isEmpty { // No cached prices — last resort is the bundled REAL dump - // (stale but genuine), then the demo sample set. - stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations + // (stale but genuine), then the demo sample set. The banner + // labels the bundled snapshot honestly with its own date. + if let dump = BundledDumpProvider.stations { + stations = dump + offlineDataDate = FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) + } else { + stations = SampleFuelProvider.sampleStations + offlineDataDate = nil + } } else { stations = FuelStore.loadStations() + offlineDataDate = nil } } // Keep monitor geofences in sync with the freshest data. diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 08b4c3d..1996378 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -158,3 +158,8 @@ "Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days."; "Average price over the days shown" = "Average price over the days shown"; "Average pence above the day's cheapest favourite" = "Average pence above the day's cheapest favourite"; + +/* 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"; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 8c787b5..c1d598e 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -587,3 +587,22 @@ final class PriceDisplayTests: XCTestCase { XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009") } } + +// MARK: - Offline data banner label + +final class OfflineDataLabelTests: XCTestCase { + func testOfflineDataLabelFormatsStampWithFractionalSeconds() { + // The bundled dump's real envelope stamp. + XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33.000Z"), "15 Aug") + } + + func testOfflineDataLabelToleratesPlainISODate() { + XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33Z"), "15 Aug") + } + + func testOfflineDataLabelNilWhenMissingOrUnparseable() { + XCTAssertNil(FuelStore.offlineDataLabel(from: nil)) + XCTAssertNil(FuelStore.offlineDataLabel(from: "")) + XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date")) + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 68a4d4f..7ae8f86 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -782,6 +782,27 @@ struct FuelStore { loadString(service: dataUpdatedKey) } + /// A short label for the offline-data banner: "15 Aug" from a GOV.UK + /// `data_updated` ISO 8601 stamp (with or without fractional seconds). + /// Nil when the stamp is missing or unparseable — callers then hide the + /// banner rather than label data with a wrong date. + static func offlineDataLabel(from stamp: String?) -> String? { + guard let stamp, !stamp.isEmpty else { return nil } + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = withFraction.date(from: stamp) + if date == nil { + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + date = plain.date(from: stamp) + } + guard let date else { return nil } + let formatter = DateFormatter() + formatter.dateFormat = "d MMM" + formatter.locale = Locale(identifier: "en_GB") + return formatter.string(from: date) + } + /// True when the cached data is fresh enough that a scheduled auto-refresh /// should be skipped (twice-a-day policy). static var isCacheFresh: Bool {