Stop Approx. distances beside routed values; add Best Value alert mode

Distances (the "Approx. 1.5 mi" report):
- Refuse junk CoreLocation fixes (invalid accuracy, or a coarse >300 m fix
  within 3 km of the position already held; a coarse fix in another city is
  real travel and is still accepted).
- Resolve ONE display basis per render through FuelStore.distanceDisplayOrigin,
  used by rows, radius filter, sorting, savings figures and the Live Activity,
  so a stray fix can no longer re-base the list outside the route cache's 600 m
  ring while the routed values sit unused in the cache.
- Treat an incomplete route set as a state: retry it after 30 s rather than the
  full 10-minute throttle, and repair a stale incomplete flag once every
  in-radius station has a route (nothing left to route, flag stuck down).
- Widget extension resolves the same origin for fetch, radius, ordering and
  labels, and keeps its own fix in fuelboard.widgetLocation instead of
  overwriting the app's GPS fix.

Alerts:
- Best Value as a distinct alert mode on the existing geofence/notification
  pipeline, with a persisted threshold; reverts to cheapest when the feature is
  unconfigured and is not selectable while disabled.

Diagnostics (DEBUG, gated): FB_DIST_FOCUS now prints FB-DIST-ORIGIN (candidate,
accuracy, anchor, offset, resolved branch, served origin), FB_FAKE_FIX feeds a
synthetic fix through the real guard, FB_STALE_INCOMPLETE forces the stale-flag
state, and the route-fill status is surfaced in Settings behind debug mode.
This commit is contained in:
FuelBoard Contributor
2026-10-13 22:04:00 +01:00
parent 2a2823c180
commit 6f177837a5
13 changed files with 1194 additions and 99 deletions
+217 -34
View File
@@ -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 <substring>` 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