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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user