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.
78 lines
2.9 KiB
Swift
78 lines
2.9 KiB
Swift
// WidgetLocationFetcher.swift — one-shot location for the WIDGET EXTENSION.
|
|
//
|
|
// WidgetKit providers run in a short budget, so this fetcher is deliberately
|
|
// timeout-bounded: it requests a single location and gives up after a few
|
|
// seconds, falling back to whatever the caller already has cached.
|
|
//
|
|
// Location permission is shared with the containing app (same TCC identity),
|
|
// so if the user granted "While Using" in FuelBoard, the widget can use
|
|
// location directly — no extra prompt in the car.
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
@MainActor
|
|
final class WidgetLocationFetcher: NSObject, CLLocationManagerDelegate {
|
|
static let shared = WidgetLocationFetcher()
|
|
|
|
private let manager = CLLocationManager()
|
|
private var continuation: CheckedContinuation<CLLocation?, Never>?
|
|
private var timeoutTask: Task<Void, Never>?
|
|
|
|
private override init() {
|
|
super.init()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
}
|
|
|
|
/// 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 }
|
|
|
|
switch manager.authorizationStatus {
|
|
case .authorizedWhenInUse, .authorizedAlways:
|
|
break
|
|
case .notDetermined:
|
|
// Widget extensions share the app's TCC identity; if the app was
|
|
// never opened, the system can't prompt from here — fall back.
|
|
return nil
|
|
case .denied, .restricted:
|
|
return nil
|
|
@unknown default:
|
|
return nil
|
|
}
|
|
|
|
return await withCheckedContinuation { cont in
|
|
continuation = cont
|
|
manager.requestLocation()
|
|
timeoutTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
|
self?.continuation?.resume(returning: nil)
|
|
self?.continuation = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
MainActor.assumeIsolated {
|
|
timeoutTask?.cancel()
|
|
continuation?.resume(returning: locations.last)
|
|
continuation = nil
|
|
}
|
|
}
|
|
|
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
MainActor.assumeIsolated {
|
|
timeoutTask?.cancel()
|
|
continuation?.resume(returning: nil)
|
|
continuation = nil
|
|
}
|
|
}
|
|
}
|