Tabs (Stations/Favourites/Alerts): favourites with cheapest-first ranking, proximity geofence alerts (favourites priority, 3km default radius, 1h dedup), Always location + background mode
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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 {
|
||||
@Published var monitoredStationIDs: [String] = []
|
||||
@Published var lastAlert: 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
|
||||
}
|
||||
|
||||
/// Re-registers geofences. Call whenever stations/favourites/settings change.
|
||||
func update(stations: [FuelStation], favourites: [FuelStation], fuel: FuelType, radiusKM: Double) {
|
||||
self.stations = stations
|
||||
self.favourites = favourites
|
||||
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.
|
||||
var candidates: [FuelStation] = favourites
|
||||
let favIDs = Set(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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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.
|
||||
let fresh = try await FuelPriceProvider.active.fetchStations(
|
||||
near: station.lat, lng: station.lng, fuel: fuel
|
||||
)
|
||||
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 = station.prices[fuel] else { return }
|
||||
|
||||
fireAlert(for: station, 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 content = UNMutableNotificationContent()
|
||||
content.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
|
||||
content.body = "\(station.name) is the cheapest within \(Int(radiusKM)) km at \(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)) · \(Int(radiusKM)) km radius"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user