Files
fuelboard/FuelBoard/ProximityMonitor.swift
T

552 lines
26 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
}
/// 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<UNNotificationCategory> = []
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
// 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
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 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)
}
}
}
}
}