Files
fuelboard/FuelBoard/ProximityMonitor.swift
FuelBoard Contributor 465e6cd4d4 feat: honour Tap station action on notification and Live Activity taps
Notification body/action taps route through StationTapAction:
Open map opens Apple Maps (existing); More info presents the
in-app detail sheet (falls back to the map sheet when the
station is not in cache).

Live Activity Lock Screen card and island station row pick
their Link destination the same way: Maps URL for Open map,
fuelboard://station deep link for More info, handled in
FuelBoardApp and observed by ContentView.

Also adds the small non-interactive map preview above the
Directions button in the More info sheet.
2026-09-16 13:11:36 +01:00

873 lines
42 KiB
Swift

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?
/// Station tapped via notification when Settings → Tap station = More info.
/// ContentView presents this as a StationDetailView sheet.
@Published var pendingDetailStation: FuelStation?
/// 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<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()
// 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 honours Settings → Tap station: Open map opens
/// Apple Maps directions to the station from the user's current location;
/// More info opens the station's detail sheet in the app instead. 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 {
routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
} else if !isTie, let name, let lat, let lng {
routeNotificationTap(name: name, latitude: lat, longitude: lng)
}
// Tie body tap: intentionally no directions — the user must pick
// an option button to choose a station.
completionHandler()
}
}
/// Routes a resolved notification tap through the user's Tap station
/// preference: Open map → Apple Maps; More info → in-app detail sheet
/// (falling back to the map sheet when the station isn't in cache).
@MainActor
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
guard FuelStore.loadStationTapAction() == .showDetails else {
openDirections(to: name, latitude: latitude, longitude: longitude)
return
}
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
?? stations.first(where: { $0.name == name }) {
pendingDetailStation = station
} else {
// Station not in cache (e.g. data refreshed since the alert fired)
// — fall back to the map sheet so the tap still shows something.
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
}
}
/// 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)
}
}
}
}
}