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 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 location: Coordinate? = { if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) } return nil }() @State private var isLoading = false @State private var statusMessage = "" @State private var showOnboarding = false @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 { TabView { StationsView( stations: displayedStations, totalCount: sortedStations.count, isLoading: isLoading, selectedFuel: $selectedFuel, sortMode: $sortMode, stationLimit: $stationLimit, distanceUnit: distanceUnit, baselinePrice: baselinePrice, topStationID: topStationID, location: location, favouriteIDs: favouriteIDs, onToggleFavourite: toggleFavourite, onRefresh: { await refresh(force: true) } ) .tabItem { Label("Stations", systemImage: "fuelpump.fill") } FavouritesView( favourites: refreshedFavourites, selectedFuel: selectedFuel, location: location, distanceUnit: distanceUnit, onToggleFavourite: toggleFavourite ) .tabItem { Label("Favourites", systemImage: "star.fill") } AlertsView( enabled: $alertsEnabled, radius: $alertsRadius, fuel: $alertsFuel, distanceUnit: distanceUnit, monitoredCount: monitor.monitoredStationIDs.count, lastAlert: monitor.lastAlert ) .tabItem { Label("Alerts", systemImage: "bell.fill") } settingsTab } .fullScreenCover(isPresented: $showOnboarding) { OnboardingView { showOnboarding = false } } .sheet(item: $monitor.pendingStationMap) { request in StationMapView(request: request) } .onAppear { // 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. if FuelStore.loadHasCompletedOnboarding() { locationManager.startForegroundTracking() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: alertsRadius) monitor.setEnabled(alertsEnabled) // Refresh only when the cache is stale (twice-a-day policy). 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: alertsRadius) 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: alertsRadius) // 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: alertsRadius) } } .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() } .onChange(of: alertsEnabled) { _, newValue in FuelStore.saveAlertsEnabled(newValue) monitor.setEnabled(newValue) monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: alertsRadius) if newValue { locationManager.startBackgroundTracking() } } .onChange(of: alertsRadius) { _, newValue in FuelStore.saveAlertsRadius(newValue) monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: alertsRadius) } .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: alertsRadius) } } /// 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, alertsFuel: alertsFuel, alertsRadiusKM: alertsRadius, testAlertResult: monitor.lastTestResult, onTestAlert: { monitor.sendTestNotification() }, onPlainTestAlert: { monitor.sendPlainTestNotification() }, onRefreshDebugStatus: { monitor.refreshDebugStatus() }, onShowOnboarding: { showOnboarding = true }, debugStatus: monitor.debugStatus, appCheapest: appCheapestStatus ) .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: alertsRadius) } /// Fetches fresh prices, but only when the cache is stale — unless /// `force` is true (pull-to-refresh is the manual override). private func refresh(force: Bool = false) async { if !force, FuelStore.isCacheFresh { return // data already fresh — skip network entirely } isLoading = true defer { isLoading = false } do { let fetched = try await FuelPriceProvider.active.fetchStations( near: location?.lat, lng: location?.lng, fuel: selectedFuel, radiusKM: nil // full-UK dump; device filters by miles radius ) stations = fetched FuelStore.saveStations(fetched) FuelStore.saveLastRefresh() // Persist relay envelope metadata (source, station count, GOV.UK // dataset update time) for the Settings → About section. if let meta = RelayFuelProvider.latestMeta { FuelStore.saveRelayMeta(meta) } // Keep the keychain favourites fresh with the new prices — the // widget's Favourites mode reads them from keychain (the only // channel shared on SideStore free), so stale star-time snapshots // would otherwise show old prices. FuelStore.saveFavourites(refreshedFavourites) WidgetCenter.shared.reloadAllTimelines() statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" } catch { statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data." stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations() } // Keep monitor geofences in sync with the freshest data. monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: alertsRadius) } } // MARK: - Station row (shared by Stations + Favourites tabs) struct StationRow: View { let station: FuelStation let fuel: FuelType let location: Coordinate? let distanceUnit: DistanceUnit 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 "best" } // 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) Text(String(format: "£%.3f", price / 100)) .font(.title3.bold().monospaced()) .monospacedDigit() } 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? 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() } } private var lastWidgetReload = Date.distantPast func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // Ignore — the app still works price-sorted without location. } }