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 = 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 } 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? /// 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=` 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 { 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..` // 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 ` 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 ` 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 ` 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, 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? let isTopResult: Bool let isFavourite: Bool /// Label for the cheapest-pump-price badge ("TOP" in price/distance order, /// "CHEAPEST" when the list is ranked by net saving). var topBadgeText: String = "TOP" var tapAction: StationTapAction = .openMap var fuelSavings: FuelSavingsAvailability? /// The session fill-up figure (the one the adjustment control edits). The /// compact banner does not print it today, but a row that carried a hard /// zero would print "for £0" the moment it did — pass the real figure. var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount var onToggleFavourite: () -> Void = {} var onShowDetails: (FuelStation) -> Void = { _ in } 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 } 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 { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 12) { // LEFT — round brand logo (or generic fuel pump fallback) Group { if let asset = station.brandImageName { Image(asset) .resizable() .scaledToFit() .padding(5) } else { Image(systemName: "fuelpump.fill") .font(.system(size: 22, 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) // MIDDLE — name, full address, distance VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(station.name) .font(.headline) .lineLimit(1) .truncationMode(.tail) ForEach(StationNotice.notices(for: station)) { notice in Image(systemName: notice.symbolName) .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) .accessibilityLabel(notice.title) } } Text(StationAddressLine.display(address: station.address, postcode: station.postcode, name: station.name)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) if let location { let distance = FuelStore.stationDistanceDisplay( station: station, userLat: location.lat, userLng: location.lng ) Text(distance.labelWithReturn(unit: distanceUnit)) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() .accessibilityLabel(distance.spokenLabelWithReturn(unit: distanceUnit)) } } .frame(maxWidth: .infinity, alignment: .leading) // RIGHT — price in £ (no p) + difference vs cheapest VStack(alignment: .trailing, spacing: 2) { if isTopResult { // The badge always marks the CHEAPEST PUMP PRICE (never // "best saving"), so in Best Value order — where the // top row is the best net saving — it may sit lower down // the list, and says so. It sits ABOVE the price it // annotates rather than beside the name, so it reads as // part of the price and the name keeps its width. Text(topBadgeText) .font(.caption2.bold()) .padding(.horizontal, 5) .padding(.vertical, 1) .background(Capsule().fill(.blue.opacity(0.15))) .foregroundStyle(.blue) .lineLimit(1) .fixedSize() } if let price = station.prices[fuel] { HStack(spacing: 5) { Circle() .fill(ragColor) .frame(width: 8, height: 8) FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold) } if let deltaText { Text(deltaText) .font(.caption2.bold().monospaced()) .foregroundStyle(ragColor) if !isCheapestPrice { // "+8.0p" on its own never said what it measured // against; the delta is against the cheapest price // in range in EVERY sort mode. Text("vs cheapest") .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) } } } } // FAR RIGHT — favourite star Button { onToggleFavourite() } label: { Image(systemName: isFavourite ? "star.fill" : "star") .foregroundStyle(isFavourite ? .yellow : .secondary) } .buttonStyle(.borderless) } if fuelSavings != nil { // Full-width verdict banner under the row. Inline in the middle // column it truncated ("Approx. £2.75 better off after tra…") // because the // price block squeezes that column — and the verdict is the // point of this sort mode, so it gets its own line. FuelSavingsSummaryView( availability: fuelSavings, amount: fuelSavingsAmount, compact: true, distanceUnit: distanceUnit ) } } .contentShape(Rectangle()) .onTapGesture(perform: handleTap) .onLongPressGesture(perform: showDetails) } } // 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. } }