import CoreLocation import UserNotifications import SwiftUI /// Geofences favourite + closest stations and fires a local notification when /// the user enters a station whose price is the cheapest within the radius. /// /// Region budget: iOS allows 20 monitored regions per app — favourites are /// always registered first, then the closest remaining stations fill the rest. @MainActor final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { @Published var monitoredStationIDs: [String] = [] @Published var lastAlert: String? private let manager = CLLocationManager() private var stations: [FuelStation] = [] private var favourites: [FuelStation] = [] private var fuel: FuelType = .e10 private var radiusKM: Double = 3.0 private var lastNotified: [String: Date] = [:] // dedup per station private var enabled = false override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyHundredMeters manager.pausesLocationUpdatesAutomatically = true // Restore persisted state on background relaunch so region events work // even before the view fully appears. enabled = FuelStore.loadAlertsEnabled() fuel = FuelStore.loadAlertsFuel() radiusKM = FuelStore.loadAlertsRadius() stations = FuelStore.loadStations() favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station) } /// Re-registers geofences. Call whenever stations/favourites/settings change. /// Favourites are fuel-scoped entries; only entries for the monitored fuel /// get priority slots (a Diesel favourite must not grab a slot while /// monitoring Unleaded). func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) { // Fall back to the shared cache when called before the first fetch // completes (launch, background region-event wake). self.stations = stations.isEmpty ? FuelStore.loadStations() : stations self.favourites = favourites.isEmpty ? FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station) : favourites.filter { $0.fuel == fuel }.map(\.station) self.fuel = fuel self.radiusKM = radiusKM for region in manager.monitoredRegions { manager.stopMonitoring(for: region) } monitoredStationIDs = [] guard enabled else { return } // Favourites first (guaranteed slots), then closest stations, max 18. // `self.favourites` is already filtered to the monitored fuel. var candidates: [FuelStation] = self.favourites let favIDs = Set(self.favourites.map(\.id)) let location = FuelStore.loadLocation() let others = stations .filter { !favIDs.contains($0.id) } .sorted { lhs, rhs in guard let location else { return false } return lhs.distanceKM(to: location.lat, lng2: location.lng) < rhs.distanceKM(to: location.lat, lng2: location.lng) } candidates.append(contentsOf: others) var registered: [String] = [] for station in candidates.prefix(18) where station.prices[fuel] != nil { let region = CLCircularRegion( center: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng), radius: 300, identifier: station.id ) region.notifyOnEntry = true region.notifyOnExit = false manager.startMonitoring(for: region) registered.append(station.id) } monitoredStationIDs = registered } func setEnabled(_ enabled: Bool) { self.enabled = enabled if !enabled { for region in manager.monitoredRegions { manager.stopMonitoring(for: region) } monitoredStationIDs = [] } else { requestPermissions() // Re-register geofences from restored cache immediately. update(stations: stations, favourites: FuelStore.loadFavourites(), fuel: fuel, radiusKM: radiusKM) } } var isEnabled: Bool { enabled } private func requestPermissions() { // Region monitoring needs Always location for background delivery. switch manager.authorizationStatus { case .notDetermined: manager.requestWhenInUseAuthorization() manager.requestAlwaysAuthorization() case .authorizedWhenInUse: manager.requestAlwaysAuthorization() default: break } UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } } // MARK: - Region events nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { Task { @MainActor in self.handleEntry(region) } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // Ignore — alerts still work while the app is open. } private func handleEntry(_ region: CLRegion) { let stationID = region.identifier // Fall back to cached data on background wake (stations may not be // fetched yet when the app is relaunched by a region event). if stations.isEmpty { stations = FuelStore.loadStations() } guard let station = stations.first(where: { $0.id == stationID }) else { return } // Dedup: one alert per station per hour. if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 { return } Task { do { // Fresh prices around the entered station (alert radius in km). let fresh = try await FuelPriceProvider.active.fetchStations( near: station.lat, lng: station.lng, fuel: fuel, radiusKM: max(radiusKM, 10) // fetch wider than trigger radius ) let withinRadius = fresh.filter { $0.prices[fuel] != nil && $0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM } guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }), cheapest.id == station.id, let price = cheapest.prices[fuel] else { return } fireAlert(for: cheapest, price: price) lastNotified[stationID] = Date() } catch { // Silent — geofence state stays valid for next entry. } } } private func fireAlert(for station: FuelStation, price: Double) { let brand = station.brand.isEmpty ? station.name : station.brand let unit = FuelStore.loadDistanceUnit() let content = UNMutableNotificationContent() content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" content.body = "\(station.name) is the cheapest within \(unit.format(radiusKM)) at \(String(format: "%.1fp", price)). Tap to open." content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(unit.format(radiusKM)) radius" } }