Files
fuelboard/Shared/WidgetLocationFetcher.swift
FuelBoard Contributor 6f177837a5 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.
2026-10-13 22:04:00 +01:00

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
}
}
}