Files
fuelboard/FuelBoard/ProximityMonitor.swift
T
FuelBoard Contributor b1889dacf5 Tie alerts attach the app icon explicitly
LiveContainer shows its own icon in the notification header, so tied
alerts (which carry no map) now attach the app's compiled
AppIcon60x60@2x.png so the FuelBoard identity shows as the banner
thumbnail and in the expanded view. Single-station alerts keep the map
embed.
2026-08-12 11:19:51 +01:00

571 lines
27 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, and
// attach the app icon so the FuelBoard identity shows explicitly
// (the notification header icon is the container's inside
// LiveContainer, so the banner's right-hand thumbnail is the icon).
if !ties.isEmpty {
self.registerTieCategory(identifier: identifier, ties: ties)
content.categoryIdentifier = "tie-\(identifier)"
if let icon = Self.appIconAttachment() {
content.attachments = [icon]
mapStatus?("map: none (tie) · icon: attached")
} else {
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)
}
}
/// The app's own icon as a notification attachment, so the FuelBoard
/// identity shows explicitly even though the notification header icon is
/// the container's inside LiveContainer. Pulls the compiled
/// AppIcon60x60@2x.png straight from the bundle.
private static func appIconAttachment() -> UNNotificationAttachment? {
guard let url = Bundle.main.url(forResource: "AppIcon60x60@2x", withExtension: "png") else {
return nil
}
return try? UNNotificationAttachment(identifier: "icon", url: url, options: nil)
}
/// 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)
}
}
}
}
}