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
848 lines
39 KiB
Swift
848 lines
39 KiB
Swift
import SwiftUI
|
|
import CoreLocation
|
|
import WidgetKit
|
|
|
|
struct ContentView: View {
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
|
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
|
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
|
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
|
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
|
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
|
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
|
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
|
|
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
|
|
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
|
|
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
|
|
@State private var liveActivityFollowsSearch: Bool = FuelStore.loadLiveActivityFollowsSearch()
|
|
@State private var location: Coordinate? = {
|
|
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
|
return nil
|
|
}()
|
|
|
|
/// The radius the geofence actually uses: the manual alert radius, or —
|
|
/// when "Follow search" is on — the Stations-tab distance capped at 8 mi.
|
|
private var effectiveAlertsRadiusKM: Double {
|
|
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
|
}
|
|
@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
|
|
@State private var locationManager = LocationManager()
|
|
@StateObject private var monitor = ProximityMonitor()
|
|
|
|
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
|
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
|
/// redundant — the whole country sorted nearest-first, because "nearest"
|
|
/// must never answer with an empty state. STRICT: no fallback to
|
|
/// out-of-radius stations in Cheapest mode.
|
|
private var poolStations: [FuelStation] {
|
|
let selling = stations.filter { $0.prices[selectedFuel] != nil }
|
|
guard let location else { return selling }
|
|
if sortMode == .closest { return selling } // radius disabled in Closest
|
|
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km
|
|
return selling.filter {
|
|
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
|
}
|
|
}
|
|
|
|
/// The price reference every row's delta + RAG compares against — the
|
|
/// "best" of the current pool. In BOTH modes that is the CHEAPEST station
|
|
/// within the chosen miles radius (the stations you'd actually consider).
|
|
/// Cheapest mode's pool already is the radius; Closest mode lists the
|
|
/// whole country nearest-first (so it never empties), but best value is
|
|
/// still judged against what's practically reachable, so deltas stay local.
|
|
/// Fallback: if the radius has no stations, use the pool minimum.
|
|
private var baselinePrice: Double? {
|
|
let pool = poolStations
|
|
if sortMode == .closest, let location {
|
|
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km
|
|
let within = pool.filter {
|
|
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
|
}
|
|
if let localMin = within.compactMap({ $0.prices[selectedFuel] }).min() {
|
|
return localMin
|
|
}
|
|
}
|
|
return pool.compactMap { $0.prices[selectedFuel] }.min()
|
|
}
|
|
|
|
/// The "best" station — the cheapest in the pool — gets the TOP badge.
|
|
/// First occurrence in display order wins on price ties.
|
|
private var topStationID: String? {
|
|
guard let baselinePrice else { return nil }
|
|
return displayedStations.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
|
}
|
|
|
|
/// The app's own "cheapest in range" — the TOP-badge station — mirrored
|
|
/// into the Settings → Debug section so its "Cheapest in range" row
|
|
/// matches the app list (and the widget when the widget's fuel + distance
|
|
/// match the app's). The debug section previously showed the ALERT
|
|
/// prediction (alertsFuel + alert radius), which is a different pool by
|
|
/// design — that mismatch is what this fixes.
|
|
private var appCheapestStatus: DebugAppCheapest? {
|
|
guard let topID = topStationID,
|
|
let station = stations.first(where: { $0.id == topID }),
|
|
let location else { return nil }
|
|
return DebugAppCheapest(
|
|
station: station,
|
|
fuel: selectedFuel,
|
|
radiusKM: distanceUnit.toKM(Double(stationLimit)),
|
|
distanceKM: station.distanceKM(to: location.lat, lng2: location.lng)
|
|
)
|
|
}
|
|
|
|
private var displayedStations: [FuelStation] {
|
|
sortedStations
|
|
}
|
|
|
|
private var sortedStations: [FuelStation] {
|
|
let pool = poolStations
|
|
switch sortMode {
|
|
case .closest:
|
|
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
|
|
return pool.sorted { lhs, rhs in
|
|
// Closest first; price only breaks ties.
|
|
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
if lDist != rDist { return lDist < rDist }
|
|
return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]!
|
|
}
|
|
case .cheapest:
|
|
return pool.sorted { lhs, rhs in
|
|
// Cheapest first; distance only breaks ties.
|
|
let lPrice = lhs.prices[selectedFuel]!
|
|
let rPrice = rhs.prices[selectedFuel]!
|
|
if lPrice != rPrice { return lPrice < rPrice }
|
|
guard let location else { return false }
|
|
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
|
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Station IDs favourited for the CURRENTLY selected fuel — the star on a
|
|
/// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites).
|
|
private var favouriteIDs: Set<String> {
|
|
Set(favourites.filter { $0.fuel == selectedFuel }.map(\.station.id))
|
|
}
|
|
|
|
private var refreshedFavourites: [FavouriteEntry] {
|
|
FuelStore.refreshedFavourites(favourites, from: stations)
|
|
}
|
|
|
|
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") }
|
|
.tag(0)
|
|
|
|
favouritesTab
|
|
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
|
.tag(1)
|
|
|
|
AlertsView(
|
|
enabled: $alertsEnabled,
|
|
radius: $alertsRadius,
|
|
fuel: $alertsFuel,
|
|
followsSearch: $alertsFollowsSearch,
|
|
liveActivityEnabled: $liveActivityEnabled,
|
|
liveActivityFuel: $liveActivityFuel,
|
|
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
|
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
|
stationLimit: stationLimit,
|
|
distanceUnit: distanceUnit,
|
|
monitoredCount: monitor.monitoredStationIDs.count,
|
|
lastAlert: monitor.lastAlert
|
|
)
|
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
|
.tag(2)
|
|
|
|
settingsTab
|
|
.tag(3)
|
|
}
|
|
.fullScreenCover(isPresented: $showOnboarding) {
|
|
OnboardingView {
|
|
showOnboarding = false
|
|
}
|
|
}
|
|
.fullScreenCover(isPresented: $showWidgetMock) {
|
|
WidgetMockScreen()
|
|
}
|
|
.sheet(item: $monitor.pendingStationMap) { request in
|
|
StationMapView(request: request)
|
|
}
|
|
.onAppear {
|
|
// Launch-arg hooks for the screenshot/UI-test harness (same
|
|
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
|
// opens the given tab; `-skipOnboarding` skips onboarding
|
|
// without touching the stored flag.
|
|
let args = ProcessInfo.processInfo.arguments
|
|
showWidgetMock = args.contains("-widgets")
|
|
// `-seedFavourite <substring>` pins the first matching station
|
|
// for Unleaded via the normal save path (keychain + app group)
|
|
// so screenshot captures can show a populated Favourites tab.
|
|
if let i = args.firstIndex(of: "-seedFavourite"), i + 1 < args.count {
|
|
let query = args[i + 1]
|
|
if let station = FuelStore.loadStations()
|
|
.first(where: { $0.name.localizedCaseInsensitiveContains(query) }) {
|
|
FuelStore.saveFavourites([FavouriteEntry(station: station, fuel: .e10)])
|
|
}
|
|
}
|
|
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
|
switch args[i + 1] {
|
|
case "favourites": selectedTab = 1
|
|
case "alerts": selectedTab = 2
|
|
case "settings": selectedTab = 3
|
|
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
|
|
// network fetch start once it's done.
|
|
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
|
|
// StationsView's `-showKeySheet`): skip onboarding without
|
|
// touching the stored flag.
|
|
if FuelStore.loadHasCompletedOnboarding()
|
|
|| args.contains("-skipOnboarding") {
|
|
locationManager.startForegroundTracking()
|
|
// Geofences must follow the user even in the background:
|
|
// wire the delegate hook (fires on every fix incl. background
|
|
// significant-change wake-ups) to re-register the region
|
|
// window around the new position. SwiftUI onChange alone
|
|
// never runs while suspended, so alerts would otherwise stay
|
|
// frozen around the last foreground fix.
|
|
locationManager.onLocationUpdate = { [weak monitor] in
|
|
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
// The Live Activity follows the same wake-ups — this hook
|
|
// fires on every fix incl. background significant-change
|
|
// wake-ups, so the Lock Screen pill stays live while driving.
|
|
updateLiveActivity()
|
|
}
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
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") {
|
|
Task { await refresh() }
|
|
}
|
|
} else {
|
|
showOnboarding = true
|
|
}
|
|
}
|
|
.onChange(of: showOnboarding) { _, showing in
|
|
// After onboarding finishes (or the test re-run is dismissed),
|
|
// begin foreground location tracking if permission allows, and
|
|
// run the FIRST data fetch. This must be a forced refresh: a
|
|
// reinstall may leave a recent `lastRefresh` in the app-group
|
|
// defaults (which survive app deletion), which would make the
|
|
// cache-gated refresh skip the fetch and leave the station list
|
|
// empty on a brand-new install.
|
|
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
|
locationManager.startForegroundTracking()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
updateLiveActivity()
|
|
Task { await refresh(force: true) }
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active {
|
|
// Never start location tracking while onboarding is on screen —
|
|
// onboarding owns the initial permission prompts. Once it's
|
|
// completed, normal foreground tracking resumes.
|
|
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
|
|
locationManager.startForegroundTracking()
|
|
}
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
updateLiveActivity()
|
|
// No network fetch on foreground — pull-to-refresh is the override.
|
|
} else {
|
|
locationManager.stopForegroundTracking()
|
|
}
|
|
}
|
|
.onChange(of: locationManager.current) { _, newLocation in
|
|
if let newLocation {
|
|
location = newLocation
|
|
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
// Geofences follow the user's position, but the station list is
|
|
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
updateLiveActivity()
|
|
}
|
|
}
|
|
.onChange(of: selectedFuel) { _, _ in
|
|
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
.onChange(of: stationLimit) { _, newValue in
|
|
// Distance filter is LOCAL math now — the cache holds the full-UK
|
|
// dump, so changing 5/10/15 (miles or km) never needs a network
|
|
// fetch. sortedStations/radiusScopedStations recompute on the
|
|
// next render.
|
|
FuelStore.saveStationLimit(newValue)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
// Follow-search surfaces mirror this distance — re-target.
|
|
if alertsFollowsSearch {
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
updateLiveActivity()
|
|
}
|
|
.onChange(of: alertsEnabled) { _, newValue in
|
|
FuelStore.saveAlertsEnabled(newValue)
|
|
monitor.setEnabled(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
if newValue {
|
|
locationManager.startBackgroundTracking()
|
|
}
|
|
}
|
|
.onChange(of: alertsRadius) { _, newValue in
|
|
FuelStore.saveAlertsRadius(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
.onChange(of: alertsFuel) { _, newValue in
|
|
// Alerts fuel is independent of the Stations-tab selection —
|
|
// changing it re-targets geofences to stations selling that fuel.
|
|
FuelStore.saveAlertsFuel(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: newValue, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
.onChange(of: alertsFollowsSearch) { _, newValue in
|
|
// Follow search = the geofence mirrors the Stations-tab distance
|
|
// (capped at 8 mi); picking any fixed radius clears it.
|
|
FuelStore.saveAlertsFollowsSearch(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
.onChange(of: liveActivityEnabled) { _, newValue in
|
|
// Toggling the Live Activity on starts it with the current best
|
|
// station; toggling off ends any running activity.
|
|
FuelStore.saveLiveActivityEnabled(newValue)
|
|
updateLiveActivity()
|
|
}
|
|
.onChange(of: liveActivityFuel) { _, newValue in
|
|
// The pill tracks its OWN fuel — independent of the Stations tab.
|
|
FuelStore.saveLiveActivityFuel(newValue)
|
|
updateLiveActivity()
|
|
}
|
|
.onChange(of: liveActivityRadiusMiles) { _, newValue in
|
|
// Same for its own radius (miles, converted at use).
|
|
FuelStore.saveLiveActivityRadiusMiles(newValue)
|
|
updateLiveActivity()
|
|
}
|
|
.onChange(of: liveActivityFollowsSearch) { _, newValue in
|
|
// The pill mirrors the Stations-tab distance instead of its own.
|
|
FuelStore.saveLiveActivityFollowsSearch(newValue)
|
|
updateLiveActivity()
|
|
}
|
|
.onChange(of: distanceUnit) { _, _ in
|
|
// Distance unit changes the radius — mirror the new radius in the
|
|
// Live Activity immediately.
|
|
updateLiveActivity()
|
|
// Follow-search alerts also move with the unit.
|
|
if alertsFollowsSearch {
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
}
|
|
.onChange(of: priceDisplayStyle) { _, newValue in
|
|
// The pill carries its own display style in content state — push
|
|
// an update so the running activity re-renders in the new style
|
|
// immediately. Pass the style explicitly: SettingsView saves it
|
|
// in its own onChange, whose ordering vs this one isn't guaranteed,
|
|
// 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.
|
|
/// No-op (or ends the activity) when the toggle is off or there's no
|
|
/// location/data yet. Uses the Live Activity's OWN fuel + radius (set in
|
|
/// Settings) — independent of the Stations-tab fuel/distance.
|
|
private func updateLiveActivity(priceDisplayStyleOverride: PriceDisplayStyle? = nil) {
|
|
// "Follow search" mirrors the Stations-tab distance exactly (no cap —
|
|
// the pill only displays, it doesn't geofence).
|
|
let radiusKM = liveActivityFollowsSearch
|
|
? distanceUnit.toKM(Double(stationLimit))
|
|
: distanceUnit.toKM(Double(liveActivityRadiusMiles))
|
|
LiveActivityManager.update(
|
|
stations: stations,
|
|
fuel: liveActivityFuel,
|
|
radiusKM: radiusKM,
|
|
location: location,
|
|
enabled: liveActivityEnabled,
|
|
priceDisplayStyle: priceDisplayStyleOverride
|
|
)
|
|
}
|
|
|
|
/// The Stations tab, extracted from `body` so the TabView expression stays
|
|
/// within the compiler's type-check budget.
|
|
private var stationsTab: some View {
|
|
StationsView(
|
|
stations: displayedStations,
|
|
totalCount: sortedStations.count,
|
|
isLoading: isLoading,
|
|
selectedFuel: $selectedFuel,
|
|
sortMode: $sortMode,
|
|
stationLimit: $stationLimit,
|
|
distanceUnit: distanceUnit,
|
|
priceDisplayStyle: priceDisplayStyle,
|
|
baselinePrice: baselinePrice,
|
|
topStationID: topStationID,
|
|
location: location,
|
|
favouriteIDs: favouriteIDs,
|
|
onToggleFavourite: toggleFavourite,
|
|
onRefresh: { await refresh(force: true) }
|
|
)
|
|
}
|
|
|
|
/// The Favourites tab, extracted from `body` for the same type-check
|
|
/// budget reason.
|
|
private var favouritesTab: some View {
|
|
FavouritesView(
|
|
favourites: refreshedFavourites,
|
|
selectedFuel: selectedFuel,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
priceDisplayStyle: priceDisplayStyle,
|
|
onToggleFavourite: toggleFavourite,
|
|
onReorder: reorderFavourites
|
|
)
|
|
}
|
|
|
|
/// The Settings tab, extracted from `body` so the TabView expression stays
|
|
/// within the compiler's type-check budget.
|
|
private var settingsTab: some View {
|
|
SettingsView(
|
|
distanceUnit: $distanceUnit,
|
|
priceDisplayStyle: $priceDisplayStyle,
|
|
alertsFuel: alertsFuel,
|
|
alertsRadiusKM: alertsRadius,
|
|
testAlertResult: monitor.lastTestResult,
|
|
onTestAlert: { monitor.sendTestNotification() },
|
|
onPlainTestAlert: { monitor.sendPlainTestNotification() },
|
|
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
|
|
onShowOnboarding: { showOnboarding = true },
|
|
debugStatus: monitor.debugStatus,
|
|
appCheapest: appCheapestStatus,
|
|
regionError: monitor.lastRegionError,
|
|
monitoredCount: monitor.monitoredStationIDs.count,
|
|
alertLog: monitor.alertLog,
|
|
regionEventCount: monitor.regionEventCount,
|
|
debugFenceIdentifier: monitor.debugFenceIdentifier,
|
|
onDebugFence: { monitor.registerDebugFenceAroundMe() },
|
|
onClearDebugFence: { monitor.clearDebugFence() }
|
|
)
|
|
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
|
}
|
|
|
|
private func toggleFavourite(_ station: FuelStation, fuel: FuelType) {
|
|
let key = FavouriteEntry(station: station, fuel: fuel)
|
|
if favourites.contains(where: { $0.id == key.id }) {
|
|
favourites.removeAll { $0.id == key.id }
|
|
} else {
|
|
favourites.append(key)
|
|
}
|
|
FuelStore.saveFavourites(favourites)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
|
|
/// Drag-and-drop reorder from the Favourites tab — persists the new order
|
|
/// and refreshes every surface that consumes it (widgets + geofence
|
|
/// priority, which follows the stored order).
|
|
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
|
|
favourites = newOrder
|
|
FuelStore.saveFavourites(favourites)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
|
|
/// Fetches fresh prices, but only when the cache is stale — unless
|
|
/// `force` is true (pull-to-refresh is the manual override).
|
|
private func refresh(force: Bool = false) async {
|
|
if !force, FuelStore.isCacheFresh {
|
|
return // data already fresh — skip network entirely
|
|
}
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
do {
|
|
let fetched = try await FuelPriceProvider.active.fetchStations(
|
|
near: location?.lat, lng: location?.lng,
|
|
fuel: selectedFuel,
|
|
radiusKM: nil // full-UK dump; device filters by miles radius
|
|
)
|
|
stations = fetched
|
|
FuelStore.saveStations(fetched)
|
|
FuelStore.saveLastRefresh()
|
|
// Persist envelope metadata (source, station count, GOV.UK
|
|
// dataset update time) for the Settings → About section — the
|
|
// live chain records whichever leg served the fetch.
|
|
if let meta = LiveChainProvider.latestMeta {
|
|
FuelStore.saveRelayMeta(meta)
|
|
}
|
|
// Keep the keychain favourites fresh with the new prices — the
|
|
// widget's Favourites mode reads them from keychain (the only
|
|
// channel shared on SideStore free), so stale star-time snapshots
|
|
// would otherwise show old prices.
|
|
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. 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.
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
// Fresh prices → refresh the Live Activity pill too.
|
|
updateLiveActivity()
|
|
}
|
|
}
|
|
|
|
// MARK: - Station row (shared by Stations + Favourites tabs)
|
|
|
|
struct StationRow: View {
|
|
let station: FuelStation
|
|
let fuel: FuelType
|
|
let location: Coordinate?
|
|
let distanceUnit: DistanceUnit
|
|
let priceDisplayStyle: PriceDisplayStyle
|
|
let baselinePrice: Double?
|
|
let isTopResult: Bool
|
|
let isFavourite: Bool
|
|
var onToggleFavourite: () -> Void = {}
|
|
|
|
private var ragColor: Color {
|
|
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
|
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
|
|
case .green: return .green
|
|
case .amber: return .orange
|
|
case .red: return .red
|
|
}
|
|
}
|
|
|
|
private var deltaText: String? {
|
|
guard let price = station.prices[fuel], let baselinePrice else { return nil }
|
|
let delta = price - baselinePrice
|
|
if abs(delta) <= 0.05 { return NSLocalizedString("best", comment: "") }
|
|
// Signed: +X.Xp pricier than baseline, -X.Xp cheaper (Closest mode);
|
|
// in Cheapest mode the baseline is the cheapest so deltas are ≥ 0.
|
|
return String(format: "%+.1fp", delta)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// LEFT — round brand logo (or generic fuel pump fallback)
|
|
Group {
|
|
if let asset = station.brandImageName {
|
|
Image(asset)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.padding(5)
|
|
} else {
|
|
Image(systemName: "fuelpump.fill")
|
|
.font(.system(size: 22))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
.frame(width: 46, height: 46)
|
|
.background(Circle().fill(.white))
|
|
.clipShape(Circle())
|
|
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
|
|
|
// MIDDLE — name, full address, distance
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(station.name)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if isTopResult {
|
|
Text("TOP")
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(.blue.opacity(0.15)))
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
Text("\(station.address), \(station.postcode)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if let location {
|
|
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// RIGHT — price in £ (no p) + difference vs cheapest
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
if let price = station.prices[fuel] {
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(ragColor)
|
|
.frame(width: 8, height: 8)
|
|
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold)
|
|
}
|
|
if let deltaText {
|
|
Text(deltaText)
|
|
.font(.caption2.bold().monospaced())
|
|
.foregroundStyle(ragColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// FAR RIGHT — favourite star
|
|
Button {
|
|
onToggleFavourite()
|
|
} label: {
|
|
Image(systemName: isFavourite ? "star.fill" : "star")
|
|
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if let url = station.mapsDirectionsURL {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Location manager
|
|
|
|
@MainActor
|
|
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
|
@Published var current: Coordinate?
|
|
/// Called after every location fix (foreground AND background wake-ups).
|
|
/// The app uses it to re-register geofences around the new position —
|
|
/// SwiftUI's `.onChange` never runs in the background, so this delegate
|
|
/// hook is the only path that keeps the 18-region window following the
|
|
/// user while driving with the app suspended.
|
|
var onLocationUpdate: (() -> Void)?
|
|
private let manager = CLLocationManager()
|
|
|
|
override init() {
|
|
super.init()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
manager.distanceFilter = 250 // metres — re-fires while driving
|
|
manager.pausesLocationUpdatesAutomatically = true
|
|
}
|
|
|
|
/// Continuous tracking while the app is in the foreground, so the list
|
|
/// re-fetches around the user's new position as they drive.
|
|
func startForegroundTracking() {
|
|
switch manager.authorizationStatus {
|
|
case .notDetermined:
|
|
manager.requestWhenInUseAuthorization()
|
|
manager.requestLocation()
|
|
case .authorizedWhenInUse, .authorizedAlways:
|
|
manager.startUpdatingLocation()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// One-shot fix (first launch / after permission grant).
|
|
func requestUpdate() {
|
|
manager.requestLocation()
|
|
}
|
|
|
|
/// Background wake-ups on significant movement. Needs Always permission;
|
|
/// called once the user enables alerts so geofences + prices follow them.
|
|
func startBackgroundTracking() {
|
|
guard manager.authorizationStatus == .authorizedAlways else { return }
|
|
manager.startMonitoringSignificantLocationChanges()
|
|
}
|
|
|
|
func stopForegroundTracking() {
|
|
// Significant-change monitoring keeps running in the background.
|
|
manager.stopUpdatingLocation()
|
|
}
|
|
|
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
switch manager.authorizationStatus {
|
|
case .authorizedAlways:
|
|
manager.startMonitoringSignificantLocationChanges()
|
|
manager.startUpdatingLocation()
|
|
case .authorizedWhenInUse:
|
|
manager.startUpdatingLocation()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
guard let loc = locations.last else { return }
|
|
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
|
|
// Drive the widget: every significant move re-orders the widget list
|
|
// around the new position immediately, instead of waiting for the
|
|
// widget's own 5-minute timeline tick. Throttled — WidgetKit ignores
|
|
// reload spam and the relay has a rate limit, so 60 s is plenty.
|
|
let now = Date()
|
|
if now.timeIntervalSince(lastWidgetReload) >= 60 {
|
|
lastWidgetReload = now
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
// Re-register geofences around the new position. Also fires on
|
|
// background significant-change wake-ups, which SwiftUI onChange
|
|
// never sees — this is what keeps alerts working while driving.
|
|
onLocationUpdate?()
|
|
}
|
|
|
|
private var lastWidgetReload = Date.distantPast
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
// Ignore — the app still works price-sorted without location.
|
|
}
|
|
}
|