Files
fuelboard/FuelBoard/ProximityMonitor.swift
T
FuelBoard Contributor cbe01f4219 Single notification delivery: map renders before add()
LiveContainer did not honour the replace-in-place contract: adding a
second request with the same identifier delivered a SECOND notification
instead of updating the first. Now the MKMapSnapshotter renders first
(10s timeout), then exactly ONE request is added with the map attached.
If the snapshot fails or times out, the notification still fires without
the image — never two notifications.
2026-08-12 10:21:59 +01:00

424 lines
20 KiB
Swift

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 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
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)
}
}
}
/// 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))"
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.
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)
}
}
}
}
}