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
+123 -6
View File
@@ -14,6 +14,14 @@ struct StationMapRequest: Identifiable {
let longitude: Double 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 /// Geofences favourite + closest stations and fires a local notification when
/// the user enters a station whose price is the cheapest within the radius. /// 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 radiusKM: Double = 3.0
private var lastNotified: [String: Date] = [:] // dedup per station private var lastNotified: [String: Date] = [:] // dedup per station
private var enabled = false 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() { override init() {
super.init() super.init()
@@ -168,7 +179,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
cheapest.id == station.id, cheapest.id == station.id,
let price = cheapest.prices[fuel] else { return } 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() lastNotified[stationID] = Date()
} catch { } catch {
// Silent geofence state stays valid for next entry. // 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 brand = station.brand.isEmpty ? station.name : station.brand
let unit = FuelStore.loadDistanceUnit() let unit = FuelStore.loadDistanceUnit()
let location = FuelStore.loadLocation()
let distanceText: String = { 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)) return unit.format(station.distanceKM(to: location.lat, lng2: location.lng))
}() }()
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.sound = .default
// 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.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
content.sound = .default
addAlertRequest(content: content, station: station) addAlertRequest(content: content, station: station)
}
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" 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. /// attached so the expanded notification shows where the station is.
/// `mapStatus` reports whether the snapshot attached (or why not), so the /// `mapStatus` reports whether the snapshot attached (or why not), so the
/// test UI can show it instead of failing silently. /// 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( private func addAlertRequest(
content: UNMutableNotificationContent, content: UNMutableNotificationContent,
station: FuelStation, station: FuelStation,
ties: [TieChoice] = [],
mapStatus: ((String) -> Void)? = nil mapStatus: ((String) -> Void)? = nil
) { ) {
content.userInfo = [ content.userInfo = [
@@ -207,6 +249,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
"stationLat": station.lat, "stationLat": station.lat,
"stationLng": station.lng, "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 let identifier = UUID().uuidString
// Render the map FIRST, then deliver ONE notification with the map // 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 let deliver: (String) -> Void = { status in
guard !finished else { return } guard !finished else { return }
finished = true finished = true
if !ties.isEmpty {
self.registerTieCategory(identifier: identifier, ties: ties)
content.categoryIdentifier = "tie-\(identifier)"
}
mapStatus?(status) mapStatus?(status)
UNUserNotificationCenter.current().add( UNUserNotificationCenter.current().add(
UNNotificationRequest(identifier: identifier, content: content, trigger: nil) 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. /// Draws a red pin on the map snapshot at the station's location.
private static func mapImage(snapshot: MKMapSnapshotter.Snapshot, station: FuelStation) -> UIImage? { private static func mapImage(snapshot: MKMapSnapshotter.Snapshot, station: FuelStation) -> UIImage? {
let image = snapshot.image let image = snapshot.image
@@ -306,14 +382,42 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
return unit.format(candidate.distanceKM(to: location.lat, lng2: location.lng)) return unit.format(candidate.distanceKM(to: location.lat, lng2: location.lng))
}() }()
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.sound = .default
// 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.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." 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))" let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))"
lastTestResult = baseResult lastTestResult = baseResult
addAlertRequest(content: content, station: candidate) { status in addAlertRequest(content: content, station: candidate) { status in
self.lastTestResult = "\(baseResult) · \(status)" self.lastTestResult = "\(baseResult) · \(status)"
} }
}
} else { } else {
// No real candidate (no stations loaded yet, or none sell the // No real candidate (no stations loaded yet, or none sell the
// monitored fuel) still fire so delivery is testable, but say // monitored fuel) still fire so delivery is testable, but say
@@ -386,6 +490,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// Tapping a notification opens Apple Maps directions to the station from /// 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 /// 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. /// 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( nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
@@ -396,7 +505,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let lat = userInfo["stationLat"] as? Double let lat = userInfo["stationLat"] as? Double
let lng = userInfo["stationLng"] as? Double let lng = userInfo["stationLng"] as? Double
Task { @MainActor in 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) openDirections(to: name, latitude: lat, longitude: lng)
} }
} }
@@ -146,6 +146,33 @@ final class SortBaselineTests: XCTestCase {
let sorted = [a, b, c].sorted { $0.prices[.e10]! < $1.prices[.e10]! } let sorted = [a, b, c].sorted { $0.prices[.e10]! < $1.prices[.e10]! }
XCTAssertEqual(sorted.map(\.id), ["b", "c", "a"]) XCTAssertEqual(sorted.map(\.id), ["b", "c", "a"])
} }
// Ties: stations at the same cheapest price are found (within 0.01p) and
// sorted nearest-first from the reference location.
func testTiedStations() {
func station(_ id: String, _ lat: Double, _ lng: Double, _ price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
}
let far = station("far", 53.72, -1.82, 137.0)
let near = station("near", 53.71, -1.81, 137.0)
let other = station("other", 53.70, -1.80, 141.0)
let tied = FuelStore.tiedStations(in: [far, other, near], fuel: .e10, price: 137.0,
fromLat: 53.7, lng: -1.8)
XCTAssertEqual(tied.map(\.id), ["near", "far"], "tied stations sorted nearest-first")
}
// A price 0.005p away still counts as a tie; 0.05p away does not.
func testTiedStationsTolerance() {
func station(_ id: String, _ price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: 53.7, lng: -1.8, prices: [.e10: price], priceUpdated: nil)
}
let a = station("a", 137.005)
let b = station("b", 137.05)
XCTAssertEqual(FuelStore.tiedStations(in: [a, b], fuel: .e10, price: 137.0,
fromLat: 53.7, lng: -1.8).map(\.id), ["a"])
}
} }
final class DistanceTests: XCTestCase { final class DistanceTests: XCTestCase {
+16
View File
@@ -257,6 +257,22 @@ struct FuelStore {
// defaults-first for stations (keychain may hold a legacy small set from // defaults-first for stations (keychain may hold a legacy small set from
// older builds; the full country dump always wins). // older builds; the full country dump always wins).
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple
/// cheapest-station choices in an alert notification.
static func tiedStations(
in stations: [FuelStation],
fuel: FuelType,
price: Double,
fromLat lat: Double, lng: Double
) -> [FuelStation] {
stations
.filter { abs(($0.prices[fuel] ?? .infinity) - price) < 0.01 }
.sorted { lhs, rhs in
lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
}
}
static func loadStations() -> [FuelStation] { static func loadStations() -> [FuelStation] {
if let defaults = UserDefaults(suiteName: appGroupSuite), if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: stationsKey), let data = defaults.data(forKey: stationsKey),