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:
+217
-34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user