Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e5b36503 | ||
|
|
a6dbe8c46e | ||
|
|
bfeb494c03 |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+123
-3
@@ -32,6 +32,19 @@ struct ContentView: View {
|
||||
}
|
||||
@State private var isLoading = false
|
||||
@State private var statusMessage = ""
|
||||
/// 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
|
||||
@@ -139,6 +152,30 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
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
|
||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||
@@ -204,6 +241,19 @@ struct ContentView: View {
|
||||
default: selectedTab = 0
|
||||
}
|
||||
}
|
||||
// `-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
|
||||
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
|
||||
// network probe on the Data page). Location tracking and the first
|
||||
@@ -233,7 +283,10 @@ struct ContentView: View {
|
||||
monitor.setEnabled(alertsEnabled)
|
||||
updateLiveActivity()
|
||||
// Refresh only when the cache is stale (twice-a-day policy).
|
||||
// Skipped under the force-* hooks so the banner stays up.
|
||||
if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") && !args.contains("-forceHistoryFailure") {
|
||||
Task { await refresh() }
|
||||
}
|
||||
} else {
|
||||
showOnboarding = true
|
||||
}
|
||||
@@ -367,6 +420,59 @@ struct ContentView: View {
|
||||
// so reading storage here could push the OLD style.
|
||||
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
||||
}
|
||||
} // VStack: status banner + TabView
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||
}
|
||||
|
||||
/// 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: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 30)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(.secondarySystemGroupedBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
/// Pushes the current best-in-radius station into the Live Activity.
|
||||
@@ -420,7 +526,9 @@ struct ContentView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onReorder: reorderFavourites
|
||||
onReorder: reorderFavourites,
|
||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -504,14 +612,26 @@ struct ContentView: View {
|
||||
FuelStore.saveFavourites(refreshedFavourites)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||||
// 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 {
|
||||
// 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
|
||||
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||
} else {
|
||||
stations = SampleFuelProvider.sampleStations
|
||||
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()
|
||||
dataStatus = .connectionProblem
|
||||
}
|
||||
}
|
||||
// Keep monitor geofences in sync with the freshest data.
|
||||
|
||||
@@ -17,6 +17,10 @@ struct FavouritesView: View {
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
/// Persists a reordered favourites array (after drag-and-drop).
|
||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||
/// Propagated from ContentView — Trends' history failure with no data
|
||||
/// raises the global connection banner; recovery clears it.
|
||||
var onHistoryUnavailable: (() -> Void)? = nil
|
||||
var onHistoryRecovered: (() -> Void)? = nil
|
||||
|
||||
/// Fuel types that currently have at least one favourite — these are the
|
||||
/// only tabs shown (a fuel with no favourites gets no tab).
|
||||
@@ -62,7 +66,9 @@ struct FavouritesView: View {
|
||||
distanceUnit: DistanceUnit,
|
||||
priceDisplayStyle: PriceDisplayStyle,
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) {
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||
onHistoryUnavailable: (() -> Void)? = nil,
|
||||
onHistoryRecovered: (() -> Void)? = nil) {
|
||||
self.favourites = favourites
|
||||
self.selectedFuel = selectedFuel
|
||||
self.location = location
|
||||
@@ -70,6 +76,8 @@ struct FavouritesView: View {
|
||||
self.priceDisplayStyle = priceDisplayStyle
|
||||
self.onToggleFavourite = onToggleFavourite
|
||||
self.onReorder = onReorder
|
||||
self.onHistoryUnavailable = onHistoryUnavailable
|
||||
self.onHistoryRecovered = onHistoryRecovered
|
||||
_fuel = State(initialValue: selectedFuel)
|
||||
}
|
||||
|
||||
@@ -158,7 +166,9 @@ struct FavouritesView: View {
|
||||
TrendsView(
|
||||
favourites: favourites,
|
||||
selectedFuel: activeFuel,
|
||||
priceDisplayStyle: priceDisplayStyle
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
onHistoryUnavailable: onHistoryUnavailable,
|
||||
onHistoryRecovered: onHistoryRecovered
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@@ -16,6 +16,12 @@ struct TrendsView: View {
|
||||
let selectedFuel: FuelType
|
||||
let priceDisplayStyle: PriceDisplayStyle
|
||||
|
||||
/// Propagated up to ContentView so a price-history fetch that fails with
|
||||
/// NO data raises the global connection banner (same red banner as the
|
||||
/// stations fetch). `onHistoryRecovered` fires once data loads again.
|
||||
var onHistoryUnavailable: (() -> Void)? = nil
|
||||
var onHistoryRecovered: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var fuel: FuelType = .e10
|
||||
@@ -80,6 +86,15 @@ struct TrendsView: View {
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
// QA hook: force the unreachable state for screenshots (same pattern
|
||||
// as -showTrends / -forceConnectionProblem). Runs before the fetch so
|
||||
// the retry state renders immediately with no spinner flash.
|
||||
if ProcessInfo.processInfo.arguments.contains("-forceHistoryFailure") {
|
||||
series = []
|
||||
loadFailed = true
|
||||
onHistoryUnavailable?()
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
loadFailed = false
|
||||
defer { isLoading = false }
|
||||
@@ -98,6 +113,14 @@ struct TrendsView: View {
|
||||
loadFailed = firstSnapshot == nil
|
||||
}
|
||||
series = fetched
|
||||
// A failure with no data IS a connection problem — raise the global
|
||||
// banner so the user isn't stuck with a silent retry state. Success
|
||||
// clears it (only if the banner is the connection banner).
|
||||
if loadFailed {
|
||||
onHistoryUnavailable?()
|
||||
} else if hasAnyData {
|
||||
onHistoryRecovered?()
|
||||
}
|
||||
}
|
||||
|
||||
private func yLabel(_ pence: Double) -> String {
|
||||
|
||||
@@ -158,3 +158,13 @@
|
||||
"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 %@";
|
||||
"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";
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user