The notification used to be added only inside the MKMapSnapshotter completion — if the snapshot hung or failed, no notification was ever delivered and the result line never showed the map status. Now the notification fires immediately (same identifier), the snapshot renders in the background with an 8s timeout, and on success the delivered notification is replaced in place with the map attached. Result line always updates: preparing -> attached / failed / timed out.
425 lines
20 KiB
Swift
425 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
|
|
|
|
// Deliver the notification IMMEDIATELY — the alert must never wait on
|
|
// the map. If the snapshot later succeeds, the same identifier is used
|
|
// to replace the delivered notification with the map attached.
|
|
mapStatus?("map: preparing…")
|
|
UNUserNotificationCenter.current().add(
|
|
UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
|
|
)
|
|
|
|
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)
|
|
|
|
// Timeout so a hung snapshot can't leave the result line stuck on
|
|
// "preparing" forever.
|
|
var finished = false
|
|
let timeout = DispatchWorkItem {
|
|
Task { @MainActor in
|
|
guard !finished else { return }
|
|
finished = true
|
|
mapStatus?("map: timed out")
|
|
}
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8, execute: timeout)
|
|
|
|
snapshotter.start { [content] snapshot, error in
|
|
timeout.cancel()
|
|
Task { @MainActor in
|
|
guard !finished else { return }
|
|
finished = true
|
|
let finalContent = content
|
|
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) {
|
|
finalContent.attachments = [attachment]
|
|
status = "map: attached"
|
|
}
|
|
} else if let error {
|
|
status = "map: snapshot failed (\(error.localizedDescription))"
|
|
}
|
|
mapStatus?(status)
|
|
// Replace the delivered notification (same identifier) so the
|
|
// map appears in place.
|
|
UNUserNotificationCenter.current().add(
|
|
UNNotificationRequest(identifier: identifier, content: finalContent, trigger: nil)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|