Banners: mention Stations-tab refresh; add connection-problem banner
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
This commit is contained in:
+75
-32
@@ -32,11 +32,19 @@ 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
|
/// What data is on screen, driving which (if any) status banner shows
|
||||||
/// no-network last resort) and the banner labels it honestly with the
|
/// above the tabs:
|
||||||
/// snapshot's date ("Offline data from 15 Aug"). Cleared as soon as a
|
/// - `.live`: fetched or cached data — nothing to say.
|
||||||
/// live fetch succeeds.
|
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot —
|
||||||
@State private var offlineDataDate: String?
|
/// 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 showOnboarding = false
|
||||||
@State private var showWidgetMock = false
|
@State private var showWidgetMock = false
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@@ -145,8 +153,28 @@ struct ContentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let offlineDataDate {
|
switch dataStatus {
|
||||||
offlineBanner(date: offlineDataDate)
|
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) {
|
TabView(selection: $selectedTab) {
|
||||||
stationsTab
|
stationsTab
|
||||||
@@ -213,13 +241,18 @@ struct ContentView: View {
|
|||||||
default: selectedTab = 0
|
default: selectedTab = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// `-forceOfflineDump` simulates the no-network last resort for the
|
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||||||
// screenshot harness: serve the bundled real dump and label it
|
// failure legs for the screenshot harness. The auto-refresh below
|
||||||
// with the offline banner. The auto-refresh below is skipped so
|
// is skipped so the banner stays up (a live fetch would clear it).
|
||||||
// the banner stays visible (a live fetch would clear it).
|
|
||||||
if args.contains("-forceOfflineDump") {
|
if args.contains("-forceOfflineDump") {
|
||||||
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
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
|
// 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
|
||||||
@@ -250,8 +283,8 @@ 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.
|
// Skipped under the force-* hooks so the banner stays up.
|
||||||
if !args.contains("-forceOfflineDump") {
|
if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") {
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -387,34 +420,42 @@ 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
|
} // VStack: status banner + TabView
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: offlineDataDate)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The offline-data strip: shown (pinned above the tabs) whenever the app
|
/// The banner title for the bundled-snapshot case: date when the stamp
|
||||||
/// is serving the bundled no-network snapshot. Tapping it retries the
|
/// parsed, plain "Offline data" otherwise.
|
||||||
/// live fetch — pull-to-refresh without needing the list gesture.
|
private func offlineTitle(date: String) -> String {
|
||||||
private func offlineBanner(date: String) -> some View {
|
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 {
|
Button {
|
||||||
Task { await refresh(force: true) }
|
Task { await refresh(force: true) }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "wifi.slash")
|
Image(systemName: icon)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(tint)
|
||||||
.frame(width: 30)
|
.frame(width: 30)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(String(format: NSLocalizedString("Offline data from %@", comment: ""), date))
|
Text(title)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
Text("Pull to refresh to update prices")
|
Text(subtitle)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(tint)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -423,14 +464,14 @@ struct ContentView: View {
|
|||||||
.fill(Color(.secondarySystemGroupedBackground))
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
|
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,8 +610,8 @@ 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.
|
// Live data restored — any status banner no longer applies.
|
||||||
offlineDataDate = nil
|
dataStatus = .live
|
||||||
} 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 {
|
||||||
@@ -579,14 +620,16 @@ struct ContentView: View {
|
|||||||
// labels the bundled snapshot honestly with its own date.
|
// labels the bundled snapshot honestly with its own date.
|
||||||
if let dump = BundledDumpProvider.stations {
|
if let dump = BundledDumpProvider.stations {
|
||||||
stations = dump
|
stations = dump
|
||||||
offlineDataDate = FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp)
|
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||||
} else {
|
} else {
|
||||||
stations = SampleFuelProvider.sampleStations
|
stations = SampleFuelProvider.sampleStations
|
||||||
offlineDataDate = nil
|
dataStatus = .live
|
||||||
}
|
}
|
||||||
} else {
|
} 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()
|
stations = FuelStore.loadStations()
|
||||||
offlineDataDate = nil
|
dataStatus = .connectionProblem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep monitor geofences in sync with the freshest data.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
|
|||||||
@@ -161,5 +161,10 @@
|
|||||||
|
|
||||||
/* Offline data banner */
|
/* Offline data banner */
|
||||||
"Offline data from %@" = "Offline data from %@";
|
"Offline data from %@" = "Offline data from %@";
|
||||||
"Pull to refresh to update prices" = "Pull to refresh to update prices";
|
"Offline data" = "Offline data";
|
||||||
"Offline data from %@. Pull to refresh to update prices" = "Offline data from %@. Pull to refresh to update prices";
|
"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";
|
||||||
|
|||||||
Reference in New Issue
Block a user