import CoreLocation import UserNotifications import SwiftUI import MapKit import UIKit /// Debug-only location snapshot shown in Settings → Debug. Lets the /// developer verify location services while testing: the last fix (with age) /// plus how many stations selling the monitored fuel are within the alert /// radius of that fix — the exact criteria live alerts use. struct DebugLocationStatus: Equatable { var coordinate: Coordinate? var fixAge: TimeInterval? var fuel: FuelType var radiusKM: Double var inRangeCount: Int var cheapestInRange: FuelStation? var cheapestDistanceKM: Double? } /// One recorded event in the live alert trace (Settings → Debug). Lets the /// tester see exactly which stage of the alert chain ran — whether a region /// event arrived at all, which gate (if any) suppressed the alert, and when /// an alert was scheduled. In-memory only: resets on relaunch. struct AlertLogEntry: Identifiable, Equatable { enum Kind: String, Equatable { /// A geofence entry event reached the monitor (didEnterRegion). case entry /// A gate blocked the alert (dedup, not-cheapest, station missing…). case gate /// A non-recoverable failure (fresh-price fetch threw). case error /// An alert was scheduled with UNUserNotificationCenter. case fired } let id = UUID() let date: Date let kind: Kind let text: String } /// App-view cheapest for the Debug section: mirrors EXACTLY what the Stations /// list computes (selected fuel, stationLimit radius, current location), so /// the debug "Cheapest in range" row matches the TOP badge in the app — /// not the alert prediction (which uses alertsFuel + alert radius, a /// different pool by design). struct DebugAppCheapest: Equatable { let station: FuelStation let fuel: FuelType let radiusKM: Double let distanceKM: Double } /// 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. /// Each region is a circle around a station with radius = the alert radius /// (clamped to the device ceiling), so the notification fires on APPROACH — /// entering the winner's circle — not at the forecourt. @MainActor final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate { private static let favouriteDropThresholdPence: Double = 1.0 @Published var monitoredStationIDs: [String] = [] @Published var lastAlert: String? @Published private(set) var lastTestResult: String? @Published var pendingStationMap: StationMapRequest? /// Debug-only snapshot for the Settings → Debug section. Recomputed on /// every location/stations change via `refreshDebugStatus()`. @Published private(set) var debugStatus: DebugLocationStatus? /// Last CoreLocation region-monitoring failure, if any. Shown in Settings /// → Debug so a silently-failed `startMonitoring` (region budget exceeded, /// auth not granted, radius too large) is visible instead of invisible. @Published private(set) var lastRegionError: String? /// Live alert trace (Settings → Debug): every stage the chain reached, /// newest first, capped at 8. Empty = no region event has ever arrived. @Published private(set) var alertLog: [AlertLogEntry] = [] /// How many `didEnterRegion` callbacks this session — proof the geofences /// actually fire (vs. a gate silently blocking every alert). @Published private(set) var regionEventCount = 0 /// Identifier of the armed debug fence (Settings → Debug → "Register /// 100 m fence"), nil when not armed. The fence is a 100 m circle at the /// current location that exists only to prove iOS delivers region events /// on this install; entering it fires a plain notification, no gates. @Published private(set) var debugFenceIdentifier: 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 /// 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() // Effective radius honours "Follow search" even on a cold launch / // background region-event wake (the raw manual radius would otherwise // be used until the first foreground update re-targets geofences). radiusKM = FuelStore.effectiveAlertsRadiusKM( followsSearch: FuelStore.loadAlertsFollowsSearch(), manualKM: 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 refreshDebugStatus() // Drop every region EXCEPT the debug fence (if armed) — the fence is // a fixed 100 m circle at a fixed point that must survive re-registration // churn; stations re-register from scratch every pass. for region in manager.monitoredRegions where !region.identifier.hasPrefix("debug-fence") { manager.stopMonitoring(for: region) } monitoredStationIDs = [] guard enabled else { return } // A successful re-registration pass resets any earlier failure so the // Debug section only shows the CURRENT region problem. lastRegionError = nil // 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] = [] // Trigger radius = the ALERT radius (approach heads-up), not a tiny // forecourt fence: entering the winner's region means you're within // the alert radius of it, and the notification fires as you approach. // Clamp to the device's region-monitoring ceiling — registering a // larger region fails (kCLErrorRegionMonitoringFailure) or is reduced. let maxRegion = manager.maximumRegionMonitoringDistance let triggerRadius = maxRegion > 0 ? min(radiusKM * 1000, maxRegion) : radiusKM * 1000 for station in candidates.prefix(18) where station.prices[fuel] != nil { let region = CLCircularRegion( center: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng), radius: triggerRadius, 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 = [] debugFenceIdentifier = nil } else { requestPermissions() // Re-register geofences from restored cache immediately. update(stations: stations, favourites: FuelStore.loadFavourites(), fuel: fuel, radiusKM: radiusKM) } } var isEnabled: Bool { enabled } /// Compares the latest refreshed favourites against the persisted cheapest- /// favourite baseline for one fuel and schedules a notification when the /// winner changes or the current winner drops by a meaningful amount. func evaluateFavouritePriceDropAlert( favourites: [FavouriteEntry], previousSnapshots: [FuelType: FavouriteAlertSnapshot], monitoredFuel: FuelType, enabled: Bool ) { var snapshots = previousSnapshots guard let current = FuelStore.cheapestFavourite(in: favourites, fuel: monitoredFuel), let currentPrice = current.station.prices[monitoredFuel] else { snapshots.removeValue(forKey: monitoredFuel) FuelStore.saveFavouriteAlertSnapshots(snapshots) return } let currentSnapshot = FavouriteAlertSnapshot( fuel: monitoredFuel, stationID: current.station.id, stationName: current.station.name, price: currentPrice ) defer { snapshots[monitoredFuel] = currentSnapshot FuelStore.saveFavouriteAlertSnapshots(snapshots) } guard enabled else { return } guard let previous = previousSnapshots[monitoredFuel] else { return } let winnerChanged = previous.stationID != currentSnapshot.stationID let priceDropped = previous.stationID == currentSnapshot.stationID && currentSnapshot.price <= previous.price - Self.favouriteDropThresholdPence guard winnerChanged || priceDropped else { return } fireFavouriteDropAlert(current: current, previous: previous, price: currentPrice, winnerChanged: winnerChanged) } /// Records a stage in the live alert trace, newest first, capped at 8. private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) { alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0) if alertLog.count > 8 { alertLog.removeLast() } } // MARK: - Debug fence /// Debug-only (Settings → Debug → "Register 100 m fence"): arms a 100 m /// circle around the last known fix purely to prove iOS delivers region /// events on this install. Stepping 100 m away and back should tick /// "Region events this session" and fire a plain notification — no alert /// gates apply. If that produces nothing, region delivery is broken at the /// system level and no amount of alert-logic fixing will help. func registerDebugFenceAroundMe() { guard let fix = FuelStore.loadLocationWithDate() else { logAlert(.error, "debug fence — no location fix yet (wait for one, then retry)") return } clearDebugFence() let id = "debug-fence-\(UUID().uuidString.prefix(8))" let region = CLCircularRegion( center: CLLocationCoordinate2D(latitude: fix.coordinate.lat, longitude: fix.coordinate.lng), radius: 100, identifier: id ) region.notifyOnEntry = true region.notifyOnExit = false manager.startMonitoring(for: region) debugFenceIdentifier = id logAlert(.entry, "debug fence armed — 100 m circle at current location (step away and back in)") } func clearDebugFence() { guard let id = debugFenceIdentifier else { return } for region in manager.monitoredRegions where region.identifier == id { manager.stopMonitoring(for: region) } debugFenceIdentifier = nil logAlert(.gate, "debug fence cleared") } /// Recomputes the Settings → Debug location snapshot from current state. /// "In range" uses the SAME criteria as live alerts: stations selling the /// monitored fuel within the alert radius of the last known location. func refreshDebugStatus() { guard let fix = FuelStore.loadLocationWithDate() else { debugStatus = DebugLocationStatus( coordinate: nil, fixAge: nil, fuel: fuel, radiusKM: radiusKM, inRangeCount: 0, cheapestInRange: nil, cheapestDistanceKM: nil) return } let sellers = stations.filter { $0.prices[fuel] != nil } let inRange = sellers.filter { $0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng) <= radiusKM } let cheapest = inRange.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) } let cheapestDistance = cheapest.map { $0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng) } debugStatus = DebugLocationStatus( coordinate: fix.coordinate, fixAge: Date().timeIntervalSince(fix.date), fuel: fuel, radiusKM: radiusKM, inRangeCount: inRange.count, cheapestInRange: cheapest, cheapestDistanceKM: cheapestDistance) } private func requestPermissions() { // Region monitoring needs Always location for background delivery. // Never request WhenInUse and Always back-to-back — iOS ignores the // second call while the first prompt is pending, leaving the app // stuck on WhenInUse and background geofence entries undelivered. // Escalation happens in locationManagerDidChangeAuthorization: // WhenInUse granted -> request Always -> re-register on grant. switch manager.authorizationStatus { case .notDetermined: manager.requestWhenInUseAuthorization() case .authorizedWhenInUse: manager.requestAlwaysAuthorization() default: break } UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { switch manager.authorizationStatus { case .authorizedWhenInUse: // Escalate: background region delivery (the whole point of // alerts) requires Always. iOS shows the upgrade prompt here. manager.requestAlwaysAuthorization() case .authorizedAlways: // Regions registered under WhenInUse-only won't deliver in the // background; re-register now that Always is granted. (ContentView's // LocationManager separately restarts significant-change tracking // on its own delegate callback, so the region window follows the // user in the background.) update(stations: stations, favourites: FuelStore.loadFavourites(), fuel: fuel, radiusKM: radiusKM) default: break } } // MARK: - Region events nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { Task { @MainActor in // Trace: prove the geofence event itself arrived. This is the // single point that distinguishes "no region delivery" from // "delivery fine, a gate blocked the alert". self.regionEventCount += 1 self.logAlert(.entry, "didEnterRegion — \(region.identifier)") // Debug fence (Settings → Debug): exists only to prove iOS // delivers region events on this install. Entering it fires a // plain notification with no alert gates — if the counter ticks // and a banner shows after stepping out and back, delivery works. if region.identifier.hasPrefix("debug-fence") { self.logAlert(.fired, "debug fence entered — iOS delivered the region event (100 m)") self.sendPlainTestNotification() return } self.handleEntry(region) } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // Ignore — alerts still work while the app is open. } nonisolated func locationManager( _ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error ) { Task { @MainActor in // Surface region-registration failures instead of swallowing // them: a failed startMonitoring (region budget, auth, radius) // means alerts silently stop. Cleared on the next successful // re-registration (update() resets it). lastRegionError = "\(region?.identifier ?? "?") · \(error.localizedDescription)" } } 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 { logAlert(.gate, "region \(stationID) — station not in loaded list (Stations tab not fetched yet?)") return } // Dedup: one alert per station per hour. if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 { logAlert(.gate, "\(station.name) — dedup: already alerted \(Int(Date().timeIntervalSince(last) / 60)) min ago") 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]! }) else { logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch") return } guard cheapest.id == station.id else { logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius") return } // withinRadius guarantees the fuel exists on every candidate. guard 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 { // Trace the silent catch — a relay failure drops the alert // with no banner, which previously looked like "no alert". logAlert(.error, "\(station.name) — fresh-price fetch failed: \(error.localizedDescription)") } } } 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) } logAlert(.fired, "alert scheduled — \(brand) · \(String(format: "%.1fp", price))") lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" } private func fireFavouriteDropAlert( current: FavouriteEntry, previous: FavouriteAlertSnapshot, price: Double, winnerChanged: Bool ) { let content = UNMutableNotificationContent() content.sound = .default let fuel = current.fuel let previousPriceText = String(format: "%.1fp", previous.price) let currentPriceText = String(format: "%.1fp", price) if winnerChanged { content.title = "New cheapest \(fuel.displayName.lowercased()) favourite" content.body = "\(current.station.name) is now your cheapest favourite at \(currentPriceText), ahead of \(previous.stationName)." } else { content.title = "Cheapest favourite just dropped" content.body = "\(current.station.name) fell from \(previousPriceText) to \(currentPriceText) for \(fuel.displayName.lowercased())." } addAlertRequest(content: content, station: current.station) logAlert(.fired, "favourite alert scheduled — \(current.station.name) · \(currentPriceText)") lastAlert = "Favourite · \(current.station.name) · \(currentPriceText)" } /// 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 // Tie case: no map. A snapshot would only show one arbitrary station // (the primary), which is misleading — the user must pick from the // action buttons. Deliver immediately with the choice buttons. if !ties.isEmpty { self.registerTieCategory(identifier: identifier, ties: ties) content.categoryIdentifier = "tie-\(identifier)" mapStatus?("map: none (tie — pick a station)") UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: identifier, content: content, trigger: nil) ) return } // Single-station case: 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 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 logAlert(.fired, "test alert scheduled — \(choices.count) tied at \(String(format: "%.1fp", price))") 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 logAlert(.fired, "test alert scheduled — \(brand) · \(String(format: "%.1fp", price))") 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) logAlert(.fired, "test alert scheduled — no \(fuel.displayName.lowercased()) candidate, fallback copy sent") 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 logAlert(.fired, "plain test notification scheduled — \(brand)") 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) logAlert(.fired, "plain test notification scheduled — no station data") } } // 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 of a tie /// notification does NOT open directions — nothing has been chosen yet; /// only the option buttons make a choice. nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo let isTie = userInfo["tieStations"] != nil 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 !isTie, let name, let lat, let lng { openDirections(to: name, latitude: lat, longitude: lng) } // Tie body tap: intentionally no directions — the user must pick // an option button to choose a station. 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) } } } } }