Files
fuelboard/FuelBoard/ContentView.swift
T
FuelBoard Contributor ff8a1e18d0 Redesign the station listing and move the Best Value controls
Two columns split by a 1pt hairline: identity on the left at a fixed 52%
share so rows line up when scrolling, price and savings on the right,
leading-aligned on one axis with the price dominant. The favourites star
sits under the station logo, freeing the width the pounds-and-pence price
needs to render in full rather than scaled or truncated.

Badges stay tinted rather than filled — BEST VALUE green, CHEAPEST blue,
none for nearest — and the verdict capsule is one shared view across the
listing, the detail sheet and the compact summary, so wording and colour
cannot drift between them.

Best Value's minimum saving moves from Alerts to Settings, Fuel Savings,
beside the fill size and MPG that produce the saving; the Alerts tab reads
it back instead of offering it twice. Its options are now 50p steps to £5
(was 50p/£1/£2/£5), so £2.50 and £3 are reachable, with a test pinning the
step, the ceiling and the store's string round-trip.

The verdict capsule scales to 0.7 before truncating. Measured on a 393pt
screen its copy is handed ~69pt while "Closest station" needs ~90pt, so the
old 0.8 floor could not reach it and rows showed "Closest stati..." instead.

Verified: Debug build clean, swift test 249/249.
2026-10-16 13:38:11 +01:00

2255 lines
113 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import CoreLocation
import WidgetKit
/// The single floating notification banner. Network/offline state and tip
/// outcomes all funnel through ONE style and render via the same chrome in
/// ContentView — never pushing layout, always overlaid.
enum AppBanner: Hashable {
case offlineDump(date: String)
case connectionProblem
case tip(TipStore.TipOutcome)
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var stations: [FuelStation] = FuelStore.loadStations()
/// Bumped when a road-distance fill finishes (or the accurate-distances toggle
/// changes). Row distances are read from the shared store INSIDE the row
/// views, so nothing else would invalidate the list and a freshly completed
/// fill would not appear until some other state changed.
@State private var roadDistanceTick = 0
@State private var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = RoadDistanceService.currentDebugStatus()
/// Set from `FB_DIST_FOCUS`/`-distFocus` so a road-distance fill can re-print
/// the affected station's display value once the new distances land.
@State private var distFocusQuery: String?
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
@State private var sortMode: SortMode = ContentView.qaSortModeOverride(default: FuelStore.loadSortMode())
@State private var stationLimit: Int = ContentView.qaStationRadiusOverride(default: FuelStore.loadStationLimit())
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
@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 alertsMode: AlertMode = FuelStore.loadAlertsMode()
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
@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()
/// The device's own last TRUSTWORTHY fix — raw CoreLocation output (subject
/// to LocationManager's junk-fix guard). Used for the network fetch hint and
/// for saving the position; never for labelling distances.
@State private var deviceLocation: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil
}()
/// Horizontal accuracy (metres) of `deviceLocation` when it came from a live
/// fix; nil when the value was seeded from the saved location at launch.
@State private var deviceLocationAccuracy: Double?
/// The single basis every DISTANCE the user sees is measured from — list
/// rows, the radius filter, sorting, the savings/journey figures and the
/// Live Activity all read this one origin, so no two of them can disagree
/// about where the user is.
///
/// Resolved through `FuelStore.distanceDisplayOrigin` rather than used raw:
/// a coarse Wi-Fi/cell fix (or one that contradicts the route cache's
/// anchor) must not silently re-base the UI — that is the bug where a row
/// showed an "Approx." marker beside the routed figure. When the live fix
/// is untrustworthy, this stays on the fix the cached routes were built
/// around; when the user has genuinely moved, it follows them.
private var location: Coordinate? {
guard let deviceLocation else { return nil }
let origin = FuelStore.distanceDisplayOrigin(
candidateLat: deviceLocation.lat,
candidateLng: deviceLocation.lng,
candidateAccuracyMeters: deviceLocationAccuracy
)
return Coordinate(lat: origin.lat, lng: origin.lng)
}
#if DEBUG
/// The origin decision behind `location`, for the `FB_DIST_FOCUS` probe.
private var displayOriginDiagnostics: (candidate: Coordinate?, accuracy: Double?,
source: String, candidateOffsetM: Double?,
anchor: (lat: Double, lng: Double)?) {
guard let deviceLocation else { return (nil, nil, "noFix", nil, nil) }
let resolved = FuelStore.distanceDisplayOrigin(
candidateLat: deviceLocation.lat, candidateLng: deviceLocation.lng,
candidateAccuracyMeters: deviceLocationAccuracy
)
let cache = FuelStore.loadRoadDistances()
return (deviceLocation, deviceLocationAccuracy, resolved.source,
cache.map { FuelStore.distanceMetersBetween(lat1: $0.sourceLat, lng1: $0.sourceLng,
lat2: deviceLocation.lat, lng2: deviceLocation.lng) },
cache.map { ($0.sourceLat, $0.sourceLng) })
}
#endif
// MARK: - QA overrides (DEBUG)
/// QA hooks: `-stationRadius 10` / `-sortMode cheapest` override the stored
/// results filter and sort for ONE launch, so filtering can be verified at
/// each radius without tapping the picker (simctl has no tap command). Never
/// persisted — the stored setting survives untouched.
private static func qaStationRadiusOverride(default fallback: Int) -> Int {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-stationRadius"), i + 1 < args.count,
let miles = Int(args[i + 1]), FuelStore.stationRadiusOptions.contains(miles) {
return miles
}
#endif
return fallback
}
/// QA hook: `-priceStyle poundsPence` renders prices in the wide pounds &
/// pence form for ONE launch. Same reason as `-sortMode`: simctl has no tap
/// command, so the wide price has to be measured against the row's column
/// widths without walking Settings → Units for every check. Never persisted.
private static func qaPriceStyleOverride(default fallback: PriceDisplayStyle) -> PriceDisplayStyle {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-priceStyle"), i + 1 < args.count,
let style = PriceDisplayStyle(rawValue: args[i + 1]) {
return style
}
#endif
return fallback
}
private static func qaSortModeOverride(default fallback: SortMode) -> SortMode {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-sortMode"), i + 1 < args.count,
let mode = SortMode(rawValue: args[i + 1]) {
return mode
}
// devicectl parses a leading-dash app argument as one of its OWN options,
// so on a real device the env form is the one that arrives.
if let env = ProcessInfo.processInfo.environment["FB_SORT_MODE"],
let mode = SortMode(rawValue: env) {
return mode
}
#endif
return fallback
}
/// 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)
}
private var effectiveAlertsMode: AlertMode {
(alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode
}
private func ensureBestValueAlertModeIsAvailable() {
guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return }
alertsMode = .cheapest
FuelStore.saveAlertsMode(.cheapest)
}
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
monitor.update(
stations: stations,
favourites: refreshedFavourites,
fuel: overrideFuel ?? alertsFuel,
radiusKM: effectiveAlertsRadiusKM,
alertMode: effectiveAlertsMode,
bestValueThresholdGBP: alertsBestValueThresholdGBP,
fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: sessionFuelSavingsAmount
)
}
private func installLocationUpdateHook() {
locationManager.onLocationUpdate = {
self.updateAlertMonitor()
// 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()
refreshRoadDistancesIfNeeded()
}
}
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
/// stations around the current fix. The app owns routing — the widget and
/// Live Activity only read the cached result. Falls back to the last saved
/// location so it can run before the first fresh GPS fix arrives.
private func refreshRoadDistancesIfNeeded() {
let origin = location ?? FuelStore.loadLocation()
guard let origin else { return }
let priority = worthTheDriveRoutingPriorities(location: origin)
Task {
await RoadDistanceService.refreshIfNeeded(
stations: stations,
lat: origin.lat,
lng: origin.lng,
prioritising: priority
)
}
}
/// The stations worth spending Apple Maps route requests on: the best
/// Worth-the-Drive candidates by estimated net saving, plus the comparison
/// baseline (every other row is measured against it).
///
/// Ranking is by net saving, so these are typically FURTHER out than the
/// nearest stations — the ones nearest-only routing never reached, which is
/// why their rows stayed on straight-line estimates.
private func worthTheDriveRoutingPriorities(location: Coordinate) -> [FuelStation] {
guard fuelSavingsSettings.isConfigured else { return [] }
let baseline = nearestSavingsBaseline
let ranked = FuelSavingsEstimator.ranked(
poolStations,
fuel: selectedFuel,
baseline: baseline,
settings: fuelSavingsSettings,
amount: sessionFuelSavingsAmount,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
var priorities = Array(ranked.prefix(RoadDistanceService.priorityCandidates))
if let baseline, !priorities.contains(where: { $0.id == baseline.id }) {
priorities.insert(baseline, at: 0)
}
return priorities
}
private func ensureBackgroundMotionTrackingIfNeeded() {
if alertsEnabled || liveActivityEnabled {
locationManager.startBackgroundTracking()
}
}
@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()
/// Owns tip purchases; the single instance is shared down to Settings.
/// Its transient outcome rides the SAME status-banner chrome as the
/// network/offline strip (ContentView renders it above the tabs).
@StateObject private var tipStore = TipStore()
// --- Explicit banner animation state (robust fade-out) ---
// SwiftUI's removal transition for the banner proved unreliable here
// (the tip faded to nothing only in the cross-nil case, and even then
// inconsistently). Drive opacity/offset explicitly instead so a
// dismissal ALWAYS fades + slides up on ANY banner change.
@State private var currentBanner: AppBanner?
@State private var bannerOpacity: Double = 0
@State private var bannerOffset: CGFloat = 0
@State private var bannerClearTask: Task<Void, Never>?
/// The pool the list draws from. In Cheapest and Worth-the-Drive modes 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 radius-bound modes.
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
// Test the distance the ROW shows (cached road route where there is one),
// not the straight line: a station 9.8 mi straight-line but 14.6 mi by road
// used to pass a 10-mile filter and then display 14.6 mi inside it.
return selling.filter {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
}
/// 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 {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
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 topStationID(in: displayedStations, baselinePrice: baselinePrice)
}
/// The TOP-badge station within an ALREADY-SORTED list. Split out so a caller
/// that has the sorted list (the Stations tab) does not sort the whole
/// country a second time just to find one id.
private func topStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
list.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: FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)
)
}
private var displayedStations: [FuelStation] {
sortedStations
}
/// Timing wrapper. The work lives in `computeSortedStations()` so the DEBUG
/// probe can wrap it while Release/Archive calls the plain function.
private var sortedStations: [FuelStation] {
#if DEBUG
if ProcessInfo.processInfo.environment["FB_PERF"] != nil {
let started = Date()
let result = computeSortedStations()
print("FB-PERF sortedStations mode=\(effectiveSortMode.rawValue) stations=\(result.count) "
+ "ms=\(String(format: "%.1f", Date().timeIntervalSince(started) * 1_000))")
return result
}
#endif
return computeSortedStations()
}
/// The display list, sorted.
///
/// Both keyed paths DECORATE first: each station's display distance is
/// computed once per station rather than twice per comparison, so ranking the
/// whole country in Closest mode costs ~8k distance reads instead of ~200k.
/// That is the difference between a list that appears instantly and a picker
/// that hangs for seconds — the display distance is not free (it consults the
/// accurate-distances gate and the route cache).
private func computeSortedStations() -> [FuelStation] {
let pool = poolStations
switch effectiveSortMode {
case .closest:
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
let loc = location
var decorated = pool.indices.map { index in
(index: index,
distance: FuelStore.displayDistanceKM(station: pool[index], userLat: loc.lat, userLng: loc.lng))
}
decorated.sort { lhs, rhs in
// Closest first; price only breaks ties. Ordered on the SAME
// distance the row shows, or a row reading "9.9 mi" can sit above
// one reading "8.0 mi".
if lhs.distance != rhs.distance { return lhs.distance < rhs.distance }
return pool[lhs.index].prices[selectedFuel]! < pool[rhs.index].prices[selectedFuel]!
}
return decorated.map { pool[$0.index] }
case .cheapest:
// Cheapest first; distance only breaks ties. Decorated so a tie never
// re-reads a distance inside the comparison (equal prices are common,
// and ties are where the old comparator did all its cache work).
var decorated = pool.indices.map { index in
(index: index,
price: pool[index].prices[selectedFuel]!,
distance: location.map {
FuelStore.displayDistanceKM(station: pool[index], userLat: $0.lat, userLng: $0.lng)
} ?? .greatestFiniteMagnitude)
}
decorated.sort { lhs, rhs in
if lhs.price != rhs.price { return lhs.price < rhs.price }
return lhs.distance < rhs.distance
}
return decorated.map { pool[$0.index] }
case .worthTheDrive:
return sortedForWorthTheDrive(pool)
}
}
/// The comparison baseline AND each station's journey, from ONE pass.
///
/// Every savings label on a row needs this pair, and it used to be built
/// twice per tab: `nearestSavingsBaseline` scanned all ~8k stations
/// (computing a journey for each) and `savingsLookup` immediately scanned them
/// all AGAIN to fill the per-station map. Both now share one result.
///
/// The baseline is chosen with the same journey guards as
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
/// off, the ordinary Closest station still names that row.
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
guard let location else { return (nil, [:]) }
guard fuelSavingsSettings.isConfigured else {
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
savingsJourneyMiles(for: $0, location: location)
}
return (baseline, [:])
}
var journeys: [String: FuelSavingsJourney] = [:]
for station in stations where station.prices[selectedFuel] != nil {
if let journey = savingsJourneyMiles(for: station, location: location) {
journeys[station.id] = journey
}
}
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
journeys[$0.id]
}
return (baseline, journeys)
}
/// Row/detail savings lookup with the comparison baseline and the per-station
/// journeys already resolved by `savingsScaffold()`.
///
/// StationsView asks for savings once per visible row, so handing the view a
/// per-station method would rescan ~8k stations dozens of times per render.
private func savingsLookup(baseline: FuelStation?,
journeys: [String: FuelSavingsJourney])
-> (FuelStation) -> FuelSavingsAvailability? {
guard fuelSavingsSettings.isConfigured else { return { _ in nil } }
guard let location else { return { _ in .unavailable("Location unavailable") } }
let baselineJourney: FuelSavingsJourney? = baseline.flatMap {
journeys[$0.id] ?? savingsJourneyMiles(for: $0, location: location)
}
let baselineID = baseline?.id
let settings = fuelSavingsSettings
let amount = sessionFuelSavingsAmount
let fuel = selectedFuel
return { station in
FuelSavingsEstimator.availability(
station: station,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journeys[station.id],
baselineJourney: baselineID.flatMap { journeys[$0] } ?? baselineJourney
)
}
}
/// The nearest station that sells the selected fuel — the Best Value
/// comparison baseline. It is chosen with the SAME journey values used by
/// the savings calculation, so a cached road route can change the baseline
/// and keep the "extra return travel" labels synchronized with the
/// cost calculation. When no road route is cached, the journey falls back to
/// straight-line out-and-back, and the result is marked as an estimate.
private var nearestSavingsBaseline: FuelStation? {
guard let location else { return nil }
return FuelSavingsEstimator.baselineStation(
stations,
fuel: selectedFuel,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
}
/// The sort the UI and the list actually use.
///
/// The Worth-the-Drive sort is only offered while Fuel Savings is
/// configured, but the chosen sort is persisted — so a stored
/// `.worthTheDrive` would otherwise keep the fill-up control on screen (and
/// leave the picker on a mode it no longer lists) after the feature is
/// switched off. Fall back to Cheapest for RENDER ONLY: the stored
/// preference is left untouched, so re-enabling the feature restores the
/// user's choice.
private var effectiveSortMode: SortMode {
(sortMode == .worthTheDrive && !fuelSavingsSettings.isConfigured) ? .cheapest : sortMode
}
/// Journey (out and back) miles for a station: the measured road route when
/// one has been fetched, otherwise a straight-line out-and-back estimate, so
/// that every station the app lists can carry a saving.
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
for: station, userLat: location.lat, userLng: location.lng
) ? .confirmed : .estimated
return FuelSavingsJourney(
miles: roadMeters / FuelSavingsCalculator.metresPerMile,
basis: .roadRoute,
confidence: confidence
)
}
return FuelSavingsJourney(
miles: savingsFallbackMiles(for: station, location: location),
basis: .straightLine,
confidence: .estimated
)
}
private func savingsFallbackMiles(for station: FuelStation, location: Coordinate) -> Double {
FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) / FuelSavingsCalculator.kilometresPerMile * 2
}
private func sortedForWorthTheDrive(_ pool: [FuelStation]) -> [FuelStation] {
guard fuelSavingsSettings.isConfigured, let location else {
#if DEBUG
print("FB-DRIVE skipped configured=\(fuelSavingsSettings.isConfigured) location=\(self.location == nil ? "none" : "ok") pool=\(pool.count)")
#endif
return pool.sorted { ($0.prices[selectedFuel] ?? .infinity) < ($1.prices[selectedFuel] ?? .infinity) }
}
// Every station in the pool is ranked, with a straight-line estimate
// until its road route has been measured. Ranking only a road-route
// shortlist hid cheaper stations a few miles further out and left later
// pages with no savings (see `FuelSavingsEstimator.ranked`).
let baseline = nearestSavingsBaseline
let ordered = FuelSavingsEstimator.ranked(
pool,
fuel: selectedFuel,
baseline: baseline,
settings: fuelSavingsSettings,
amount: sessionFuelSavingsAmount,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
#if DEBUG
logWorthTheDriveSummary(ordered, baseline: baseline, location: location)
#endif
return ordered
}
/// Compiled in EVERY configuration: the detail sheet's `journeyDiagnostics`
/// argument is not conditional (an `#if` inside an argument list does not
/// build), so this symbol must exist for Release/Archive too. Debug builds
/// still require the hidden diagnostics trigger before doing any work;
/// Release returns nil and does no work.
private func journeyDiagnostics(for station: FuelStation, baseline: FuelStation?) -> FuelSavingsJourneyDiagnostics? {
#if DEBUG
guard FuelStore.loadDebugMode(), let location else { return nil }
let stationDistance = FuelStore.roadDistanceDiagnostics(for: station, userLat: location.lat, userLng: location.lng)
let baselineDiagnostics = baseline.map { FuelStore.roadDistanceDiagnostics(for: $0, userLat: location.lat, userLng: location.lng) }
let journey = savingsJourneyMiles(for: station, location: location)
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
let availability = FuelSavingsEstimator.availability(
station: station,
fuel: selectedFuel,
baseline: baseline,
settings: fuelSavingsSettings,
amount: sessionFuelSavingsAmount,
journey: journey,
baselineJourney: baselineJourney
)
let result = availability?.result
func miles(_ value: Double?) -> String {
value.map { String(format: "%.3f mi", $0) } ?? "—"
}
func meters(_ value: Double?) -> String {
value.map { String(format: "%.0f m", $0) } ?? "—"
}
func source(_ basis: FuelSavingsDistanceBasis) -> String {
basis == .roadRoute ? "road route" : "straight-line estimate"
}
func amount(_ value: FuelSavingsFillAmount) -> String {
switch value.unit {
case .pounds: return String(format: "£%.2f", value.value)
case .litres: return String(format: "%.2f L", value.value)
}
}
func rawMoney(_ value: Double?) -> String {
value.map { String(format: "£%.6f", $0) } ?? "—"
}
let candidatePriceValue = station.prices[selectedFuel]
let baselinePriceValue = baseline?.prices[selectedFuel]
let price = candidatePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
let baselinePrice = baselinePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
let priceDifference = candidatePriceValue.flatMap { candidate in
baselinePriceValue.map { String(format: "%+.1fp/L", $0 - candidate) }
} ?? "—"
let litresBasis = sessionFuelSavingsAmount.unit == .pounds ? "candidate price" : "fixed litres"
let rows: [(String, String)] = [
("Station", "\(station.name) (\(station.id))"),
("Settings default fill-up", amount(fuelSavingsSettings.preferredAmount)),
("Effective/session fill-up", amount(sessionFuelSavingsAmount)),
("Fill-up unit", sessionFuelSavingsAmount.unit.displayName),
("MPG", String(format: "%.1f UK MPG", fuelSavingsSettings.mpg)),
("Candidate price", price),
("Baseline price", baselinePrice),
("Price difference", priceDifference),
("Candidate litres", result.map { String(format: "%.6f L · %@", $0.fillLitres, litresBasis) } ?? "—"),
("Gross saving raw", rawMoney(result?.grossSavingGBP)),
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
("Outward road", meters(stationDistance.outwardMeters)),
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
("Total journey", meters(stationDistance.totalJourneyMeters)),
("Cache", stationDistance.hasValidCacheEntry ? "valid" : "missing/invalid"),
("Baseline", baseline.map { "\($0.name) (\($0.id))" } ?? "—"),
("Baseline distance", baselineDiagnostics.map { "\(distanceUnit.format($0.displayDistanceKM)) · \(source($0.displaySource))" } ?? "—"),
("Baseline total", meters(baselineDiagnostics?.totalJourneyMeters)),
("Station journey", miles(journey?.miles)),
("Baseline journey", miles(baselineJourney?.miles)),
("Additional journey", miles(result?.extraDistanceMiles)),
("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"),
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
("Net saving raw", rawMoney(result?.netSavingGBP)),
("Displayed net saving", result?.headline() ?? "—")
]
return FuelSavingsJourneyDiagnostics(rows: rows)
#else
return nil
#endif
}
#if DEBUG
/// Last FB-DRIVE line printed, so the ranking (which runs on every render)
/// only reports when the result actually changes.
private static var lastDriveLogLine: String?
/// One summary line per ranking pass (FB-DRIVE) so a device console shows
/// whether the Best Value list is actually carrying savings for the
/// whole pool — the regression was silent otherwise.
private func logWorthTheDriveSummary(_ ordered: [FuelStation],
baseline: FuelStation?,
location: Coordinate) {
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
var rows: [(station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)] = []
for station in ordered {
// Capture the journey ONCE and print that same value: routing runs
// in the background, so re-reading it for the log would mix a stale
// journey into a fresh figure and make the numbers unreadable.
let journey = savingsJourneyMiles(for: station, location: location)
guard let availability = FuelSavingsEstimator.availability(
station: station,
fuel: selectedFuel,
baseline: baseline,
settings: fuelSavingsSettings,
amount: sessionFuelSavingsAmount,
journey: journey,
baselineJourney: baselineJourney
), let result = availability.result else { continue }
rows.append((station, result, journey))
}
func describe(_ row: (station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)) -> String {
let price = row.station.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a"
return "\"\(row.station.name)\" p=\(price) j=\(String(format: "%.1f", row.journey?.miles ?? 0))mi(\(row.journey?.basis.rawValue ?? "none")) gross=\(String(format: "%.2f", row.result.grossSavingGBP)) extra=\(String(format: "%.2f", row.result.extraDrivingCostGBP)) extraMi=\(String(format: "%.1f", row.result.extraDistanceMiles)) net=\(String(format: "%.2f", row.result.netSavingGBP)) \"\(row.result.headline())\""
}
let baselineLine: String
if let baseline, let baselineJourney {
let price = baseline.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a"
baselineLine = "baseline=\"\(baseline.name)\" p=\(price) j=\(String(format: "%.1f", baselineJourney.miles))mi(\(baselineJourney.basis.rawValue))"
} else {
baselineLine = "baseline=none"
}
let leaders = rows.prefix(3).map(describe).joined(separator: " | ")
let line = "FB-DRIVE pool=\(ordered.count) withSavings=\(rows.count) \(baselineLine) | \(leaders)"
guard ContentView.lastDriveLogLine != line else { return }
ContentView.lastDriveLogLine = line
print(line)
// `FB_DRIVE_FOCUS=<text>` prints one named station's row too, so a
// specific complaint can be checked against real routed numbers.
if let focus = ProcessInfo.processInfo.environment["FB_DRIVE_FOCUS"], !focus.isEmpty,
let row = rows.first(where: { $0.station.name.localizedCaseInsensitiveContains(focus) }) {
print("FB-DRIVE focus \(describe(row))")
}
}
#endif
/// 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)
}
#if DEBUG
/// Prints the DISPLAYED distance and its source for stations matching `query`
/// (see `FB_DIST_FOCUS`). Called once a fix is available and again whenever a
/// road-distance fill lands: that before/after is how the accurate-distances
/// toggle is verified on a real device, where no screenshot tool exists.
static func logDistanceFocus(query: String, stations: [FuelStation], location: Coordinate?,
originNote: String = "") {
if !originNote.isEmpty {
print("FB-DIST-ORIGIN \(originNote)")
}
guard let loc = location else {
print("FB-DIST no location fix (stations=\(stations.count))")
return
}
let matches = stations.filter { $0.name.localizedCaseInsensitiveContains(query) }
print("FB-DIST query=\"\(query)\" matches=\(matches.count) pool=\(stations.count) "
+ "mode=\(FuelStore.accurateDistancesEnabled) "
+ "ready=\(FuelStore.accurateDistancesReady(userLat: loc.lat, userLng: loc.lng))")
for s in matches {
let d = FuelStore.roadDistanceDiagnostics(for: s, userLat: loc.lat, userLng: loc.lng)
let straight = s.distanceKM(to: loc.lat, lng2: loc.lng)
let shown = FuelStore.stationDistanceDisplay(station: s, userLat: loc.lat, userLng: loc.lng)
print("FB-DIST \"\(s.name)\" display=\(String(format: "%.3f", d.displayDistanceKM))km "
+ "straight=\(String(format: "%.3f", straight))km source=\(d.displaySource.rawValue) "
+ "outward=\(d.outwardMeters.map { String(format: "%.0f", $0) } ?? "nil")m "
+ "return=\(d.returnMeters.map { String(format: "%.0f", $0) } ?? "nil")m "
+ "shown=\(String(format: "%.3f", shown.km))km approx=\(shown.isApproximate)")
}
}
/// One line describing where the displayed distances are measured FROM:
/// the raw device fix and its accuracy, the route cache's anchor and their
/// offset, and which branch `distanceDisplayOrigin` took. Printed by
/// `FB_DIST_FOCUS` so a device can prove which basis the list used.
private var distanceFocusOriginNote: String {
let d = displayOriginDiagnostics
let candidate = d.candidate.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
let accuracy = d.accuracy.map { String(format: "%.0fm", $0) } ?? "nil"
let anchor = d.anchor.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
let offset = d.candidateOffsetM.map { String(format: "%.0fm", $0) } ?? "nil"
let served = location.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
return "candidate=\(candidate) acc=\(accuracy) anchor=\(anchor) "
+ "candidateOffset=\(offset) resolved=\(d.source) served=\(served)"
}
#endif
#if DEBUG
/// Times the two forms of the accurate-distances read: the raw suite read
/// (one `UserDefaults(suiteName:)` construction plus a cfprefsd round-trip,
/// which is what `stationDistanceDisplay` did on EVERY call) against the
/// memoised property. Prints microseconds per read so the cost of the old hot
/// path can be multiplied out from measurement instead of guessed at.
static func runToggleReadBenchmark() {
let reads = 20_000
var sink = 0
var started = Date()
for _ in 0..<reads where UserDefaults(suiteName: FuelStore.appGroupSuite)?
.bool(forKey: FuelStore.accurateDistancesKey) == true { sink += 1 }
let raw = Date().timeIntervalSince(started) * 1_000_000 / Double(reads)
started = Date()
for _ in 0..<reads where FuelStore.accurateDistancesEnabled { sink += 1 }
let memo = Date().timeIntervalSince(started) * 1_000_000 / Double(reads)
print("FB-PERF toggleRead raw=\(String(format: "%.2f", raw))µs "
+ "memo=\(String(format: "%.4f", memo))µs sink=\(sink)")
}
#endif
var body: some View {
configuredContent
// Reads `roadDistanceTick` so a finished road-distance fill (or the
// accurate-distances toggle) actually re-renders the list: the row
// distances are read from the shared store inside the row views, so
// nothing else would invalidate them and a completed fill would go
// unseen until some unrelated state changed.
.onChange(of: roadDistanceTick) { _, _ in }
}
private var configuredContent: AnyView {
interactionObservedContent
}
private var presentedContent: AnyView {
AnyView(
// The Best Value announcement for users who updated into the feature
// (see `BestValueAnnouncementHost`): a one-page cover, shown once per
// announcement version, never while first-run onboarding is due.
BestValueAnnouncementHost(
isSuppressed: showOnboarding,
onDismiss: {
// The page writes straight to the store — it is its own cover
// with its own copy of the settings — so pull the stored
// values back in, or the choice wouldn't take effect until
// the next launch.
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
}
) {
mainContent
}
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView {
showOnboarding = false
}
}
.fullScreenCover(isPresented: $showWidgetMock) {
WidgetMockScreen()
}
.sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request)
}
.sheet(item: $monitor.pendingDetailStation) { station in
// One scaffold for the sheet's savings, baseline name and
// diagnostics — this closure used to rebuild the baseline
// twice and scan the whole list again for the journeys.
let scaffold = savingsScaffold()
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit,
fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount,
savingsBaselineStationName: scaffold.baseline?.name,
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
)
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistancesUpdated)) { _ in
roadDistanceTick &+= 1
#if DEBUG
if let query = distFocusQuery {
ContentView.logDistanceFocus(query: query, stations: stations, location: location,
originNote: distanceFocusOriginNote)
}
#endif
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardAccurateDistancesChanged)) { _ in
// Re-render (rows and the radius filter read the same store),
// refresh the Live Activity, and start a fill from here so the
// switch has a visible effect within seconds.
roadDistanceTick &+= 1
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String,
let station = stations.first(where: { $0.id == id }) {
monitor.pendingDetailStation = station
return
}
// Station left the cache — fall back to the map sheet so
// the tap still shows something.
if let lat = info["stationLat"] as? Double,
let lng = info["stationLng"] as? Double {
let name = info["stationName"] as? String ?? "Station"
monitor.pendingStationMap = StationMapRequest(
name: name, latitude: lat, longitude: lng
)
}
}
)
}
private var lifecycleObservedContent: AnyView {
AnyView(
presentedContent
.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
#if DEBUG
showWidgetMock = args.contains("-widgets")
// `-accurateDistances 0|1` (or `FB_ACCURATE_DISTANCES`, the form that
// survives devicectl's argument parsing) forces the setting for a
// launch so both display bases can be exercised without tapping
// Settings. DEBUG-only, but it does write the real setting.
var accurateOverride: String? = nil
if let i = args.firstIndex(of: "-accurateDistances"), i + 1 < args.count {
accurateOverride = args[i + 1]
}
if let env = ProcessInfo.processInfo.environment["FB_ACCURATE_DISTANCES"], !env.isEmpty {
accurateOverride = env
}
if let value = accurateOverride {
FuelStore.setAccurateDistancesEnabled(value == "1")
}
// `FB_PERF_BENCH=1` times the toggle read the display path makes: the
// raw `UserDefaults(suiteName:)` form (what the sort used to do per
// comparison) against the memoised property. DEBUG-only.
if ProcessInfo.processInfo.environment["FB_PERF_BENCH"] != nil {
ContentView.runToggleReadBenchmark()
}
// `-distFocus <substring>` prints the DISPLAYED distance and its
// source for every matching station, read from THIS device's own
// route cache (FB-DIST). Two devices can legitimately disagree
// (one has a routed distance, the other falls back to the straight
// line), and this is the only way to compare them side by side
// without a device screenshot tool. Waits (bounded) for a fix.
var distFocusQuery: String? = nil
if let i = args.firstIndex(of: "-distFocus"), i + 1 < args.count { distFocusQuery = args[i + 1] }
// devicectl swallows a leading-dash app argument (it parses `-distFocus`
// as its own `-t`), so the env form — set as DEVICECTL_CHILD_FB_DIST_FOCUS
// — is the one that works on a real device.
if let env = ProcessInfo.processInfo.environment["FB_DIST_FOCUS"], !env.isEmpty {
distFocusQuery = env
}
if let query = distFocusQuery {
self.distFocusQuery = query
Task { @MainActor in
for _ in 0..<12 {
if location != nil, !stations.isEmpty { break }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
ContentView.logDistanceFocus(query: query, stations: stations, location: location,
originNote: distanceFocusOriginNote)
}
}
// `FB_STALE_INCOMPLETE=1` rewrites the cached route set with the
// complete flag cleared — the state a capped or interrupted pass
// leaves behind. Launching in it must not show "Approx." for ever:
// the app has to notice that nothing is left to route and repair the
// flag. DEBUG-only verification hook.
if ProcessInfo.processInfo.environment["FB_STALE_INCOMPLETE"] == "1",
let cache = FuelStore.loadRoadDistances() {
FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng,
entries: cache.entries, complete: false)
print("FB-COMPLETE forced stale incomplete cached=\(cache.entries.count) "
+ "(expect repair on the next fill check)")
Task { @MainActor in
// Let a fix and the station pool land, then run the fill
// check the app makes on every fix/foreground.
try? await Task.sleep(nanoseconds: 5_000_000_000)
refreshRoadDistancesIfNeeded()
}
}
// `FB_FAKE_FIX="lat,lng,accuracyM"` pushes ONE synthetic fix through
// the real LocationManager path (guard included), then re-prints the
// origin probe. A phone that is sitting still cannot produce the
// coarse Wi-Fi fix that flips every row to "Approx.", so this is how
// that case is verified on-device instead of guessed at.
if let fake = ProcessInfo.processInfo.environment["FB_FAKE_FIX"], !fake.isEmpty {
let parts = fake.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
if parts.count == 3, let lat = Double(parts[0]), let lng = Double(parts[1]),
let accuracy = Double(parts[2]) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 4_000_000_000)
locationManager.applyFix(lat: lat, lng: lng, accuracy: accuracy)
print("FB-FIX applied lat=\(lat) lng=\(lng) acc=\(accuracy)m "
+ "manager=\(locationManager.current.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none")")
try? await Task.sleep(nanoseconds: 2_000_000_000)
if let query = distFocusQuery {
ContentView.logDistanceFocus(query: query, stations: stations, location: location,
originNote: distanceFocusOriginNote)
}
}
}
}
// `-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.
// Repeatable: each occurrence adds another favourite (QA temp).
// Optional ":fuel" suffix (e10|e5|diesel) picks the
// fuel, so the fuel-aware Trends open can be verified (QA temp).
let seedQueries = args.enumerated()
.filter { $0.element == "-seedFavourite" }
.compactMap { i, _ in
i + 1 < args.count ? args[i + 1] : nil
}
if !seedQueries.isEmpty {
let stations = FuelStore.loadStations()
var favs = seedQueries.compactMap { query in
let parts = query.split(separator: ":", maxSplits: 1)
let name = String(parts[0])
let fuel = parts.count > 1
? FuelType(rawValue: String(parts[1])) ?? .e10
: FuelType.e10
return stations.first { $0.name.localizedCaseInsensitiveContains(name) }
.map { FavouriteEntry(station: $0, fuel: fuel) }
}
if favs.isEmpty, let q = seedQueries.first,
let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) {
favs = [FavouriteEntry(station: s, fuel: .e10)]
}
if !favs.isEmpty {
FuelStore.saveFavourites(favs)
WatchSyncManager.shared.pushSnapshot()
}
}
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
}
}
// QA temp: `-fuel <e10|e5|diesel>` sets the app-wide selected
// fuel so the fuel-aware Trends open can be verified (simulates
// the user having switched to the diesel tab).
if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count,
let f = FuelType(rawValue: args[i + 1]) {
selectedFuel = f
FuelStore.saveSelectedFuel(f)
}
// QA temp: `FB_DRIVE_DEBUG=1` (DEBUG builds only) forces the
// optional Fuel Savings feature ON and selects the Worth the Drive
// sort, so the ranking can be exercised from the device console
// (FB-DRIVE) without tapping through Settings.
//
// It must NOT substitute the stored MPG / fill amount: doing that
// planted the DEFAULT values (45 MPG) over the stored ones and made
// every logged figure wrong — the exact opposite of the hook's
// purpose. Only `enabled` is overridden; the store is still the
// source for MPG and fill.
//
// An ENVIRONMENT variable, not a launch argument: devicectl clusters
// dash-options per character, so a word like `-worthTheDrive` is
// parsed as its own `-t` (--timeout) and the launch is rejected.
if ProcessInfo.processInfo.environment["FB_DRIVE_DEBUG"] == "1" {
var forced = FuelStore.loadFuelSavingsSettings()
forced.enabled = true
fuelSavingsSettings = forced
sortMode = .worthTheDrive
}
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
// "Tap station = More info". The stored default is open-Apple-Maps,
// so on a simulator a tap bounces out to Maps with no way back and
// the Worth the Drive detail section can't be checked at all.
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
stationTapAction = .showDetails
}
// `-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
}
#else
showWidgetMock = false
#endif
// Diagnostic only: `effectiveSortMode` already handles a stored
// `.worthTheDrive` that the feature can no longer back.
#if DEBUG
print("FB-SAVINGS launch storedSort=\(sortMode.displayName) effective=\(effectiveSortMode.displayName) configured=\(fuelSavingsSettings.isConfigured) enabled=\(fuelSavingsSettings.enabled) mpg=\(fuelSavingsSettings.mpg) amount=\(fuelSavingsSettings.preferredAmount.value)")
#endif
// 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 DEBUG
let shouldSkipOnboarding = args.contains("-skipOnboarding")
// The onboarding flag lives in app-group defaults, which survive an
// app delete — so a reinstall alone won't bring onboarding back.
// `-showOnboarding` forces it up for the screenshot harness (and
// pairs with OnboardingView's `-onboardingPage` /
// `-onboardingBestValue`).
let shouldForceOnboarding = args.contains("-showOnboarding")
#else
let shouldSkipOnboarding = false
let shouldForceOnboarding = false
#endif
if shouldForceOnboarding {
showOnboarding = true
} else if !FuelStore.shouldShowOnboarding()
|| shouldSkipOnboarding {
locationManager.startForegroundTracking()
// Geofences and the Live Activity must follow the user even in
// the background: wire the delegate hook (fires on every fix
// incl. background significant-change wake-ups) so SwiftUI's
// foreground-only onChange isn't the only update path.
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
updateAlertMonitor()
monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Compute road distances early (throttled; falls back to the
// last saved location) so distance surfaces are road-matched
// as soon as stations are available.
refreshRoadDistancesIfNeeded()
// Refresh only when the cache is stale (twice-a-day policy).
// Skipped under the force-* hooks so the banner stays up.
#if DEBUG
let shouldSkipInitialRefresh = args.contains("-forceOfflineDump")
|| args.contains("-forceConnectionProblem")
|| args.contains("-forceHistoryFailure")
#else
let shouldSkipInitialRefresh = false
#endif
if !shouldSkipInitialRefresh {
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() {
// Onboarding can switch Best Value on and set the MPG and
// fill-up figures. It is a full-screen cover with its own copy
// of the settings, so it writes straight to the store instead of
// through this view's binding — pull the stored values back in
// or the choice wouldn't take effect until the next launch.
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
ensureBestValueAlertModeIsAvailable()
locationManager.startForegroundTracking()
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
updateAlertMonitor()
updateLiveActivity()
Task { await refresh(force: true) }
}
}
.onChange(of: fuelSavingsSettings) { old, new in
// An explicit Settings default edit supersedes the temporary slider
// override. Changes to MPG/enabled alone leave the session fill intact.
if old.preferredAmount != new.preferredAmount {
sessionFuelSavingsAmount = new.preferredAmount
}
// Fuel Savings changed in Settings while the app was running: the
// effective sort follows automatically (no stored sort is touched).
#if DEBUG
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
#endif
ensureBestValueAlertModeIsAvailable()
updateAlertMonitor()
}
.onChange(of: sessionFuelSavingsAmount) { _, _ in
// This remains a session-only override: do not write it to FuelStore.
// Any active Best Value alert/Live Activity must nevertheless use
// the same effective amount as rows, details, favourites and sorting.
updateAlertMonitor()
updateLiveActivity()
}
.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()
}
updateAlertMonitor()
updateLiveActivity()
refreshRoadDistancesIfNeeded()
if FuelStore.hasPendingWatchRefreshRequest {
Task { await handlePendingWatchRefreshRequest() }
}
// No network fetch on foreground unless the watch explicitly requests one.
} else {
locationManager.stopForegroundTracking()
}
}
.onChange(of: locationManager.current) { _, newLocation in
if let newLocation {
deviceLocation = newLocation
deviceLocationAccuracy = locationManager.currentAccuracy
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WatchSyncManager.shared.pushSnapshot()
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).
updateAlertMonitor()
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
}
)
}
private var interactionObservedContent: AnyView {
AnyView(
lifecycleObservedContent
.onChange(of: selectedFuel) { _, _ in
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
.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 {
updateAlertMonitor()
}
updateLiveActivity()
}
.onChange(of: alertsEnabled) { _, newValue in
FuelStore.saveAlertsEnabled(newValue)
monitor.setEnabled(newValue)
updateAlertMonitor()
ensureBackgroundMotionTrackingIfNeeded()
}
.onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue)
updateAlertMonitor()
}
.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)
updateAlertMonitor(fuel: newValue)
}
.onChange(of: alertsMode) { _, newValue in
if newValue == .bestValue, !fuelSavingsSettings.isConfigured {
alertsMode = .cheapest
FuelStore.saveAlertsMode(.cheapest)
updateAlertMonitor()
return
}
FuelStore.saveAlertsMode(newValue)
updateAlertMonitor()
}
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
updateAlertMonitor()
}
.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)
updateAlertMonitor()
}
.onChange(of: favouriteDropAlertsEnabled) { _, newValue in
FuelStore.saveFavouriteDropAlertsEnabled(newValue)
}
.onChange(of: favouriteDropAlertsFuel) { _, newValue in
FuelStore.saveFavouriteDropAlertsFuel(newValue)
}
.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)
ensureBackgroundMotionTrackingIfNeeded()
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 {
updateAlertMonitor()
}
}
.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)
}
)
}
/// The one banner to show right now. Network/offline and tip outcomes all
/// funnel here and render through the SAME floating chrome. A transient
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
/// network state for its ~3.5 s.
private var activeBanner: AppBanner? {
if let outcome = tipStore.outcome { return .tip(outcome) }
switch dataStatus {
case .live: return nil
case .offlineDump(let date): return .offlineDump(date: date)
case .connectionProblem: return .connectionProblem
}
}
private var mainContent: some View {
GeometryReader { geo in
ZStack(alignment: .top) {
rootTabView
if let banner = currentBanner {
// Floating near the top of the screen, over the nav area —
// overlays content (never pushes it) and sits above the
// main content so it doesn't cover or block the list/pill
// beneath it. Opacity/offset are driven explicitly by
// reflectBanner so the fade-out reliably animates.
floatingBanner(banner)
.padding(.top, geo.safeAreaInsets.top + 10)
.opacity(bannerOpacity)
.offset(y: bannerOffset)
}
}
}
.task(id: activeBanner) {
// Fires on launch with the current banner AND whenever it changes —
// unlike onChange(computed) which can miss the first non-nil value
// when dataStatus is set slightly after the view appears.
reflectBanner(activeBanner)
}
}
/// Explicitly animates the banner in/out — a robust replacement for the
/// SwiftUI removal transition (which was silently not firing). Handles:
/// - first appearance → fade + settle down from slightly above
/// - any content change (tip→network, new tip) → fade the old out, then
/// fade the new in — so a tip ALWAYS visibly fades away
/// - dismissal → fade out + slide up, then clear after the fade
private func reflectBanner(_ newBanner: AppBanner?) {
guard newBanner != currentBanner else { return }
bannerClearTask?.cancel()
if let newBanner {
if currentBanner == nil {
present(newBanner)
} else {
// Crossfade: slide+fade the current out, then present the new.
withAnimation(.easeOut(duration: 0.2)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
guard !Task.isCancelled else { return }
present(newBanner)
}
}
} else {
withAnimation(.easeOut(duration: 0.35)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
currentBanner = nil
bannerOpacity = 0
bannerOffset = 0
}
}
}
/// Fade a banner in from slightly above and settle it into place.
private func present(_ banner: AppBanner) {
currentBanner = banner
bannerOpacity = 0
bannerOffset = -16
withAnimation(.easeOut(duration: 0.3)) {
bannerOpacity = 1
bannerOffset = 0
}
}
private var rootTabView: some View {
TabView(selection: $selectedTab) {
stationsTab
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
.tag(0)
favouritesTab
.tabItem { Label("Favourites", systemImage: "star.fill") }
.tag(1)
alertsTab
.tabItem { Label("Alerts", systemImage: "bell.fill") }
.tag(2)
settingsTab
.tag(3)
}
}
/// 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)
}
/// ONE floating banner style for all notifications. Network/offline and
/// tip outcomes share the same chrome; only the tap action differs —
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
@ViewBuilder
private func floatingBanner(_ banner: AppBanner) -> some View {
switch banner {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBannerCard(
icon: "wifi.slash", tint: .orange, title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
trailingIcon: "arrow.clockwise",
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)
) {
Task { await refresh(force: true) }
}
case .connectionProblem:
statusBannerCard(
icon: "wifi.exclamationmark", tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
) {
Task { await refresh(force: true) }
}
case .tip(let outcome):
statusBannerCard(
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
trailingIcon: "xmark", accessibilityLabel: outcome.message
) {
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
}
}
}
/// The single floating-banner chrome shared by every notification
/// (network/offline + tip). Overlaid — never in flow — so it never moves
/// the content beneath it.
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
trailingIcon: String, accessibilityLabel: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
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)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: trailingIcon)
.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)
)
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
)
.padding(.horizontal, 12)
}
.contentShape(Rectangle())
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
}
/// 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 {
// ONE scaffold (baseline + journeys) and ONE sort serve the list, its
// count, the TOP badge and every savings label. Asking for
// `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`,
// `topStationID` and `savingsLookup` separately walked the full ~8k list
// six times per render — seconds of main-thread work in Closest mode,
// where the pool is every station in the UK.
let scaffold = savingsScaffold()
let baselineStation = scaffold.baseline
let displayed = displayedStations
let topID = baselinePrice.flatMap { topStationID(in: displayed, baselinePrice: $0) }
return StationsView(
stations: displayed,
totalCount: displayed.count,
isLoading: isLoading,
selectedFuel: $selectedFuel,
sortMode: Binding(get: { effectiveSortMode }, set: { sortMode = $0 }),
stationLimit: $stationLimit,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
topStationID: topID,
location: location,
favouriteIDs: favouriteIDs,
stationTapAction: stationTapAction,
fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
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 {
let scaffold = savingsScaffold()
let baselineStation = scaffold.baseline
return FavouritesView(
favourites: refreshedFavourites,
selectedFuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
stationTapAction: stationTapAction,
fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
)
}
/// The Alerts tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget.
private var alertsTab: some View {
AlertsView(
enabled: $alertsEnabled,
radius: $alertsRadius,
fuel: $alertsFuel,
alertMode: $alertsMode,
bestValueThresholdGBP: alertsBestValueThresholdGBP,
bestValueConfigured: fuelSavingsSettings.isConfigured,
favouriteDropEnabled: $favouriteDropAlertsEnabled,
favouriteDropFuel: $favouriteDropAlertsFuel,
followsSearch: $alertsFollowsSearch,
liveActivityEnabled: $liveActivityEnabled,
liveActivityFuel: $liveActivityFuel,
liveActivityRadiusMiles: $liveActivityRadiusMiles,
liveActivityFollowsSearch: $liveActivityFollowsSearch,
stationLimit: stationLimit,
distanceUnit: distanceUnit,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert,
onCheckFavouriteDropNow: {
Task { await refresh(force: true) }
}
)
}
/// The Settings tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget.
private var settingsTab: some View {
SettingsView(
tipStore: tipStore,
distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle,
stationTapAction: $stationTapAction,
fuelSavingsSettings: $fuelSavingsSettings,
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
dataRefreshMode: $dataRefreshMode,
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,
roadDistanceDebugStatus: roadDistanceDebugStatus,
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)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
updateAlertMonitor()
}
/// 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)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
updateAlertMonitor()
}
private func handlePendingWatchRefreshRequest() async {
guard FuelStore.hasPendingWatchRefreshRequest else { return }
await refresh(force: true)
}
/// Shows whatever prices we already have BEFORE the network call settles, so
/// a refresh never hides data behind a full-screen spinner. It deliberately
/// does NOT touch `dataStatus`: a fetch that is still in flight must not
/// flash an offline/connection banner, and a successful fetch clears the
/// state anyway. The catch path below labels the state honestly ONLY if the
/// fetch really fails.
private func showFallbackStationsWhileRefreshing() {
guard stations.isEmpty else { return }
if let cached = MirrorFuelProvider.cachedStations(
near: location?.lat,
lng: location?.lng,
fuel: selectedFuel,
radiusKM: nil
), !cached.isEmpty {
stations = cached
return
}
let saved = FuelStore.loadStations()
if !saved.isEmpty {
stations = saved
return
}
// Bundled snapshot — real but possibly dated. Shown so the list is
// never blank while the fetch is in flight; if the fetch fails, the
// catch path re-labels it with the honest bundled-dump date.
if let dump = BundledDumpProvider.stations, !dump.isEmpty {
stations = dump
}
}
/// 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 {
// Freshness ALONE must never skip the fetch: `lastRefresh` is stored
// keychain-first (survives reinstall) while the station file lives in
// the App Group container (wiped on reinstall). A "fresh" cache with
// no cached stations used to short-circuit here and leave the list
// empty for ever after every reinstall. Only skip when there is
// genuinely something to show.
let cachedCount = FuelStore.loadStations().count
if !force, FuelStore.isCacheFresh, cachedCount > 0 {
#if DEBUG
print("FB-REFRESH skip: cache fresh, \(cachedCount) cached stations")
#endif
return // data already fresh — skip network entirely
}
#if DEBUG
print("FB-REFRESH start force=\(force) cached=\(cachedCount)")
#endif
showFallbackStationsWhileRefreshing()
isLoading = true
defer { isLoading = false }
do {
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
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
#if DEBUG
print("FB-REFRESH ok: \(fetched.count) stations from \(LiveChainProvider.lastSource ?? "?")")
#endif
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh()
if force, FuelStore.hasPendingWatchRefreshRequest {
FuelStore.markWatchRefreshHandled()
}
WatchSyncManager.shared.pushSnapshot()
// 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)
monitor.evaluateFavouritePriceDropAlert(
favourites: refreshedFavourites,
previousSnapshots: previousFavouriteSnapshots,
monitoredFuel: favouriteDropAlertsFuel,
enabled: favouriteDropAlertsEnabled
)
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 {
#if DEBUG
print("FB-REFRESH fail: \(error)")
#endif
if force, FuelStore.hasPendingWatchRefreshRequest {
FuelStore.markWatchRefreshHandled()
}
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.
updateAlertMonitor()
// 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?
/// This row is the cheapest price in range — the caller's existing
/// `topStationID` test. Feeds the CHEAPEST badge.
let isTopResult: Bool
let isFavourite: Bool
var tapAction: StationTapAction = .openMap
var fuelSavings: FuelSavingsAvailability?
/// The user's Best Value alert threshold — the app's existing gate for
/// calling a saving worth the drive, so a row labels itself BEST VALUE by
/// exactly the rule the alerts already use. Read per row (the alert path
/// reads it per evaluation too); pass explicitly to inject a value.
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in }
/// The price's point size, scaled with Dynamic Type: the price is built from
/// an explicit size (its "p"/"/L" suffixes are sized relative to it inside
/// `priceTextAttributed`), so it needs a metric rather than a font style.
/// 22pt keeps the price the row's loudest element after the station name.
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
/// The identity column's share of the ROW, for every row and both price
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio,
/// held per row rather than derived from the row's text — the point is that
/// the hairline, the price axis and the star cannot move as the list scrolls.
/// The price column takes the remainder, and its savings capsule scales down
/// instead of widening the column and dragging the hairline with it.
///
/// One value for both price styles: the star used to sit at the end of the
/// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
/// — ~20pt wider than the bare `158.7` at the same point size — did not fit on
/// a 393pt screen. With the star now under the station logo the price column
/// has that width back, so the identity column no longer has to give width up
/// in pounds & pence, and names keep the same room in both styles.
private static let leftSectionShare: CGFloat = 0.52
/// The comparison label sits beside the capsule only where the row is wide
/// enough for both (regular width). On an iPhone the mockup's canvas is
/// wider than the whole screen, so the label drops under the capsule rather
/// than starving the station name.
@Environment(\.horizontalSizeClass) private var sizeClass
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)
}
/// True when this row IS the cheapest in range — its delta reads "best",
/// so labelling it "vs cheapest" would be nonsense.
private var isCheapestPrice: Bool {
guard let price = station.prices[fuel], let baselinePrice else { return false }
return abs(price - baselinePrice) <= 0.05
}
/// The row's single recommendation badge, if any. BEST VALUE (worth the
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one,
/// being context rather than a recommendation.
private var recommendationBadge: StationRecommendationBadge? {
StationRecommendationBadge.badge(
netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapest: isTopResult,
bestValueThresholdGBP: bestValueThresholdGBP
)
}
/// VoiceOver: the price, then the delta the redesign no longer prints — the
/// listing shows a savings capsule in its place, so the comparison against
/// the cheapest price in range would otherwise be visible only as a colour.
private func priceSpokenLabel(_ price: Double) -> String {
guard let deltaText else { return FuelStore.priceTextSpoken(price) }
let comparison = String(localized: "vs cheapest", comment: "Price delta comparison caption")
return "\(FuelStore.priceTextSpoken(price)), \(isCheapestPrice ? deltaText : "\(deltaText) \(comparison)")"
}
private func handleTap() {
switch tapAction {
case .openMap:
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
case .showDetails:
showDetails()
}
}
private func showDetails() {
onShowDetails(station)
}
var body: some View {
// Two sections, one hairline: station identity on the left, price and
// savings on the right. No wrapper VStack and no full-width verdict
// banner under the row — the savings capsule carries the verdict inside
// the right-hand column, and the extra travel behind it is explained in
// the detail sheet instead of being repeated on every row.
HStack(alignment: .center, spacing: 12) {
// LEFT SECTION — station identity: brand logo, then name, address and
// distance. Given a FIXED SHARE of the row (~52%, the mockup's ratio)
// rather than sizing to its content: sized to content, this column
// grew and shrank with each row's badge and savings copy, which moved
// the hairline 23pt between adjacent rows (x767 on a BEST VALUE row,
// x699 on a "costs more" row) — so the divider and the price axis
// both jumped sideways as the list scrolled. One share for every row
// puts them on a single vertical line down the whole list.
HStack(alignment: .top, spacing: 12) {
// The logo, with the favourite star directly beneath it. Moving
// the star out of the price line is what gives the price column
// its width back: sat at the end of that line it held ~28pt of
// the column, which is exactly the space the wide pounds &
// pence price needs for its own digits on a 393pt screen.
// 10pt between logo and star: at 4pt the star's glyph sat almost
// against the logo's bottom edge and read as part of it rather
// than as the control it is.
VStack(spacing: 10) {
// 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, weight: .semibold))
.foregroundStyle(Color.black.opacity(0.82))
}
}
.frame(width: 46, height: 46)
.background(Circle().fill(Color.white))
.clipShape(Circle())
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
favouriteButton
}
// Name, full address, distance
VStack(alignment: .leading, spacing: 2) {
// The name WRAPS instead of truncating: the row is tall
// enough now, and a name cut to "Z A Service Stat…" cannot
// be told apart from the station next door. Baseline
// alignment keeps the notice symbols on the name's FIRST
// line instead of floating beside a two-line name.
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(station.name)
.font(.headline)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
ForEach(StationNotice.notices(for: station)) { notice in
Image(systemName: notice.symbolName)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.accessibilityLabel(notice.title)
}
}
// Wraps for the same reason as the name — addresses here run
// to 70 characters at the 95th percentile.
Text(StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
// ONE-WAY distance: the listing shows how far the station
// is. The return leg stays in the savings explanation, where
// the extra travel it drives is actually costed.
HStack(spacing: 3) {
Image(systemName: "location.fill")
.font(.caption2)
.accessibilityHidden(true)
Text(distance.label(unit: distanceUnit))
.font(.caption2)
.monospacedDigit()
}
.foregroundStyle(.secondary)
.accessibilityElement(children: .ignore)
.accessibilityLabel(distance.spokenLabel(unit: distanceUnit))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in
length * Self.leftSectionShare
}
// 1pt hairline between the two sections. Inset top and bottom so it
// spans the row's content without touching the row edges, and
// decorative: the columns are told apart by their content, and two
// hairs per row would just be noise to VoiceOver.
//
// `secondary`, not `separator`: over a dark row fill the system
// separator measures 7/255 brighter than the fill — measurably
// invisible — while the mockup's rule sits ~112 above it. Secondary
// lands on the mockup's own value in both appearances.
Rectangle()
.fill(Color.secondary)
.frame(width: 1)
.frame(maxHeight: .infinity)
.padding(.vertical, 6)
.accessibilityHidden(true)
// RIGHT SECTION — price and savings, every element leading-aligned on
// ONE axis. The 12pt row spacing plus this column's leading padding
// puts that axis ~22pt right of the hairline. (The mockup centres the
// price against the badge so its status dot hangs into the gutter; the
// redesign was explicitly asked not to do that.)
VStack(alignment: .leading, spacing: 12) {
if let badge = recommendationBadge {
badgeCapsule(badge)
}
// Price line: the status dot and the price, leading-aligned on
// the column's one axis. The star no longer ends this line — it
// sits under the station logo — so this whole column width now
// belongs to the price, which is what lets the wide pounds &
// pence form (£1.58⁷/L) render at full size rather than being
// scaled down or truncated.
if let price = station.prices[fuel] {
HStack(spacing: 5) {
Circle()
.fill(ragColor)
.frame(width: 8, height: 8)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
size: priceSize, weight: .bold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
// The visible "+1.8p vs cheapest" line is gone with the
// redesign, so the comparison rides on the price's label.
.accessibilityElement(children: .ignore)
.accessibilityLabel(priceSpokenLabel(price))
}
if let result = fuelSavings?.result {
savingsCapsule(result)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// The spec's ~20-24pt of air between the divider and the price
// column: the row's own 12pt spacing plus this 10pt.
.padding(.leading, 10)
}
.contentShape(Rectangle())
.onTapGesture(perform: handleTap)
.onLongPressGesture(perform: showDetails)
}
/// The favourite toggle, sitting under the station logo. Kept as its own view
/// so it stays a separate VoiceOver element — the price beside it is a single
/// grouped label — and keeps its own tap target inside the row's tap and
/// long-press gestures.
private var favouriteButton: some View {
Button {
onToggleFavourite()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
.buttonStyle(.borderless)
}
/// The savings capsule plus the comparison it is measured against. The
/// capsule itself is the shared verdict view, so a row, the detail sheet and
/// the compact summary cannot drift apart. The comparison label sits beside
/// the capsule only where there is width for both.
@ViewBuilder
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
let comparison = Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
// Same narrow column as the capsule above it, so the same floor:
// scaled copy, not a trimmed "vs closest st…".
.minimumScaleFactor(0.7)
if sizeClass == .regular {
HStack(spacing: 8) {
FuelSavingsCapsule(result: result)
comparison
}
} else {
VStack(alignment: .leading, spacing: 2) {
FuelSavingsCapsule(result: result)
comparison
}
}
}
/// One recommendation badge, above the price it annotates. Tinted rather
/// than the mockup's solid fill, like every other badge and capsule in the
/// app: coloured text keeps its contrast in both appearances, at every
/// Dynamic Type size, without introducing a colour outside the palette.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
Text(badge.title)
.font(.caption2.bold())
.foregroundStyle(badge.tint)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(badge.tint.opacity(0.15)))
.lineLimit(1)
.fixedSize()
}
}
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
/// they stay out of the shared model the tests cover.
private extension StationRecommendationBadge {
var title: String {
switch self {
case .bestValue:
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold")
case .cheapest:
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
}
}
var tint: Color { self == .bestValue ? .green : .blue }
}
// MARK: - Location manager
@MainActor
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
@Published var current: Coordinate?
/// Horizontal accuracy (metres) of the fix behind `current`. Published
/// alongside it so a surface can tell a real GPS fix from a Wi-Fi guess.
@Published var currentAccuracy: Double?
/// How imprecise a fix may be before it is refused outright (see
/// `didUpdateLocations`). Wi-Fi/cell fixes routinely land at 500 m–3 km;
/// a GPS fix is tens of metres, so this only ever rejects the former.
static let maxTrustedFixAccuracyMeters: Double = 300
/// 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 }
applyFix(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude,
accuracy: loc.horizontalAccuracy)
}
/// One position report. Real delegate fixes and the DEBUG `FB_FAKE_FIX`
/// probe both land here, so the probe exercises the guard below instead of
/// bypassing it.
///
/// Junk-fix guard: CoreLocation hands over Wi-Fi/cell fixes that can sit
/// kilometres out, plus negative accuracies meaning "invalid". Adopting one
/// as the user's position re-bases EVERY distance on screen — the fix lands
/// outside the route cache's 600 m ring, so each row falls back to the
/// straight line marked "Approx." even though its routed figure was
/// correct, and refreshing never helped because the junk fix kept arriving.
/// A coarse fix that stays near the position we already have is noise, so
/// the last trustworthy fix is kept; a coarse fix in another city is
/// evidence of real travel and IS accepted, or the app would go on serving
/// the old city's distances.
func applyFix(lat: Double, lng: Double, accuracy: Double) {
guard accuracy > 0 else { return }
let basis = current ?? FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
if let basis,
accuracy > Self.maxTrustedFixAccuracyMeters,
FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng)
<= FuelStore.roadDistanceCoarseFixRadiusMeters {
#if DEBUG
print("FB-FIX rejected coarse fix acc=\(String(format: "%.0f", accuracy))m "
+ "offset=\(String(format: "%.0f", FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng)))m")
#endif
return
}
currentAccuracy = accuracy
current = Coordinate(lat: lat, lng: lng)
// 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.
}
}