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:
@@ -116,21 +116,23 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
}
|
||||
|
||||
private func makeEntry(configuration: Configuration) async -> 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<Configuration: WidgetConfigurationIntent & Widg
|
||||
// App Store build must not contain a private LAN relay fallback.
|
||||
}
|
||||
|
||||
private func makeEntryCore(configuration: Configuration) async -> 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<Configuration: WidgetConfigurationIntent & Widg
|
||||
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
|
||||
.map(\.station)
|
||||
var refreshed: [FuelStation] = fuelFavourites
|
||||
if let location,
|
||||
let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
||||
if let origin,
|
||||
let fetched = await Self.fetchFocused(near: origin, fuel: fuel, radiusKM: radiusKM) {
|
||||
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||
refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 }
|
||||
}
|
||||
@@ -206,8 +235,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
// previously left the widget stuck on sample data). Falls back to the
|
||||
// shared cache, then to samples.
|
||||
var stations: [FuelStation] = []
|
||||
if let location {
|
||||
if let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
||||
if let origin {
|
||||
if let fetched = await Self.fetchFocused(near: origin, fuel: fuel, radiusKM: radiusKM) {
|
||||
stations = fetched
|
||||
}
|
||||
} else {
|
||||
@@ -223,12 +252,14 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
}
|
||||
if stations.isEmpty { stations = FuelStore.loadStations() }
|
||||
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
||||
if let location {
|
||||
if let origin {
|
||||
// STRICT: cached data fetched around another location must never
|
||||
// leak out-of-radius stations into the widget.
|
||||
// leak out-of-radius stations into the widget. Measured from the
|
||||
// SAME origin the rows are labelled with, so the radius and the
|
||||
// displayed distances can never disagree.
|
||||
stations = stations.filter {
|
||||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||
userLat: location.lat, userLng: location.lng)
|
||||
userLat: origin.lat, userLng: origin.lng)
|
||||
}
|
||||
}
|
||||
let filtered = stations.filter { $0.prices[fuel] != nil }
|
||||
@@ -236,9 +267,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
case .closest:
|
||||
// Nearest first; price only breaks ties.
|
||||
ordered = filtered.sorted { lhs, rhs in
|
||||
if let location {
|
||||
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng)
|
||||
let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
|
||||
if let origin {
|
||||
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
|
||||
let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
|
||||
if lDist != rDist { return lDist < rDist }
|
||||
}
|
||||
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
||||
@@ -250,23 +281,23 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
let lPrice = lhs.prices[fuel]!
|
||||
let rPrice = rhs.prices[fuel]!
|
||||
if lPrice != rPrice { return lPrice < rPrice }
|
||||
if let location {
|
||||
return FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) <
|
||||
FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
|
||||
if let origin {
|
||||
return FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) <
|
||||
FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FuelPriceEntry(
|
||||
return (FuelPriceEntry(
|
||||
data: FuelPriceWidgetEntryData(
|
||||
date: Date(), stations: Array(ordered.prefix(8)),
|
||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||
location: location, locationSource: source,
|
||||
location: origin, locationSource: source,
|
||||
unit: unit
|
||||
)
|
||||
)
|
||||
), originSource)
|
||||
}
|
||||
|
||||
/// One focused fetch around the widget's location. Public HTTPS mirror only;
|
||||
|
||||
Reference in New Issue
Block a user