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 } /// One station offered as a dynamic notification action button when several /// stations tie for the cheapest price within the radius. struct TieChoice { let station: FuelStation /// Short button label, e.g. "Tesco · 0.8 mi". let buttonTitle: String } /// 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 /// Dynamically registered categories for tie-choice notifications. Kept so /// setNotificationCategories never drops previously registered categories. private var tieCategories: Set = [] 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 } // Ties: all stations in radius at the same cheapest price. let tied = FuelStore.tiedStations( in: withinRadius, fuel: fuel, price: price, fromLat: station.lat, lng: station.lng ) fireAlert(for: cheapest, price: price, ties: tied) lastNotified[stationID] = Date() } catch { // Silent — geofence state stays valid for next entry. } } } private func fireAlert(for station: FuelStation, price: Double, ties: [FuelStation] = []) { let brand = station.brand.isEmpty ? station.name : station.brand let unit = FuelStore.loadDistanceUnit() let location = FuelStore.loadLocation() let distanceText: String = { guard let location else { return unit.format(0) } return unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) }() let content = UNMutableNotificationContent() content.sound = .default // When several stations share the cheapest price, offer each as an // action button so the user can pick which to get directions to. let choices = ties.map { tie in let tieDistance: String = { guard let location else { return unit.format(0) } return unit.format(tie.distanceKM(to: location.lat, lng2: location.lng)) }() return TieChoice(station: tie, buttonTitle: "\(tie.name) · \(tieDistance)") } if choices.count > 1 { content.title = "Cheapest \(fuel.displayName) nearby: \(choices.count) stations" content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions." addAlertRequest(content: content, station: station, ties: choices) } else { content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." 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. /// /// When `ties` is non-empty (several stations share the cheapest price), a /// dynamic UNNotificationCategory with up to 4 action buttons is registered /// — one per tied station — and the tie list rides in `userInfo` so the tap /// handler can resolve which station an action button refers to. private func addAlertRequest( content: UNMutableNotificationContent, station: FuelStation, ties: [TieChoice] = [], mapStatus: ((String) -> Void)? = nil ) { content.userInfo = [ "stationName": station.name, "stationLat": station.lat, "stationLng": station.lng, ] if !ties.isEmpty { content.userInfo["tieStations"] = ties.map { tie in [ "name": tie.station.name, "lat": tie.station.lat, "lng": tie.station.lng, ] } } let identifier = UUID().uuidString // Render the map FIRST, then deliver ONE notification with the map // attached. (Earlier, delivering immediately and "replacing" via a // second add() showed up as two notifications in LiveContainer — the // replace-in-place contract isn't honoured there.) A timeout // guarantees the alert still fires, without the map, if the snapshot // hangs. mapStatus?("map: preparing…") 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) var finished = false let deliver: (String) -> Void = { status in guard !finished else { return } finished = true if !ties.isEmpty { self.registerTieCategory(identifier: identifier, ties: ties) content.categoryIdentifier = "tie-\(identifier)" } mapStatus?(status) UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: identifier, content: content, trigger: nil) ) } // Timeout so a hung snapshot can't leave the alert undelivered (and // the result line stuck on "preparing") forever. let timeout = DispatchWorkItem { Task { @MainActor in deliver("map: timed out — no image") } } DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timeout) snapshotter.start { [content] snapshot, error in timeout.cancel() Task { @MainActor in 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) { content.attachments = [attachment] status = "map: attached" } } else if let error { status = "map: snapshot failed (\(error.localizedDescription))" } deliver(status) } } } /// Registers (or updates) a notification category whose action buttons are /// the tied stations, capped at the 4 the system shows. Categories are /// accumulated so prior registrations are never dropped. private func registerTieCategory(identifier: String, ties: [TieChoice]) { let actions = ties.prefix(4).enumerated().map { index, tie in UNNotificationAction( identifier: "tie_\(index)", title: tie.buttonTitle, options: [.foreground] ) } let category = UNNotificationCategory( identifier: "tie-\(identifier)", actions: Array(actions), intentIdentifiers: [], options: [] ) tieCategories.insert(category) UNUserNotificationCenter.current().setNotificationCategories(tieCategories) } /// 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.sound = .default // Ties: every station in radius at the same cheapest price, sorted // nearest first. If more than one, offer them as action buttons. let tieLat = location?.lat ?? candidate.lat let tieLng = location?.lng ?? candidate.lng let tied = FuelStore.tiedStations( in: inRadius, fuel: fuel, price: price, fromLat: tieLat, lng: tieLng ) let choices = tied.map { station in let tieDistance: String = { guard let location else { return unit.format(0) } return unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) }() return TieChoice(station: station, buttonTitle: "\(station.name) · \(tieDistance)") } if choices.count > 1 { content.title = "Cheapest \(fuel.displayName) nearby: \(choices.count) stations" content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions." let baseResult = "\(fuel.displayName) · \(choices.count) tied at \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))" lastTestResult = baseResult addAlertRequest(content: content, station: candidate, ties: choices) { status in self.lastTestResult = "\(baseResult) · \(status)" } } else { content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" lastTestResult = baseResult addAlertRequest(content: content, station: candidate) { status in self.lastTestResult = "\(baseResult) · \(status)" } } } 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. /// /// When the notification carried tie-choice action buttons, the tapped /// button's identifier ("tie_0", "tie_1", …) selects the corresponding /// station from userInfo["tieStations"]; tapping the body itself falls /// back to the primary station. 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 response.actionIdentifier.hasPrefix("tie_"), let index = Int(response.actionIdentifier.dropFirst(4)), let ties = userInfo["tieStations"] as? [[String: Any]], ties.indices.contains(index), let tieName = ties[index]["name"] as? String, let tieLat = ties[index]["lat"] as? Double, let tieLng = ties[index]["lng"] as? Double { openDirections(to: tieName, latitude: tieLat, longitude: tieLng) } else 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) } } } } }