diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index 3e4b44a..150b713 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -12,6 +12,9 @@ struct AlertsView: View { @Binding var enabled: Bool @Binding var radius: Double // stored in km (monitor + storage) @Binding var fuel: FuelType // the fuel alerts monitor for + @Binding var alertMode: AlertMode + @Binding var bestValueThresholdGBP: Double + let bestValueConfigured: Bool @Binding var favouriteDropEnabled: Bool @Binding var favouriteDropFuel: FuelType /// Whether alerts mirror the Stations-tab search distance (capped at 8 mi). @@ -150,6 +153,16 @@ struct AlertsView: View { Section { Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled) + Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) { + ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in + Text(mode.displayName).tag(mode) + } + } + if !bestValueConfigured { + Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint")) + .font(.caption) + .foregroundStyle(.secondary) + } Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) { ForEach(FuelType.allCases) { fuel in Text(fuel.displayName).tag(fuel) @@ -164,6 +177,18 @@ struct AlertsView: View { Text(alertRadiusGuidance) .font(.caption) .foregroundStyle(.secondary) + if alertMode == .bestValue { + Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) { + ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in + Text(FuelSavingsFormat.money(value)).tag(value) + } + } + if !bestValueConfigured { + Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint")) + .font(.caption) + .foregroundStyle(.secondary) + } + } if enabled && monitoredCount == 0 { Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint")) .font(.caption) @@ -172,7 +197,10 @@ struct AlertsView: View { } header: { Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title")) } footer: { - Text(String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())) + let modeDescription = alertMode == .bestValue + ? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased()) + : String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased()) + Text(modeDescription) } if enabled, let lastAlert { diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index e35f1ba..6992e3b 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -20,6 +20,7 @@ struct ContentView: View { /// views, so nothing else would invalidate the list and a freshly completed /// fill would not appear until some other state changed. @State private var roadDistanceTick = 0 + @State private var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = RoadDistanceService.currentDebugStatus() /// Set from `FB_DIST_FOCUS`/`-distFocus` so a road-distance fill can re-print /// the affected station's display value once the new distances land. @State private var distFocusQuery: String? @@ -36,6 +37,8 @@ struct ContentView: View { @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel() + @State private var alertsMode: AlertMode = FuelStore.loadAlertsMode() + @State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP() @State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled() @State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel() @State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch() @@ -43,11 +46,57 @@ struct ContentView: View { @State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel() @State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles() @State private var liveActivityFollowsSearch: Bool = FuelStore.loadLiveActivityFollowsSearch() - @State private var location: Coordinate? = { + /// The device's own last TRUSTWORTHY fix — raw CoreLocation output (subject + /// to LocationManager's junk-fix guard). Used for the network fetch hint and + /// for saving the position; never for labelling distances. + @State private var deviceLocation: Coordinate? = { if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) } return nil }() + /// Horizontal accuracy (metres) of `deviceLocation` when it came from a live + /// fix; nil when the value was seeded from the saved location at launch. + @State private var deviceLocationAccuracy: Double? + + /// The single basis every DISTANCE the user sees is measured from — list + /// rows, the radius filter, sorting, the savings/journey figures and the + /// Live Activity all read this one origin, so no two of them can disagree + /// about where the user is. + /// + /// Resolved through `FuelStore.distanceDisplayOrigin` rather than used raw: + /// a coarse Wi-Fi/cell fix (or one that contradicts the route cache's + /// anchor) must not silently re-base the UI — that is the bug where a row + /// showed an "Approx." marker beside the routed figure. When the live fix + /// is untrustworthy, this stays on the fix the cached routes were built + /// around; when the user has genuinely moved, it follows them. + private var location: Coordinate? { + guard let deviceLocation else { return nil } + let origin = FuelStore.distanceDisplayOrigin( + candidateLat: deviceLocation.lat, + candidateLng: deviceLocation.lng, + candidateAccuracyMeters: deviceLocationAccuracy + ) + return Coordinate(lat: origin.lat, lng: origin.lng) + } + + #if DEBUG + /// The origin decision behind `location`, for the `FB_DIST_FOCUS` probe. + private var displayOriginDiagnostics: (candidate: Coordinate?, accuracy: Double?, + source: String, candidateOffsetM: Double?, + anchor: (lat: Double, lng: Double)?) { + guard let deviceLocation else { return (nil, nil, "noFix", nil, nil) } + let resolved = FuelStore.distanceDisplayOrigin( + candidateLat: deviceLocation.lat, candidateLng: deviceLocation.lng, + candidateAccuracyMeters: deviceLocationAccuracy + ) + let cache = FuelStore.loadRoadDistances() + return (deviceLocation, deviceLocationAccuracy, resolved.source, + cache.map { FuelStore.distanceMetersBetween(lat1: $0.sourceLat, lng1: $0.sourceLng, + lat2: deviceLocation.lat, lng2: deviceLocation.lng) }, + cache.map { ($0.sourceLat, $0.sourceLng) }) + } + #endif + // MARK: - QA overrides (DEBUG) /// QA hooks: `-stationRadius 10` / `-sortMode cheapest` override the stored @@ -88,10 +137,32 @@ struct ContentView: View { FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius) } + private var effectiveAlertsMode: AlertMode { + (alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode + } + + private func ensureBestValueAlertModeIsAvailable() { + guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return } + alertsMode = .cheapest + FuelStore.saveAlertsMode(.cheapest) + } + + private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) { + monitor.update( + stations: stations, + favourites: refreshedFavourites, + fuel: overrideFuel ?? alertsFuel, + radiusKM: effectiveAlertsRadiusKM, + alertMode: effectiveAlertsMode, + bestValueThresholdGBP: alertsBestValueThresholdGBP, + fuelSavingsSettings: fuelSavingsSettings, + fuelSavingsAmount: sessionFuelSavingsAmount + ) + } + private func installLocationUpdateHook() { locationManager.onLocationUpdate = { [weak monitor] in - monitor?.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + self.updateAlertMonitor() // The Live Activity follows the same wake-ups — this hook // fires on every fix incl. background significant-change // wake-ups, so the Lock Screen pill stays live while driving. @@ -591,7 +662,11 @@ struct ContentView: View { /// (see `FB_DIST_FOCUS`). Called once a fix is available and again whenever a /// road-distance fill lands: that before/after is how the accurate-distances /// toggle is verified on a real device, where no screenshot tool exists. - static func logDistanceFocus(query: String, stations: [FuelStation], location: Coordinate?) { + static func logDistanceFocus(query: String, stations: [FuelStation], location: Coordinate?, + originNote: String = "") { + if !originNote.isEmpty { + print("FB-DIST-ORIGIN \(originNote)") + } guard let loc = location else { print("FB-DIST no location fix (stations=\(stations.count))") return @@ -611,6 +686,21 @@ struct ContentView: View { + "shown=\(String(format: "%.3f", shown.km))km approx=\(shown.isApproximate)") } } + + /// One line describing where the displayed distances are measured FROM: + /// the raw device fix and its accuracy, the route cache's anchor and their + /// offset, and which branch `distanceDisplayOrigin` took. Printed by + /// `FB_DIST_FOCUS` so a device can prove which basis the list used. + private var distanceFocusOriginNote: String { + let d = displayOriginDiagnostics + let candidate = d.candidate.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none" + let accuracy = d.accuracy.map { String(format: "%.0fm", $0) } ?? "nil" + let anchor = d.anchor.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none" + let offset = d.candidateOffsetM.map { String(format: "%.0fm", $0) } ?? "nil" + let served = location.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none" + return "candidate=\(candidate) acc=\(accuracy) anchor=\(anchor) " + + "candidateOffset=\(offset) resolved=\(d.source) served=\(served)" + } #endif #if DEBUG @@ -699,7 +789,8 @@ struct ContentView: View { roadDistanceTick &+= 1 #if DEBUG if let query = distFocusQuery { - ContentView.logDistanceFocus(query: query, stations: stations, location: location) + ContentView.logDistanceFocus(query: query, stations: stations, location: location, + originNote: distanceFocusOriginNote) } #endif } @@ -711,6 +802,9 @@ struct ContentView: View { updateLiveActivity() refreshRoadDistancesIfNeeded() } + .onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in + roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus() + } .onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in let info = note.userInfo ?? [:] if let id = info["stationID"] as? String, @@ -783,7 +877,48 @@ struct ContentView: View { if location != nil, !stations.isEmpty { break } try? await Task.sleep(nanoseconds: 1_500_000_000) } - ContentView.logDistanceFocus(query: query, stations: stations, location: location) + ContentView.logDistanceFocus(query: query, stations: stations, location: location, + originNote: distanceFocusOriginNote) + } + } + // `FB_STALE_INCOMPLETE=1` rewrites the cached route set with the + // complete flag cleared — the state a capped or interrupted pass + // leaves behind. Launching in it must not show "Approx." for ever: + // the app has to notice that nothing is left to route and repair the + // flag. DEBUG-only verification hook. + if ProcessInfo.processInfo.environment["FB_STALE_INCOMPLETE"] == "1", + let cache = FuelStore.loadRoadDistances() { + FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng, + entries: cache.entries, complete: false) + print("FB-COMPLETE forced stale incomplete cached=\(cache.entries.count) " + + "(expect repair on the next fill check)") + Task { @MainActor in + // Let a fix and the station pool land, then run the fill + // check the app makes on every fix/foreground. + try? await Task.sleep(nanoseconds: 5_000_000_000) + refreshRoadDistancesIfNeeded() + } + } + // `FB_FAKE_FIX="lat,lng,accuracyM"` pushes ONE synthetic fix through + // the real LocationManager path (guard included), then re-prints the + // origin probe. A phone that is sitting still cannot produce the + // coarse Wi-Fi fix that flips every row to "Approx.", so this is how + // that case is verified on-device instead of guessed at. + if let fake = ProcessInfo.processInfo.environment["FB_FAKE_FIX"], !fake.isEmpty { + let parts = fake.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + if parts.count == 3, let lat = Double(parts[0]), let lng = Double(parts[1]), + let accuracy = Double(parts[2]) { + Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + locationManager.applyFix(lat: lat, lng: lng, accuracy: accuracy) + print("FB-FIX applied lat=\(lat) lng=\(lng) acc=\(accuracy)m " + + "manager=\(locationManager.current.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none")") + try? await Task.sleep(nanoseconds: 2_000_000_000) + if let query = distFocusQuery { + ContentView.logDistanceFocus(query: query, stations: stations, location: location, + originNote: distanceFocusOriginNote) + } + } } } // `-seedFavourite ` pins the first matching station @@ -911,8 +1046,7 @@ struct ContentView: View { // foreground-only onChange isn't the only update path. installLocationUpdateHook() ensureBackgroundMotionTrackingIfNeeded() - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() monitor.setEnabled(alertsEnabled) updateLiveActivity() // Compute road distances early (throttled; falls back to the @@ -951,11 +1085,11 @@ struct ContentView: View { // or the choice wouldn't take effect until the next launch. fuelSavingsSettings = FuelStore.loadFuelSavingsSettings() sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount + ensureBestValueAlertModeIsAvailable() locationManager.startForegroundTracking() installLocationUpdateHook() ensureBackgroundMotionTrackingIfNeeded() - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() updateLiveActivity() Task { await refresh(force: true) } } @@ -966,6 +1100,8 @@ struct ContentView: View { #if DEBUG print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)") #endif + ensureBestValueAlertModeIsAvailable() + updateAlertMonitor() } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { @@ -975,8 +1111,7 @@ struct ContentView: View { if !showOnboarding, FuelStore.loadHasCompletedOnboarding() { locationManager.startForegroundTracking() } - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() updateLiveActivity() refreshRoadDistancesIfNeeded() if FuelStore.hasPendingWatchRefreshRequest { @@ -989,14 +1124,14 @@ struct ContentView: View { } .onChange(of: locationManager.current) { _, newLocation in if let newLocation { - location = newLocation + deviceLocation = newLocation + deviceLocationAccuracy = locationManager.currentAccuracy FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng) WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() // Geofences follow the user's position, but the station list is // NOT re-fetched on every movement (cached, twice-a-day policy). - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() updateLiveActivity() refreshRoadDistancesIfNeeded() } @@ -1021,36 +1156,45 @@ struct ContentView: View { WidgetCenter.shared.reloadAllTimelines() // Follow-search surfaces mirror this distance — re-target. if alertsFollowsSearch { - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() } updateLiveActivity() } .onChange(of: alertsEnabled) { _, newValue in FuelStore.saveAlertsEnabled(newValue) monitor.setEnabled(newValue) - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() ensureBackgroundMotionTrackingIfNeeded() } .onChange(of: alertsRadius) { _, newValue in FuelStore.saveAlertsRadius(newValue) - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() } .onChange(of: alertsFuel) { _, newValue in // Alerts fuel is independent of the Stations-tab selection — // changing it re-targets geofences to stations selling that fuel. FuelStore.saveAlertsFuel(newValue) - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: newValue, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor(fuel: newValue) + } + .onChange(of: alertsMode) { _, newValue in + if newValue == .bestValue, !fuelSavingsSettings.isConfigured { + alertsMode = .cheapest + FuelStore.saveAlertsMode(.cheapest) + updateAlertMonitor() + return + } + FuelStore.saveAlertsMode(newValue) + updateAlertMonitor() + } + .onChange(of: alertsBestValueThresholdGBP) { _, newValue in + FuelStore.saveAlertsBestValueThresholdGBP(newValue) + updateAlertMonitor() } .onChange(of: alertsFollowsSearch) { _, newValue in // Follow search = the geofence mirrors the Stations-tab distance // (capped at 8 mi); picking any fixed radius clears it. FuelStore.saveAlertsFollowsSearch(newValue) - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() } .onChange(of: favouriteDropAlertsEnabled) { _, newValue in FuelStore.saveFavouriteDropAlertsEnabled(newValue) @@ -1086,8 +1230,7 @@ struct ContentView: View { updateLiveActivity() // Follow-search alerts also move with the unit. if alertsFollowsSearch { - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() } } .onChange(of: priceDisplayStyle) { _, newValue in @@ -1387,6 +1530,9 @@ struct ContentView: View { enabled: $alertsEnabled, radius: $alertsRadius, fuel: $alertsFuel, + alertMode: $alertsMode, + bestValueThresholdGBP: $alertsBestValueThresholdGBP, + bestValueConfigured: fuelSavingsSettings.isConfigured, favouriteDropEnabled: $favouriteDropAlertsEnabled, favouriteDropFuel: $favouriteDropAlertsFuel, followsSearch: $alertsFollowsSearch, @@ -1428,6 +1574,7 @@ struct ContentView: View { alertLog: monitor.alertLog, regionEventCount: monitor.regionEventCount, debugFenceIdentifier: monitor.debugFenceIdentifier, + roadDistanceDebugStatus: roadDistanceDebugStatus, onDebugFence: { monitor.registerDebugFenceAroundMe() }, onClearDebugFence: { monitor.clearDebugFence() } ) @@ -1444,8 +1591,7 @@ struct ContentView: View { FuelStore.saveFavourites(favourites) WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() } /// Drag-and-drop reorder from the Favourites tab — persists the new order @@ -1456,8 +1602,7 @@ struct ContentView: View { FuelStore.saveFavourites(favourites) WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() } private func handlePendingWatchRefreshRequest() async { @@ -1582,8 +1727,7 @@ struct ContentView: View { } } // Keep monitor geofences in sync with the freshest data. - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) + updateAlertMonitor() // Fresh prices → refresh the Live Activity pill too. updateLiveActivity() } @@ -1786,6 +1930,13 @@ struct StationRow: View { @MainActor final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { @Published var current: Coordinate? + /// Horizontal accuracy (metres) of the fix behind `current`. Published + /// alongside it so a surface can tell a real GPS fix from a Wi-Fi guess. + @Published var currentAccuracy: Double? + /// How imprecise a fix may be before it is refused outright (see + /// `didUpdateLocations`). Wi-Fi/cell fixes routinely land at 500 m–3 km; + /// a GPS fix is tens of metres, so this only ever rejects the former. + static let maxTrustedFixAccuracyMeters: Double = 300 /// Called after every location fix (foreground AND background wake-ups). /// The app uses it to re-register geofences around the new position — /// SwiftUI's `.onChange` never runs in the background, so this delegate @@ -1847,7 +1998,39 @@ final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocat func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } - current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude) + applyFix(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude, + accuracy: loc.horizontalAccuracy) + } + + /// One position report. Real delegate fixes and the DEBUG `FB_FAKE_FIX` + /// probe both land here, so the probe exercises the guard below instead of + /// bypassing it. + /// + /// Junk-fix guard: CoreLocation hands over Wi-Fi/cell fixes that can sit + /// kilometres out, plus negative accuracies meaning "invalid". Adopting one + /// as the user's position re-bases EVERY distance on screen — the fix lands + /// outside the route cache's 600 m ring, so each row falls back to the + /// straight line marked "Approx." even though its routed figure was + /// correct, and refreshing never helped because the junk fix kept arriving. + /// A coarse fix that stays near the position we already have is noise, so + /// the last trustworthy fix is kept; a coarse fix in another city is + /// evidence of real travel and IS accepted, or the app would go on serving + /// the old city's distances. + func applyFix(lat: Double, lng: Double, accuracy: Double) { + guard accuracy > 0 else { return } + let basis = current ?? FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) } + if let basis, + accuracy > Self.maxTrustedFixAccuracyMeters, + FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng) + <= FuelStore.roadDistanceCoarseFixRadiusMeters { + #if DEBUG + print("FB-FIX rejected coarse fix acc=\(String(format: "%.0f", accuracy))m " + + "offset=\(String(format: "%.0f", FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng)))m") + #endif + return + } + currentAccuracy = accuracy + current = Coordinate(lat: lat, lng: lng) // Drive the widget: every significant move re-orders the widget list // around the new position immediately, instead of waiting for the // widget's own 5-minute timeline tick. Throttled — WidgetKit ignores diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 8dfb3ea..bec1f43 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -111,6 +111,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca private var favourites: [FuelStation] = [] private var fuel: FuelType = .e10 private var radiusKM: Double = 3.0 + private var alertMode: AlertMode = .cheapest + private var bestValueThresholdGBP: Double = 1.0 + private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings() + private var fuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount private var lastNotified: [String: Date] = [:] // dedup per station private var enabled = false /// Dynamically registered categories for tie-choice notifications. Kept so @@ -135,6 +139,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca radiusKM = FuelStore.effectiveAlertsRadiusKM( followsSearch: FuelStore.loadAlertsFollowsSearch(), manualKM: FuelStore.loadAlertsRadius()) + alertMode = FuelStore.loadAlertsMode() + bestValueThresholdGBP = FuelStore.loadAlertsBestValueThresholdGBP() + fuelSavingsSettings = FuelStore.loadFuelSavingsSettings() + fuelSavingsAmount = fuelSavingsSettings.preferredAmount stations = FuelStore.loadStations() favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station) } @@ -143,7 +151,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca /// Favourites are fuel-scoped entries; only entries for the monitored fuel /// get priority slots (a Diesel favourite must not grab a slot while /// monitoring Unleaded). - func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) { + func update( + stations: [FuelStation], + favourites: [FavouriteEntry], + fuel: FuelType, + radiusKM: Double, + alertMode: AlertMode = .cheapest, + bestValueThresholdGBP: Double = 1.0, + fuelSavingsSettings: FuelSavingsSettings = .defaults, + fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount + ) { // Fall back to the shared cache when called before the first fetch // completes (launch, background region-event wake). self.stations = stations.isEmpty ? FuelStore.loadStations() : stations @@ -152,6 +169,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca : favourites.filter { $0.fuel == fuel }.map(\.station) self.fuel = fuel self.radiusKM = radiusKM + self.alertMode = alertMode + self.bestValueThresholdGBP = bestValueThresholdGBP + self.fuelSavingsSettings = fuelSavingsSettings + self.fuelSavingsAmount = fuelSavingsAmount refreshDebugStatus() @@ -169,18 +190,37 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca // Debug section only shows the CURRENT region problem. lastRegionError = nil - // Favourites first (guaranteed slots), then closest stations, max 18. - // `self.favourites` is already filtered to the monitored fuel. + // Favourites first (guaranteed slots). For Best Value mode, spend the + // remaining iOS region slots on likely savings winners before falling + // back to closest stations; the actual notification still rechecks the + // winner after the geofence wakes the app. var candidates: [FuelStation] = self.favourites let favIDs = Set(self.favourites.map(\.id)) let location = FuelStore.loadLocation() - let others = stations - .filter { !favIDs.contains($0.id) } - .sorted { lhs, rhs in + let eligibleOthers = stations.filter { !favIDs.contains($0.id) && $0.prices[fuel] != nil } + let others: [FuelStation] + if alertMode == .bestValue, + fuelSavingsSettings.isConfigured, + let location { + let inRadius = eligibleOthers.filter { + FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM, + userLat: location.lat, userLng: location.lng) + } + others = FuelSavingsEstimator.ranked( + inRadius, + fuel: fuel, + baseline: savingsBaseline(in: inRadius, location: location), + settings: fuelSavingsSettings, + amount: fuelSavingsAmount, + journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) } + ) + } else { + others = eligibleOthers.sorted { lhs, rhs in guard let location else { return false } return FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) < FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng) } + } candidates.append(contentsOf: others) var registered: [String] = [] @@ -222,6 +262,33 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca var isEnabled: Bool { enabled } + private func savingsBaseline(in stations: [FuelStation], location: Coordinate) -> FuelStation? { + FuelSavingsEstimator.baselineStation( + stations, + fuel: fuel, + journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) } + ) + } + + private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? { + if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) { + let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance( + for: station, userLat: location.lat, userLng: location.lng + ) ? .confirmed : .estimated + return FuelSavingsJourney( + miles: roadMeters / FuelSavingsCalculator.metresPerMile, + basis: .roadRoute, + confidence: confidence + ) + } + return FuelSavingsJourney( + miles: FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) / + FuelSavingsCalculator.kilometresPerMile * 2, + basis: .straightLine, + confidence: .estimated + ) + } + /// Compares the latest refreshed favourites against the persisted cheapest- /// favourite baseline for one fuel and schedules a notification when the /// winner changes or the current winner drops by a meaningful amount. @@ -438,25 +505,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca $0.prices[fuel] != nil && $0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM } - guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else { - logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch") - return - } - guard cheapest.id == station.id else { - logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius") - return - } - // withinRadius guarantees the fuel exists on every candidate. - guard let price = cheapest.prices[fuel] else { return } - // 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() + switch alertMode { + case .cheapest: + handleCheapestEntry(station: station, withinRadius: withinRadius) + case .bestValue: + handleBestValueEntry(station: station, withinRadius: withinRadius) + } } catch { // Trace the silent catch — a relay failure drops the alert // with no banner, which previously looked like "no alert". @@ -465,6 +520,65 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca } } + private func handleCheapestEntry(station: FuelStation, withinRadius: [FuelStation]) { + guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else { + logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch") + return + } + guard cheapest.id == station.id else { + logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius") + return + } + // withinRadius guarantees the fuel exists on every candidate. + guard let price = cheapest.prices[fuel] else { return } + + // 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[station.id] = Date() + } + + private func handleBestValueEntry(station: FuelStation, withinRadius: [FuelStation]) { + guard fuelSavingsSettings.isConfigured else { + logAlert(.gate, "\(station.name) — Best Value not configured") + return + } + guard let candidate = bestValueCandidate(in: withinRadius, around: station) else { + logAlert(.gate, "\(station.name) — no Best Value candidate clears \(FuelSavingsFormat.money(bestValueThresholdGBP))") + return + } + guard candidate.station.id == station.id else { + logAlert(.gate, "\(station.name) — not Best Value: \(candidate.station.name) is \(candidate.result.headline())") + return + } + fireBestValueAlert(for: candidate.station, result: candidate.result) + lastNotified[station.id] = Date() + } + + private func bestValueCandidate(in stations: [FuelStation], around station: FuelStation) -> BestValueAlertCandidate? { + let origin = Coordinate(lat: station.lat, lng: station.lng) + let threshold = bestValueThresholdGBP + let extraMargin = 0.50 + let candidate = FuelSavingsCalculator.bestValueAlertCandidate( + stations: stations, + fuel: fuel, + settings: fuelSavingsSettings, + amount: fuelSavingsAmount, + minimumNetSavingGBP: threshold, + journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) } + ) + guard let candidate else { return nil } + if candidate.result.journeyConfidence.isEstimated, + candidate.result.netSavingGBP < threshold + extraMargin { + return nil + } + return candidate + } + private func fireAlert(for station: FuelStation, price: Double, ties: [FuelStation] = []) { let brand = station.brand.isEmpty ? station.name : station.brand let unit = FuelStore.loadDistanceUnit() @@ -499,6 +613,17 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" } + private func fireBestValueAlert(for station: FuelStation, result: FuelSavingsResult) { + let brand = station.brand.isEmpty ? station.name : station.brand + let content = UNMutableNotificationContent() + content.sound = .default + content.title = "Best Value \(fuel.displayName) nearby: \(brand)" + content.body = "\(station.name) could leave you \(result.headline()). Tap for directions." + addAlertRequest(content: content, station: station) + logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())") + lastAlert = "Best Value · \(brand) · \(result.headline())" + } + private func fireFavouriteDropAlert( current: FavouriteEntry, previous: FavouriteAlertSnapshot, @@ -669,6 +794,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca /// the geofence itself. Uses the monitor's live state (fuel, radius, /// stations) so the notification reflects the Alerts-tab configuration. func sendTestNotification() { + switch alertMode { + case .cheapest: + sendCheapestTestNotification() + case .bestValue: + sendBestValueTestNotification() + } + } + + private func sendCheapestTestNotification() { let unit = FuelStore.loadDistanceUnit() let location = FuelStore.loadLocation() let sellers = stations.filter { $0.prices[fuel] != nil } @@ -745,6 +879,70 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca } } + private func sendBestValueTestNotification() { + let unit = FuelStore.loadDistanceUnit() + let location = FuelStore.loadLocation() + let sellers = stations.filter { $0.prices[fuel] != nil } + let inRadius = sellers.filter { station in + guard let location else { return true } + return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM, + userLat: location.lat, userLng: location.lng) + } + let origin = location ?? inRadius.first.map { Coordinate(lat: $0.lat, lng: $0.lng) } + let candidate: BestValueAlertCandidate? = { + guard let origin else { return nil } + return FuelSavingsCalculator.bestValueAlertCandidate( + stations: inRadius, + fuel: fuel, + settings: fuelSavingsSettings, + amount: fuelSavingsAmount, + minimumNetSavingGBP: bestValueThresholdGBP, + journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) } + ) + }() + guard fuelSavingsSettings.isConfigured else { + sendNoCandidateTestNotification( + title: "FuelBoard Best Value test", + body: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", + result: "Best Value not configured" + ) + return + } + guard let candidate else { + let body = sellers.isEmpty + ? "No station data loaded yet — open the Stations tab first, then retry." + : "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))." + sendNoCandidateTestNotification( + title: "FuelBoard Best Value test", + body: body, + result: sellers.isEmpty ? "No stations loaded — open the Stations tab first" : "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))" + ) + return + } + let content = UNMutableNotificationContent() + content.sound = .default + let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand + content.title = "Best Value \(fuel.displayName) nearby: \(brand)" + content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions." + let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))" + lastTestResult = baseResult + logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())") + addAlertRequest(content: content, station: candidate.station) { status in + self.lastTestResult = "\(baseResult) · \(status)" + } + } + + private func sendNoCandidateTestNotification(title: String, body: String, result: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + logAlert(.fired, "test alert scheduled — \(result)") + lastTestResult = result + } + /// Fires a notification with NO criteria checks at all — no geofence, no /// cheapest-within-radius, no dedup, no permission gate, no fuel/radius /// coupling. Still carries a REAL station (the nearest one selling the diff --git a/FuelBoard/RoadDistanceService.swift b/FuelBoard/RoadDistanceService.swift index 84ec587..abf9dff 100644 --- a/FuelBoard/RoadDistanceService.swift +++ b/FuelBoard/RoadDistanceService.swift @@ -17,6 +17,43 @@ import MapKit import UIKit import WidgetKit +struct RoadDistanceFillDebugStatus: Codable, Equatable { + var state: State = .idle + var reason: String = "Waiting" + var updatedAt: Date = Date() + var originLat: Double? + var originLng: Double? + var stationPoolCount: Int = 0 + var priorityCount: Int = 0 + var candidateCount: Int = 0 + var routedCount: Int = 0 + var cachedCount: Int = 0 + var failedCount: Int = 0 + var complete: Bool = false + var capped: Bool = false + var elapsedSeconds: Double? + var movedMeters: Double? + var throttleSecondsRemaining: Double? + + enum State: String, Codable { + case idle + case gated + case running + case completed + } + + var summary: String { + switch state { + case .idle: return "Idle — \(reason)" + case .gated: return "Gated — \(reason)" + case .running: return "Running — \(reason)" + case .completed: return complete ? "Complete — routed set ready" : "Incomplete — still Approx." + } + } + + static let idle = RoadDistanceFillDebugStatus() +} + enum RoadDistanceService { /// Upper bound on stations routed per pass. Accurate-distances mode routes the /// whole search area in ONE pass, because a 12-per-pass budget could never @@ -37,11 +74,47 @@ enum RoadDistanceService { /// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so /// every station a widget/Live Activity/list can actually show gets routed. static let maxRadiusKM: Double = 25 - /// Don't route again more often than this (minutes). + /// Don't route again more often than this (minutes) once the set is whole. static let throttleMinutes: Double = 10 + /// Retry window for a set that is still INCOMPLETE — a pass cut short by + /// backgrounding the app must be continued promptly, not in ten minutes. + static let incompleteRetrySeconds: Double = 30 /// Recompute when the user moves more than this (metres) from the last /// source location. static let moveThresholdMeters: Double = 400 + static private(set) var debugStatus = RoadDistanceFillDebugStatus.idle + + @MainActor + static func currentDebugStatus() -> RoadDistanceFillDebugStatus { + debugStatus + } + + @MainActor + private static func publishDebug(_ status: RoadDistanceFillDebugStatus) { + debugStatus = status + NotificationCenter.default.post(name: .fuelBoardRoadDistanceDebugUpdated, object: nil) + } + + private static func updateDebug(_ status: RoadDistanceFillDebugStatus) async { + await MainActor.run { publishDebug(status) } + } + + private static func baseDebugStatus(state: RoadDistanceFillDebugStatus.State, + reason: String, + stations: [FuelStation], + lat: Double, + lng: Double, + priorityCount: Int) -> RoadDistanceFillDebugStatus { + RoadDistanceFillDebugStatus( + state: state, + reason: reason, + updatedAt: Date(), + originLat: lat, + originLng: lng, + stationPoolCount: stations.count, + priorityCount: priorityCount + ) + } /// Refreshes the cached road distances for the in-radius stations around /// `lat`/`lng`. Throttled by time + distance; safe to call on every fix. @@ -56,15 +129,47 @@ enum RoadDistanceService { // "Accurate distances" OFF (the default) means the browse layer never // serves a routed value, so any routing would be pure cost with nothing // visible to show for it: no pass, no requests, no widget reloads. - guard FuelStore.accurateDistancesEnabled else { return } + guard FuelStore.accurateDistancesEnabled else { + await updateDebug(baseDebugStatus(state: .gated, + reason: "Accurate distances off", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count)) + return + } // Only fill while the app is on screen. The browse layer is the only // consumer, so a background fill would spend radio and battery routing a // position nothing is displaying — worst case, a long drive. - guard await appIsActive() else { return } - guard await refreshGate.begin() else { return } + guard await appIsActive() else { + await updateDebug(baseDebugStatus(state: .gated, + reason: "App not active", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count)) + return + } + guard await refreshGate.begin() else { + await updateDebug(baseDebugStatus(state: .gated, + reason: "Fill already running", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count)) + return + } defer { Task { await refreshGate.end() } } - guard !stations.isEmpty else { return } + guard !stations.isEmpty else { + await updateDebug(baseDebugStatus(state: .gated, + reason: "No stations loaded", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count)) + return + } let existing = FuelStore.loadRoadDistances() // Entries can only be merged when they were routed from (near enough) @@ -83,7 +188,38 @@ enum RoadDistanceService { if !forcePass, let cache = existing { let elapsed = Date().timeIntervalSince1970 - cache.updatedAt let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng) - if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters { + // An INCOMPLETE set still has work to do, so it is not throttled for + // the full ten minutes. The browse layer refuses to serve a partial + // set (every row reads straight-line + "Approx." until the set is + // whole), and a pass is routinely cut short by backgrounding the app + // — so a ten-minute throttle on an incomplete set is what made + // "Approx." look permanent: reopen the app inside the window and the + // pass that would finish the job never even started, and refreshing + // the prices could not help. Retry soon instead. + let incomplete = cache.complete != true + let throttleWindow = incomplete ? incompleteRetrySeconds : throttleMinutes * 60 + if elapsed < throttleWindow && movedMeters < moveThresholdMeters { + var status = baseDebugStatus(state: .gated, + reason: incomplete ? "Throttled — incomplete set, retrying" + : "Throttled", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count) + status.cachedCount = cache.entries.count + status.complete = !incomplete + status.movedMeters = movedMeters + status.throttleSecondsRemaining = throttleWindow - elapsed + if incomplete, + await repairCompletenessIfNothingLeftToRoute(cache, stations: stations, + lat: lat, lng: lng) { + status.complete = true + status.reason = "Throttled — stale incomplete flag repaired" + } + await updateDebug(status) + if status.complete { + await notifyDistanceSurfaces() + } return } } @@ -107,12 +243,44 @@ enum RoadDistanceService { consider(station) } - guard !candidates.isEmpty else { return } - - let passStart = Date() + guard !candidates.isEmpty else { + var status = baseDebugStatus(state: .gated, + reason: "No unrouted candidates", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count) + status.cachedCount = existing?.entries.count ?? 0 + var complete = existing?.complete == true && FuelStore.accurateDistancesReady(userLat: lat, userLng: lng) + if !complete, let existing, + await repairCompletenessIfNothingLeftToRoute(existing, stations: stations, lat: lat, lng: lng) { + complete = true + status.reason = "No unrouted candidates — stale incomplete flag repaired" + } + status.complete = complete + await updateDebug(status) + if status.complete { + await notifyDistanceSurfaces() + } + return + } let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:] + + var running = baseDebugStatus(state: .running, + reason: "Routing \(candidates.count) stations", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count) + running.candidateCount = candidates.count + running.cachedCount = entries.count + running.capped = candidates.count >= fillCapacity + await updateDebug(running) + + let passStart = Date() + var routedCount = 0 for station in candidates { let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) @@ -131,6 +299,18 @@ enum RoadDistanceService { } } guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else { + var status = baseDebugStatus(state: .gated, + reason: "Route pass added no distances", + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count) + status.candidateCount = candidates.count + status.routedCount = routedCount + status.failedCount = candidates.count - routedCount + status.cachedCount = entries.count + status.elapsedSeconds = Date().timeIntervalSince(passStart) + await updateDebug(status) return } @@ -151,6 +331,20 @@ enum RoadDistanceService { } FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries, complete: !unresolved) + var completed = baseDebugStatus(state: .completed, + reason: !unresolved ? "Fill complete" : (capped ? "Capacity cap hit" : "Unresolved stations remain"), + stations: stations, + lat: lat, + lng: lng, + priorityCount: priorityStations.count) + completed.candidateCount = candidates.count + completed.routedCount = routedCount + completed.failedCount = candidates.count - routedCount + completed.cachedCount = entries.count + completed.complete = !unresolved + completed.capped = capped + completed.elapsedSeconds = Date().timeIntervalSince(passStart) + await updateDebug(completed) #if DEBUG // `FB-ROUTE` prints what a pass actually cost (stations, route calls, // wall-clock, unroutable pins, whether the set is now complete) — the @@ -161,12 +355,44 @@ enum RoadDistanceService { + "cached=\(entries.count) failed=\(candidates.count - routedCount) " + "complete=\(!unresolved)") #endif - // Wake the widgets so the new road distances surface immediately, and - // tell the app so its rows re-read the distance they are showing. - WidgetCenter.shared.reloadAllTimelines() - await MainActor.run { - NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil) + await notifyDistanceSurfaces() + } + + /// A cache can hold a route for EVERY station in the search area and still be + /// flagged incomplete — a previous pass hit the capacity cap, or was + /// interrupted between routing and writing. Nothing would ever clear that + /// flag on its own (the next pass finds nothing left to route, and the + /// throttle path returns without writing), and every row would stay on + /// straight-line + "Approx." while its routed value sat in the cache. When + /// nothing in the area is left unrouted, the set IS complete: correct the + /// flag so the routed values can be served. + private static func repairCompletenessIfNothingLeftToRoute(_ cache: RoadDistanceCache, + stations: [FuelStation], + lat: Double, + lng: Double) async -> Bool { + guard cache.complete != true, !stations.isEmpty else { return false } + let nothingLeftToRoute = !stations.contains { station in + station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM + && !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) } + guard nothingLeftToRoute else { return false } + FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng, + entries: cache.entries, complete: true) +#if DEBUG + print("FB-COMPLETE stale incomplete flag repaired cached=\(cache.entries.count) " + + "(every in-radius station already routed — no re-routing needed)") +#endif + return true + } + + @MainActor + private static func notifyDistanceSurfacesOnMain() { + WidgetCenter.shared.reloadAllTimelines() + NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil) + } + + private static func notifyDistanceSurfaces() async { + await MainActor.run { notifyDistanceSurfacesOnMain() } } private static let refreshGate = RoadDistanceRefreshGate() diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index cd002c8..08d4492 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -54,6 +54,7 @@ struct SettingsView: View { var regionEventCount: Int = 0 /// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared. var debugFenceIdentifier: String? + var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = .idle /// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe). var onDebugFence: () -> Void = {} /// Clears the armed fence (monitor.clearDebugFence). @@ -400,6 +401,36 @@ struct SettingsView: View { Text("Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.") } + Section { + LabeledContent("Status", value: roadDistanceDebugStatus.summary) + LabeledContent("Updated", value: roadDistanceDebugStatus.updatedAt.formatted(date: .omitted, time: .standard)) + if let lat = roadDistanceDebugStatus.originLat, + let lng = roadDistanceDebugStatus.originLng { + LabeledContent("Origin", value: String(format: "%.5f, %.5f", lat, lng)) + } + LabeledContent("Pool", value: "\(roadDistanceDebugStatus.stationPoolCount)") + LabeledContent("Candidates", value: "\(roadDistanceDebugStatus.candidateCount)") + LabeledContent("Cached", value: "\(roadDistanceDebugStatus.cachedCount)") + if roadDistanceDebugStatus.routedCount > 0 || roadDistanceDebugStatus.failedCount > 0 { + LabeledContent("Routed", value: "\(roadDistanceDebugStatus.routedCount)") + LabeledContent("Failed", value: "\(roadDistanceDebugStatus.failedCount)") + } + if let elapsed = roadDistanceDebugStatus.elapsedSeconds { + LabeledContent("Elapsed", value: String(format: "%.1fs", elapsed)) + } + if let moved = roadDistanceDebugStatus.movedMeters { + LabeledContent("Moved", value: String(format: "%.0f m", moved)) + } + if let remaining = roadDistanceDebugStatus.throttleSecondsRemaining { + LabeledContent("Throttle", value: String(format: "%.0f s remaining", max(0, remaining))) + } + LabeledContent("Ready", value: roadDistanceDebugStatus.complete ? "yes" : "no") + } header: { + Text("Road distance fill") + } footer: { + Text("Shows whether Accurate distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.") + } + Section { if monitoredCount == 0 { Text("No stations monitored yet — open the Stations tab to load prices first.") diff --git a/FuelBoard/WatchSyncManager.swift b/FuelBoard/WatchSyncManager.swift index 23eceb1..93cc788 100644 --- a/FuelBoard/WatchSyncManager.swift +++ b/FuelBoard/WatchSyncManager.swift @@ -11,6 +11,9 @@ extension Notification.Name { /// Posted when "Accurate distances" is switched, so the browse layer can /// re-render and start a fill from where the user actually is. static let fuelBoardAccurateDistancesChanged = Notification.Name("FuelBoardAccurateDistancesChanged") + /// Posted whenever the road-distance fill starts, finishes, or is gated. + /// Settings → Debug subscribes so the tester can see why Approx. remains. + static let fuelBoardRoadDistanceDebugUpdated = Notification.Name("FuelBoardRoadDistanceDebugUpdated") } @MainActor diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 8a0328e..c0234e1 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -192,6 +192,12 @@ "Price-drop alerts" = "Price-drop alerts"; "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise." = "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise."; "Cheapest station" = "Cheapest station"; +"Alert type" = "Alert type"; +"Best Value" = "Best Value"; +"Minimum saving" = "Minimum saving"; +"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost."; +"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost."; +"When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour." = "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour."; "Proximity alerts" = "Proximity alerts"; "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar." = "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar."; "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." = "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)."; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 4b71781..8242ef2 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -809,6 +809,129 @@ final class RoadDistanceCacheTests: XCTestCase { "a cache built from another origin must not confirm this one") } + // MARK: - Display origin across processes (widget extension fix vs app fix) + + /// The reported bug: the iPhone SE widget kept reading "Approx. 1.5 mi" for + /// the nearest stations while the app showed the SAME figure as a routed road + /// distance. The widget extension gets its own fix from its own + /// CLLocationManager and it is usually coarse — hundreds of metres from the + /// app's GPS fix that the routes were anchored to. That must not be read as + /// "the user moved away from the cache" and collapse every row. + func testCoarseWidgetFixDoesNotDowngradeRoutedDistances() { + FuelStore.setAccurateDistancesEnabled(true) + let appLat = 51.6, appLng = -0.1 + FuelStore.saveLocation(lat: appLat, lng: appLng) + FuelStore.saveRoadDistances(sourceLat: appLat, sourceLng: appLng, + entries: ["a": .init(meters: 2344, lat: 51.5074, lng: -0.1278, + returnMeters: 2400)], + complete: true) + + // The extension's own fix: ~700 m from the app's anchor, reported coarse. + let widgetLat = 51.6063 + let offsetKM = station("anchor", appLat, appLng).distanceKM(to: widgetLat, lng2: appLng) + XCTAssertGreaterThan(offsetKM, FuelStore.roadDistanceOriginToleranceMeters / 1000, + "fixture: the widget fix must sit outside the origin ring") + XCTAssertLessThan(offsetKM, FuelStore.roadDistanceCoarseFixRadiusMeters / 1000, + "fixture: and inside the coarse-fix radius") + + let origin = FuelStore.distanceDisplayOrigin(candidateLat: widgetLat, candidateLng: appLng, + candidateAccuracyMeters: 500) + XCTAssertTrue(origin.routed, "a coarse widget fix must not invalidate the routed set") + XCTAssertEqual(origin.source, "appFix") + XCTAssertEqual(origin.lat, appLat, accuracy: 0.000001) + XCTAssertEqual(origin.lng, appLng, accuracy: 0.000001) + + let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278), + userLat: origin.lat, userLng: origin.lng) + XCTAssertFalse(shown.isApproximate, "the nearest station must not read Approx. on the widget") + XCTAssertEqual(shown.km, 2.344, accuracy: 0.0001) + XCTAssertEqual(shown.label(unit: .miles), "1.5 mi") + } + + /// Sub-kilometre disagreement is fix noise whatever the reported accuracy: + /// two honest fixes of the same place can differ that much, and it is far + /// too small to justify re-labelling a whole list. + func testSubKilometreDisagreementKeepsRoutedDistances() { + FuelStore.setAccurateDistancesEnabled(true) + FuelStore.saveLocation(lat: 51.6, lng: -0.1) + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)], + complete: true) + + let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1, + candidateAccuracyMeters: 10) + XCTAssertTrue(origin.routed) + XCTAssertEqual(origin.source, "appFix") + } + + /// A PRECISE fix far from the anchor IS the user having moved. The honest + /// answer stays the straight line, marked Approx., until the app re-routes + /// from the new position. + func testPreciseFixAwayFromTheAnchorStillFallsBackToApproximate() { + FuelStore.setAccurateDistancesEnabled(true) + FuelStore.saveLocation(lat: 51.6, lng: -0.1) + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)], + complete: true) + + let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.78, candidateLng: -0.1, + candidateAccuracyMeters: 10) + XCTAssertFalse(origin.routed) + XCTAssertEqual(origin.source, "straightLine") + XCTAssertEqual(origin.lat, 51.78, accuracy: 0.000001) + + let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278), + userLat: origin.lat, userLng: origin.lng) + XCTAssertTrue(shown.isApproximate) + } + + /// The rescue only applies where the stored app fix actually backs the cache: + /// an app fix somewhere else cannot confirm routes for this neighbourhood. + func testCoarseFixIsNotRescuedWhenTheStoredAppFixIsElsewhere() { + FuelStore.setAccurateDistancesEnabled(true) + FuelStore.saveLocation(lat: 53.4808, lng: -2.2426) // app fix: Manchester + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)], + complete: true) + + let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1, + candidateAccuracyMeters: 500) + XCTAssertFalse(origin.routed) + XCTAssertEqual(origin.source, "straightLine") + } + + /// Neither an incomplete fill nor the mode being off may resolve a routed + /// origin from any fix — same all-or-nothing rule as the rows. + func testRoutedOriginRequiresACompleteCacheAndTheModeOn() { + FuelStore.setAccurateDistancesEnabled(true) + FuelStore.saveLocation(lat: 51.6, lng: -0.1) + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)], + complete: false) + XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1, + candidateAccuracyMeters: 10).routed, + "an incomplete fill must never serve routed distances") + + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)], + complete: true) + FuelStore.setAccurateDistancesEnabled(false) + XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1, + candidateAccuracyMeters: 10).routed, + "with the mode off the browse layer never serves routes") + } + + /// The widget must record its own fix under its own key: overwriting the + /// app's GPS fix with a Wi-Fi-grade one left the app seeded with the wrong + /// position and broke the anchor comparison above. + func testWidgetFixIsStoredSeparatelyFromTheAppFix() { + FuelStore.saveLocation(lat: 51.6, lng: -0.1) + FuelStore.saveWidgetLocation(lat: 51.6063, lng: -0.1) + XCTAssertEqual(FuelStore.loadLocation()?.lat ?? 0, 51.6, accuracy: 0.000001, + "the app's GPS fix must survive a widget timeline") + XCTAssertEqual(FuelStore.loadWidgetLocation()?.coordinate.lat ?? 0, 51.6063, accuracy: 0.000001) + } + // MARK: - Accurate distances toggle (browse layer only) /// The toggle must be opt-in: unset means off, so a fresh install makes no diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift index 3f8528e..9356e34 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift @@ -81,6 +81,65 @@ final class FuelSavingsCalculatorTests: XCTestCase { let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140) XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"]) } + + func testBestValueAlertCandidateRequiresConfiguredSavingsAndThreshold() { + let stations = [ + station("near", lat: 0, lng: 0, price: 160), + station("cheap", lat: 0, lng: 0, price: 150), + ] + let journeys = ["near": 2.0, "cheap": 4.0] + let off = FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)) + XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate( + stations: stations, + fuel: .e10, + settings: off, + amount: off.preferredAmount, + minimumNetSavingGBP: 1.0, + journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } } + )) + + let on = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)) + XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate( + stations: stations, + fuel: .e10, + settings: on, + amount: on.preferredAmount, + minimumNetSavingGBP: 5.0, + journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } } + )) + } + + func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws { + let stations = [ + station("baseline", lat: 0, lng: 0, price: 160), + station("small", lat: 0, lng: 0, price: 154), + station("best", lat: 0, lng: 0, price: 148), + ] + let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0] + let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)) + let candidate = FuelSavingsCalculator.bestValueAlertCandidate( + stations: stations, + fuel: .e10, + settings: settings, + amount: settings.preferredAmount, + minimumNetSavingGBP: 1.0, + journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } } + ) + XCTAssertEqual(candidate?.station.id, "best") + XCTAssertEqual(candidate?.result.status, .worthTheDrive) + XCTAssertGreaterThan(candidate?.result.netSavingGBP ?? 0, 1.0) + } + + func testBestValueAlertCandidateBreaksTiesByDistanceThenPrice() throws { + let first = FuelSavingsResult(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive) + let nearer = FuelSavingsRankedStation(stationID: "nearer", result: .available(first), distanceMiles: 4, pricePencePerLitre: 149) + let farther = FuelSavingsRankedStation(stationID: "farther", result: .available(first), distanceMiles: 8, pricePencePerLitre: 145) + XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer") + } + + private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation { + FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil) + } } final class FuelSavingsSettingsTests: XCTestCase { diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 0743fdc..7994574 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -116,21 +116,23 @@ struct FuelPriceTimelineProvider FuelPriceEntry { - let entry = await makeEntryCore(configuration: configuration) - writeDiagBeacon(entry: entry) + let (entry, originSource) = await makeEntryCore(configuration: configuration) + writeDiagBeacon(entry: entry, originSource: originSource) return entry } /// Fire-and-forget diagnostics beacon: writes the entry state to keychain /// (app-readable, one slot per intent type) and records what a widget /// timeline produced for Settings → Widget Diagnostics. Deliberately - /// outside the timeline result — can never affect rendering. - private func writeDiagBeacon(entry: FuelPriceEntry) { + /// outside the timeline result — can never affect rendering. No user + /// coordinates are written, only which fix decided the distances. + private func writeDiagBeacon(entry: FuelPriceEntry, originSource: String) { let d = entry.data let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag let first = d.stations.first let json = """ {"intent":"\(intentType)","source":"\(d.locationSource)",\ + "origin":"\(originSource)",\ "n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\ "sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\ "station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)} @@ -140,31 +142,58 @@ struct FuelPriceTimelineProvider FuelPriceEntry { + private func makeEntryCore(configuration: Configuration) async -> (FuelPriceEntry, String) { // Per-widget config: fuel + sort + distance come from THIS widget instance. let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10 let isFavourites = configuration.sort == .favourites let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest let radiusKM = Double(configuration.distance.miles) * 1.60934 - // 1) Try a fresh location fix (bounded to a few seconds). - var location = await WidgetLocationFetcher.shared.currentLocation().map { + // 1) Try a fresh location fix (bounded to a few seconds). The fix's own + // accuracy is kept: the extension runs its own CLLocationManager and is + // routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier + // in the day), which must not be read as "the user moved away from the + // route cache". + let fix = await WidgetLocationFetcher.shared.currentLocation() + var location = fix.map { Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude) } + let fixAccuracy = fix?.horizontalAccuracy var source = "live" - // 2) Fall back to the app's cached fix. + // 2) Fall back to the app's cached fix, then to our own last fix. if location == nil, let cached = FuelStore.loadLocation() { location = cached source = "cached" } + if location == nil, let own = FuelStore.loadWidgetLocation() { + location = own.coordinate + source = "widgetCached" + } if location == nil { source = "none" } - // Persist a live fix so the app shows fresh coords on next launch. + // Persist OUR fix under its own key. It must never be written over the + // app's GPS fix: that stored coordinate is what the app seeds itself + // with, and it is the fix the route cache is anchored to. if let location { - FuelStore.saveLocation(lat: location.lat, lng: location.lng) + FuelStore.saveWidgetLocation(lat: location.lat, lng: location.lng) + } + + // The origin every distance on this face is measured from. Routed values + // are anchored to the app's fix, so when our own fix is coarse and lands + // away from that anchor this labels from the app's fix instead of + // collapsing every row to straight-line + "Approx." — see + // FuelStore.distanceDisplayOrigin. + var origin: Coordinate? + var originSource = "none" + if let location { + let resolved = FuelStore.distanceDisplayOrigin(candidateLat: location.lat, + candidateLng: location.lng, + candidateAccuracyMeters: fixAccuracy) + origin = Coordinate(lat: resolved.lat, lng: resolved.lng) + originSource = resolved.source } let unit = FuelStore.loadDistanceUnit() @@ -190,8 +219,8 @@ struct FuelPriceTimelineProvider FuelSavingsJourney? + ) -> BestValueAlertCandidate? { + guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil } + let sellers = stations.filter { $0.prices[fuel] != nil } + guard !sellers.isEmpty else { return nil } + let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles) + var best: BestValueAlertCandidate? + var bestDistance = Double.greatestFiniteMagnitude + var bestPrice = Double.infinity + var baselineJourneyCache: FuelSavingsJourney? + var baselineJourneyLoaded = false + for station in sellers { + let journey = journeyMiles(station) + let baselineJourney: FuelSavingsJourney? = { + guard let baseline else { return nil } + if baseline.id == station.id { return journey } + if !baselineJourneyLoaded { + baselineJourneyCache = journeyMiles(baseline) + baselineJourneyLoaded = true + } + return baselineJourneyCache + }() + guard case .available(let result) = FuelSavingsEstimator.availability( + station: station, + fuel: fuel, + baseline: baseline, + settings: settings, + amount: amount, + journey: journey, + baselineJourney: baselineJourney + ), result.status == .worthTheDrive, + result.netSavingGBP >= minimumNetSavingGBP else { continue } + let distance = journey?.miles ?? .greatestFiniteMagnitude + let price = station.prices[fuel] ?? .infinity + guard let current = best else { + best = BestValueAlertCandidate(station: station, result: result) + bestDistance = distance + bestPrice = price + continue + } + if result.netSavingGBP > current.result.netSavingGBP || + (result.netSavingGBP == current.result.netSavingGBP && distance < bestDistance) || + (result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price < bestPrice) || + (result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price == bestPrice && station.id < current.station.id) { + best = BestValueAlertCandidate(station: station, result: result) + bestDistance = distance + bestPrice = price + } + } + return best + } } /// Turns stations into savings estimates. diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 1901b5d..09600dc 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -62,6 +62,20 @@ enum SortMode: String, Codable, CaseIterable, Identifiable { } } +enum AlertMode: String, Codable, CaseIterable, Identifiable { + case cheapest + case bestValue + + var id: String { rawValue } + + var displayName: String { + switch self { + case .cheapest: return "Cheapest station" + case .bestValue: return "Best Value" + } + } +} + /// RAG value rating for a station's price against the cheapest available. /// Thumb rules: within 1.5p = green (great value), within 4p = amber (okay), /// beyond that = red (pricey). Deliberately coarse so it reads at a glance. @@ -421,6 +435,8 @@ struct FuelStore { static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value + static let alertsModeKey = "fuelboard.alertsMode" // AlertMode raw value + static let alertsBestValueThresholdKey = "fuelboard.alertsBestValueThresholdGBP" // Double £ static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value @@ -585,6 +601,29 @@ struct FuelStore { saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey) } + /// A fix the WIDGET EXTENSION obtained on its own, kept apart from + /// `locationKey`. + /// + /// The extension's fix comes from its own `requestLocation()` and is often + /// coarse; writing it over `locationKey` replaced the app's GPS fix with a + /// Wi-Fi-grade one that could sit hundreds of metres away. That stored fix is + /// what the app seeds itself with on launch and what + /// `distanceDisplayOrigin` trusts when a coarse fix disagrees with the route + /// cache — so a widget fix must never be mistaken for the app's position. + static let widgetLocationKey = "fuelboard.widgetLocation" + + static func saveWidgetLocation(lat: Double, lng: Double, date: Date = Date()) { + saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: widgetLocationKey) + } + + static func loadWidgetLocation() -> (coordinate: Coordinate, date: Date)? { + let raw = loadString(service: widgetLocationKey) + guard let parts = raw?.split(separator: ",").compactMap({ Double($0) }), + parts.count == 3 else { return nil } + return (Coordinate(lat: parts[0], lng: parts[1]), + Date(timeIntervalSince1970: parts[2])) + } + // MARK: Selected fuel static func loadSelectedFuel() -> FuelType { @@ -613,6 +652,34 @@ struct FuelStore { saveString(fuel.rawValue, service: alertsFuelKey) } + static func loadAlertsMode() -> AlertMode { + if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) { + return mode + } + return .cheapest + } + + static func saveAlertsMode(_ mode: AlertMode) { + saveString(mode.rawValue, service: alertsModeKey) + } + + static let bestValueAlertThresholdOptionsGBP: [Double] = [0.5, 1.0, 2.0, 5.0] + + static func loadAlertsBestValueThresholdGBP() -> Double { + if let raw = loadString(service: alertsBestValueThresholdKey), + let value = Double(raw), + bestValueAlertThresholdOptionsGBP.contains(value) { + return value + } + return 1.0 + } + + static func saveAlertsBestValueThresholdGBP(_ threshold: Double) { + let snapped = bestValueAlertThresholdOptionsGBP + .min(by: { abs($0 - threshold) < abs($1 - threshold) }) ?? 1.0 + saveString(String(snapped), service: alertsBestValueThresholdKey) + } + // MARK: Sort mode static func loadSortMode() -> SortMode { @@ -1343,6 +1410,78 @@ struct FuelStore { <= roadDistanceOriginToleranceMeters } + /// How imprecise a fix must be before its distance from the route cache's + /// anchor stops being read as "the user moved". The widget extension runs its + /// own `requestLocation()` on a `kCLLocationAccuracyHundredMeters` manager and + /// is routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier + /// in the day) — hundreds of metres from the app's GPS anchor. + static let roadDistanceCoarseFixAccuracyMeters: Double = 150 + + /// How far a coarse fix may sit from the anchor before it is taken as genuine + /// travel rather than fix noise. + static let roadDistanceCoarseFixRadiusMeters: Double = 3_000 + + /// Where a surface should read displayed distances FROM, and whether routed + /// distances are usable there. + /// + /// Routed distances are anchored to the fix that produced them (the app's GPS + /// fix). A surface in another process — the widget extension — gets its OWN + /// fix, which can sit hundreds of metres from that anchor. Reading that as + /// "the user moved away from the cache" downgraded EVERY row in the widget to + /// straight-line + "Approx." while the app, whose fix IS the anchor, showed + /// routed values: one station read differently on two faces, and refreshing + /// never fixed it because each new widget fix landed outside the ring again. + /// Resolve the origin once, here, so every surface labels from the same place. + struct DistanceDisplayOrigin: Equatable { + let lat: Double + let lng: Double + /// True when the caller may serve routed distances from this origin. + let routed: Bool + /// Which fix decided it — diagnostics only. + let source: String + } + + static func distanceDisplayOrigin(candidateLat: Double, candidateLng: Double, + candidateAccuracyMeters: Double? = nil) -> DistanceDisplayOrigin { + let straightLine = DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng, + routed: false, source: "straightLine") + guard accurateDistancesEnabled, + let cache = loadRoadDistances(), cache.complete == true else { return straightLine } + + let candidateOffset = haversineMetres(cache.sourceLat, cache.sourceLng, candidateLat, candidateLng) + if candidateOffset <= roadDistanceOriginToleranceMeters { + return DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng, + routed: true, source: "candidate") + } + + // The candidate fix is outside the ring. A precise fix there means the + // user really has moved, and the honest answer is the straight line from + // where they are. A COARSE fix (or one that reported no accuracy) is fix + // noise, as is any disagreement under a kilometre — neither is good + // evidence that the routes are stale, so fall back to the app's own fix, + // which is the one the cached routes were built around. + let accuracy = candidateAccuracyMeters ?? -1 + let fixIsCoarse = accuracy <= 0 || accuracy > roadDistanceCoarseFixAccuracyMeters + guard (fixIsCoarse || candidateOffset <= 1_000), + candidateOffset <= roadDistanceCoarseFixRadiusMeters, + let appFix = loadLocationWithDate(), + haversineMetres(cache.sourceLat, cache.sourceLng, + appFix.coordinate.lat, appFix.coordinate.lng) <= roadDistanceOriginToleranceMeters, + // The stored fix must belong to this cache, not predate it by hours. + appFix.date.timeIntervalSince1970 >= cache.updatedAt - 30 * 60 else { return straightLine } + + return DistanceDisplayOrigin(lat: appFix.coordinate.lat, lng: appFix.coordinate.lng, + routed: true, source: "appFix") + } + + /// Metres between two coordinates — the same great-circle measure the + /// origin/anchor tests use, exposed so a surface can report how far its fix + /// sits from the anchor (diagnostics only). + static func distanceMetersBetween(lat1: Double, lng1: Double, + lat2: Double, lng2: Double) -> Double { + haversineMetres(lat1, lng1, lat2, lng2) + } + private static func haversineMetres(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double { let r = 6371000.0 let dLat = (lat2 - lat1) * .pi / 180 diff --git a/Shared/WidgetLocationFetcher.swift b/Shared/WidgetLocationFetcher.swift index dec8cd1..359837e 100644 --- a/Shared/WidgetLocationFetcher.swift +++ b/Shared/WidgetLocationFetcher.swift @@ -27,6 +27,11 @@ final class WidgetLocationFetcher: NSObject, CLLocationManagerDelegate { /// Requests the current location. Returns nil on denial, timeout, or /// no-permission — callers should fall back to cached data. + /// + /// The `CLLocation` is returned intact so callers can read + /// `horizontalAccuracy`: the extension is often handed a coarse Wi-Fi/cell + /// fix, and a coarse fix must not be mistaken for the user having moved away + /// from the app's routed distances (see `FuelStore.distanceDisplayOrigin`). func currentLocation(timeout: TimeInterval = 4) async -> CLLocation? { guard CLLocationManager.locationServicesEnabled() else { return nil }