diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 5bbd82e..514eaf7 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -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 = [] 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) } } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index b7662ec..1e81170 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -146,6 +146,33 @@ final class SortBaselineTests: XCTestCase { let sorted = [a, b, c].sorted { $0.prices[.e10]! < $1.prices[.e10]! } 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 { diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index d3efea1..5b0a448 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -257,6 +257,22 @@ struct FuelStore { // defaults-first for stations (keychain may hold a legacy small set from // 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] { if let defaults = UserDefaults(suiteName: appGroupSuite), let data = defaults.data(forKey: stationsKey),