import CoreLocation import UserNotifications import SwiftUI import MapKit import UIKit /// A station the user wants to see on a map — set when a notification tap /// can't hand off to Apple Maps (e.g. inside LiveContainer), so the app shows /// an in-app map with directions instead. struct StationMapRequest: Identifiable { let id = UUID() let name: String let latitude: Double let longitude: Double } /// 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, UNUserNotificationCenterDelegate { @Published var monitoredStationIDs: [String] = [] @Published var lastAlert: String? @Published private(set) var lastTestResult: String? @Published var pendingStationMap: StationMapRequest? 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 // Show alert banners even while the app is open, and complete the tap // action (opening FuelBoard) when the user taps a notification. UNUserNotificationCenter.current().delegate = self // 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 distanceText: String = { guard let location = FuelStore.loadLocation() else { return unit.format(0) } return unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) }() let content = UNMutableNotificationContent() content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default addAlertRequest(content: content, station: station) lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" } /// Adds a notification whose tap opens directions to `station`. The /// station's coordinates ride in `userInfo` so the tap handler can route /// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is /// attached so the expanded notification shows where the station is. /// `mapStatus` reports whether the snapshot attached (or why not), so the /// test UI can show it instead of failing silently. private func addAlertRequest( content: UNMutableNotificationContent, station: FuelStation, mapStatus: ((String) -> Void)? = nil ) { content.userInfo = [ "stationName": station.name, "stationLat": station.lat, "stationLng": station.lng, ] let options = MKMapSnapshotter.Options() options.region = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng), span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) ) options.size = CGSize(width: 800, height: 400) options.mapType = .standard let snapshotter = MKMapSnapshotter(options: options) snapshotter.start { [content] snapshot, error in Task { @MainActor in let finalContent = content var status = "map: no image" if let snapshot, error == nil, let image = Self.mapImage(snapshot: snapshot, station: station) { let url = FileManager.default.temporaryDirectory .appendingPathComponent("alert-map-\(UUID().uuidString).png") if let data = image.pngData(), (try? data.write(to: url)) != nil, let attachment = try? UNNotificationAttachment(identifier: "map", url: url, options: nil) { finalContent.attachments = [attachment] status = "map: attached" } } else if let error { status = "map: snapshot failed (\(error.localizedDescription))" } mapStatus?(status) let request = UNNotificationRequest(identifier: UUID().uuidString, content: finalContent, trigger: nil) UNUserNotificationCenter.current().add(request) } } } /// Draws a red pin on the map snapshot at the station's location. private static func mapImage(snapshot: MKMapSnapshotter.Snapshot, station: FuelStation) -> UIImage? { let image = snapshot.image let point = snapshot.point(for: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)) let renderer = UIGraphicsImageRenderer(size: image.size) return renderer.image { context in image.draw(at: .zero) let pinSize = CGSize(width: 36, height: 36) let pinRect = CGRect(x: point.x - pinSize.width / 2, y: point.y - pinSize.height, width: pinSize.width, height: pinSize.height) let pin = UIImage(systemName: "mappin.circle.fill")? .withTintColor(.systemRed, renderingMode: .alwaysTemplate) pin?.draw(in: pinRect) } } /// Fires a test alert built from REAL data: the cheapest station selling /// the monitored fuel within the configured alert radius of the current /// location — the same criteria a live geofence entry would apply, minus /// the geofence itself. Uses the monitor's live state (fuel, radius, /// stations) so the notification reflects the Alerts-tab configuration. func sendTestNotification() { let unit = FuelStore.loadDistanceUnit() let location = FuelStore.loadLocation() let sellers = stations.filter { $0.prices[fuel] != nil } // Cheapest within radius, judged around the current location — // identical to handleEntry's withinRadius logic. let inRadius = sellers.filter { station in guard let location else { return true } return station.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM } let candidate = inRadius.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) } if let candidate, let price = candidate.prices[fuel] { let brand = candidate.brand.isEmpty ? candidate.name : candidate.brand let distanceText: String = { guard let location else { return unit.format(0) } return unit.format(candidate.distanceKM(to: location.lat, lng2: location.lng)) }() let content = UNMutableNotificationContent() content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" addAlertRequest(content: content, station: candidate) { status in self.lastTestResult = "\(baseResult) · \(status)" } lastTestResult = baseResult } else { // No real candidate (no stations loaded yet, or none sell the // monitored fuel) — still fire so delivery is testable, but say // why the real criteria found nothing. let content = UNMutableNotificationContent() content.title = "FuelBoard test alert" content.body = sellers.isEmpty ? "No station data loaded yet — open the Stations tab first, then retry." : "No \(fuel.displayName.lowercased()) station within \(unit.format(radiusKM)) of you." content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) lastTestResult = sellers.isEmpty ? "No stations loaded — open the Stations tab first" : "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))" } } /// Fires a notification with NO criteria checks at all — no geofence, no /// cheapest-within-radius, no dedup, no permission gate, no fuel/radius /// coupling. Still carries a REAL station (the nearest one selling the /// monitored fuel) with its real name, price, distance and coordinates, so /// the tap action — directions to that station — is testable regardless of /// every alert condition. If notifications are denied system-wide nothing /// can display, but this fires regardless of every FuelBoard condition. func sendPlainTestNotification() { let unit = FuelStore.loadDistanceUnit() let location = FuelStore.loadLocation() let sellers = stations.filter { $0.prices[fuel] != nil } let nearest = sellers.min { lhs, rhs in let ld = location.map { lhs.distanceKM(to: $0.lat, lng2: $0.lng) } ?? 0 let rd = location.map { rhs.distanceKM(to: $0.lat, lng2: $0.lng) } ?? 0 return ld < rd } let content = UNMutableNotificationContent() if let nearest, let price = nearest.prices[fuel] { let brand = nearest.brand.isEmpty ? nearest.name : nearest.brand let distanceText: String = { guard let location else { return unit.format(0) } return unit.format(nearest.distanceKM(to: location.lat, lng2: location.lng)) }() content.title = "\(brand) — plain test notification" content.body = "\(nearest.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default addAlertRequest(content: content, station: nearest) } else { content.title = "FuelBoard test notification" content.body = "No station data loaded yet — open the Stations tab first." content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) } } // MARK: - Notification presentation + tap /// Show alert banners even while FuelBoard is in the foreground — without /// this, the system silently suppresses notifications when the app is open. nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { Task { @MainActor in completionHandler([.banner, .sound]) } } /// Tapping a notification opens Apple Maps directions to the station from /// the user's current location. If Apple Maps can't be opened (e.g. inside /// LiveContainer), an in-app map sheet with directions is shown instead. nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo let name = userInfo["stationName"] as? String let lat = userInfo["stationLat"] as? Double let lng = userInfo["stationLng"] as? Double Task { @MainActor in if let name, let lat, let lng { openDirections(to: name, latitude: lat, longitude: lng) } } completionHandler() } /// Opens Apple Maps with driving directions to the station; falls back to /// the in-app map sheet when the hand-off fails. @MainActor private func openDirections(to name: String, latitude: Double, longitude: Double) { let query = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" guard let url = URL(string: "maps://?daddr=\(latitude),\(longitude)&q=\(query)") else { pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude) return } UIApplication.shared.open(url) { opened in Task { @MainActor in if !opened { self.pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude) } } } } }