Tied cheapest stations become notification action buttons

When several stations share the cheapest price within the radius (within
0.01p), the alert registers a dynamic UNNotificationCategory with up to
4 action buttons (brand + distance, nearest first) and taps open
directions to the chosen station. Shared FuelStore.tiedStations helper
used by both the live geofence path and the Settings real-data test;
35 tests pass.
This commit is contained in:
FuelBoard Contributor
2026-08-12 10:48:08 +01:00
parent cbe01f4219
commit 1f50d7dcf4
3 changed files with 173 additions and 13 deletions
+130 -13
View File
@@ -14,6 +14,14 @@ struct StationMapRequest: Identifiable {
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.
///
@@ -33,6 +41,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
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()
@@ -168,7 +179,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
cheapest.id == station.id,
let price = cheapest.prices[fuel] else { return }
fireAlert(for: cheapest, price: price)
// 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.
@@ -176,18 +193,37 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
private func fireAlert(for station: FuelStation, price: Double) {
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 = FuelStore.loadLocation() else { return unit.format(0) }
guard let location 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)
// 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 tieBrand = tie.brand.isEmpty ? tie.name : tie.brand
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: "\(tieBrand) · \(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"
}
@@ -197,9 +233,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// 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 = [
@@ -207,6 +249,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
"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
// Render the map FIRST, then deliver ONE notification with the map
@@ -230,6 +281,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let deliver: (String) -> Void = { status in
guard !finished else { return }
finished = true
if !ties.isEmpty {
self.registerTieCategory(identifier: identifier, ties: ties)
content.categoryIdentifier = "tie-\(identifier)"
}
mapStatus?(status)
UNUserNotificationCenter.current().add(
UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
@@ -266,6 +321,27 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
/// 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
@@ -306,13 +382,41 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
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)"
// 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 tieBrand = station.brand.isEmpty ? station.name : station.brand
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: "\(tieBrand) · \(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
@@ -386,6 +490,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// 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 itself falls
/// back to the primary station.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
@@ -396,7 +505,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let lat = userInfo["stationLat"] as? Double
let lng = userInfo["stationLng"] as? Double
Task { @MainActor in
if let name, let lat, let lng {
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 let name, let lat, let lng {
openDirections(to: name, latitude: lat, longitude: lng)
}
}