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