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 favourites: [FuelStation] = FuelStore.loadFavourites() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @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 locationManager = LocationManager() @StateObject private var monitor = ProximityMonitor() /// Stations within the alert trigger radius of the current location, used /// as the pool for "cheapest" (RAG reference, TOP, sorting). Falls back to /// the full fetch when location is unknown or the radius pool is empty /// (rural areas) so the list is never blank. private var radiusScopedStations: [FuelStation] { guard let location else { return stations } let within = stations.filter { $0.distanceKM(to: location.lat, lng2: location.lng) <= alertsRadius } return within.isEmpty ? stations : within } private var cheapestPrice: Double? { radiusScopedStations.compactMap { $0.prices[selectedFuel] }.min() } private var displayedStations: [FuelStation] { Array(sortedStations.prefix(stationLimit)) } private var sortedStations: [FuelStation] { let available = radiusScopedStations.filter { $0.prices[selectedFuel] != nil } switch sortMode { case .closest: guard let location else { return available.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } } return available.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 available.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) } } } private var favouriteIDs: Set { Set(favourites.map(\.id)) } private var refreshedFavourites: [FuelStation] { FuelStore.refreshedFavourites(favourites, from: stations) } var body: some View { TabView { StationsView( stations: displayedStations, totalCount: sortedStations.count, isLoading: isLoading, selectedFuel: $selectedFuel, sortMode: $sortMode, stationLimit: $stationLimit, cheapestPrice: cheapestPrice, location: location, radiusKM: alertsRadius, favouriteIDs: favouriteIDs, onToggleFavourite: toggleFavourite ) .tabItem { Label("Stations", systemImage: "fuelpump.fill") } FavouritesView( favourites: refreshedFavourites, selectedFuel: selectedFuel, location: location, favouriteIDs: favouriteIDs, onToggleFavourite: toggleFavourite ) .tabItem { Label("Favourites", systemImage: "star.fill") } AlertsView( enabled: $alertsEnabled, radius: $alertsRadius, monitoredCount: monitor.monitoredStationIDs.count, lastAlert: monitor.lastAlert ) .tabItem { Label("Alerts", systemImage: "bell.fill") } } .onAppear { locationManager.startForegroundTracking() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: selectedFuel, radiusKM: alertsRadius) monitor.setEnabled(alertsEnabled) Task { await refresh() } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { locationManager.startForegroundTracking() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: selectedFuel, radiusKM: alertsRadius) Task { await refresh() } } 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() Task { await refresh() } } } .onChange(of: selectedFuel) { _, _ in // Re-fetch so the station set matches the selected fuel (relay // filters by grade); all prices still come back in one response. Task { await refresh() } } .onChange(of: alertsEnabled) { _, newValue in FuelStore.saveAlertsEnabled(newValue) monitor.setEnabled(newValue) monitor.update(stations: stations, favourites: refreshedFavourites, fuel: selectedFuel, radiusKM: alertsRadius) if newValue { locationManager.startBackgroundTracking() } } .onChange(of: alertsRadius) { _, newValue in FuelStore.saveAlertsRadius(newValue) monitor.update(stations: stations, favourites: refreshedFavourites, fuel: selectedFuel, radiusKM: alertsRadius) } } private func toggleFavourite(_ station: FuelStation) { if favouriteIDs.contains(station.id) { favourites.removeAll { $0.id == station.id } } else { favourites.append(station) } FuelStore.saveFavourites(favourites) WidgetCenter.shared.reloadAllTimelines() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: selectedFuel, radiusKM: alertsRadius) } private func refresh() async { isLoading = true defer { isLoading = false } do { let fetched = try await FuelPriceProvider.active.fetchStations(near: location?.lat, lng: location?.lng, fuel: selectedFuel) stations = fetched FuelStore.saveStations(fetched) 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: selectedFuel, radiusKM: alertsRadius) } } // MARK: - Station row (shared by Stations + Favourites tabs) struct StationRow: View { let station: FuelStation let fuel: FuelType let location: Coordinate? let cheapestPrice: Double? let isTopResult: Bool let isFavourite: Bool var onToggleFavourite: () -> Void = {} private var ragColor: Color { guard let price = station.prices[fuel], let cheapestPrice else { return .gray } switch RAGRating.rating(price: price, cheapest: cheapestPrice) { case .green: return .green case .amber: return .orange case .red: return .red } } private var deltaText: String? { guard let price = station.prices[fuel], let cheapestPrice else { return nil } let delta = price - cheapestPrice if delta <= 0.05 { return "best" } return String(format: "+%.1fp", delta) } var body: some View { HStack(spacing: 12) { // 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) // Name at natural width (does NOT fill the row โ€” truncates); // price + details stay on the row below, right-aligned. VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, 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) } Spacer(minLength: 6) } HStack(spacing: 5) { Text("\(station.address), \(station.postcode)") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Spacer(minLength: 8) if let location { Text(String(format: "%.1f km", station.distanceKM(to: location.lat, lng2: location.lng))) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() } if let price = station.prices[fuel] { Circle() .fill(ragColor) .frame(width: 10, height: 10) Text(String(format: "%.1fp", price)) .font(.title3.bold()) .monospacedDigit() if let deltaText { Text(deltaText) .font(.caption2.bold()) .foregroundStyle(ragColor) } } } } 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) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // Ignore โ€” the app still works price-sorted without location. } }