Offline banner: label the bundled-dump fallback with its snapshot date
The no-network last resort (bundled real dump) now shows a tappable banner above the tabs — 'Offline data from 15 Aug / Pull to refresh to update prices' — so stale snapshot data is never presented as live. Banner date comes from the dump's own data_updated stamp (FuelStore.offlineDataLabel, ISO 8601 with/without fractional seconds). Cleared on any successful fetch. - BundledDumpProvider.dataUpdatedStamp (JSONSerialization, app target) - ContentView: offlineDataDate state, offlineBanner view (tip-card styling, orange wifi.slash + arrow.clockwise, tap = force refresh), refresh() clears/sets it, -forceOfflineDump QA launch hook (serves the dump and skips auto-refresh so the banner stays up for captures) - Localizable.strings: 3 keys (banner title/subtitle/a11y) - +3 tests: OfflineDataLabelTests (101 total)
This commit is contained in:
@@ -15,4 +15,14 @@ enum BundledDumpProvider {
|
|||||||
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
||||||
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ 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
|
||||||
|
/// 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 showOnboarding = false
|
||||||
@State private var showWidgetMock = false
|
@State private var showWidgetMock = false
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@@ -139,6 +144,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let offlineDataDate {
|
||||||
|
offlineBanner(date: offlineDataDate)
|
||||||
|
}
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
stationsTab
|
stationsTab
|
||||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||||
@@ -204,6 +213,14 @@ struct ContentView: View {
|
|||||||
default: selectedTab = 0
|
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
|
// 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
|
||||||
// network probe on the Data page). Location tracking and the first
|
// network probe on the Data page). Location tracking and the first
|
||||||
@@ -233,7 +250,10 @@ 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.
|
||||||
|
if !args.contains("-forceOfflineDump") {
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showOnboarding = true
|
showOnboarding = true
|
||||||
}
|
}
|
||||||
@@ -367,6 +387,51 @@ 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
|
||||||
|
.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.
|
/// Pushes the current best-in-radius station into the Live Activity.
|
||||||
@@ -504,14 +569,24 @@ 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.
|
||||||
|
offlineDataDate = nil
|
||||||
} 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 {
|
||||||
// No cached prices — last resort is the bundled REAL dump
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
// (stale but genuine), then the demo sample set.
|
// (stale but genuine), then the demo sample set. The banner
|
||||||
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
// 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 {
|
} else {
|
||||||
stations = FuelStore.loadStations()
|
stations = FuelStore.loadStations()
|
||||||
|
offlineDataDate = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep monitor geofences in sync with the freshest data.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
|
|||||||
@@ -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.";
|
"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 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";
|
"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";
|
||||||
|
|||||||
@@ -587,3 +587,22 @@ final class PriceDisplayTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -782,6 +782,27 @@ struct FuelStore {
|
|||||||
loadString(service: dataUpdatedKey)
|
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
|
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
||||||
/// should be skipped (twice-a-day policy).
|
/// should be skipped (twice-a-day policy).
|
||||||
static var isCacheFresh: Bool {
|
static var isCacheFresh: Bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user