Both the real alert path and the real-data Settings test now render the notification body as 'Station · 1.2 mi away · 145.9p' — actual haversine distance to the station in the user's unit, not just the radius.
272 lines
13 KiB
Swift
272 lines
13 KiB
Swift
import CoreLocation
|
|
import UserNotifications
|
|
import SwiftUI
|
|
|
|
/// 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?
|
|
|
|
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 to open."
|
|
content.sound = .default
|
|
|
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
UNUserNotificationCenter.current().add(request)
|
|
|
|
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
|
|
}
|
|
|
|
/// 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 to open."
|
|
content.sound = .default
|
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
UNUserNotificationCenter.current().add(request)
|
|
lastTestResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))"
|
|
} 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. Purely tests "does a notification appear, and does tapping
|
|
/// it open the app". If notifications are denied system-wide nothing can
|
|
/// display, but this fires regardless of every FuelBoard condition.
|
|
static func sendPlainTestNotification() {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "FuelBoard test notification"
|
|
content.body = "This is a plain test alert — no criteria were checked. Tap to open FuelBoard."
|
|
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 just opens the app (the alert's "Tap to open").
|
|
/// No extra routing — the app comes to the foreground as-is.
|
|
nonisolated func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse,
|
|
withCompletionHandler completionHandler: @escaping () -> Void
|
|
) {
|
|
completionHandler()
|
|
}
|
|
}
|