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