The widget/Live Activity showed straight-line haversine distance (0.8 mi) while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget execution + ~40-70/day refresh budget, so the APP now computes it: - New RoadDistanceService (app target): for the nearest 12 stations each pass, calls MapKit MKDirections (free, no API key, matches Apple Maps) and caches metres keyed by station ID. - Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it even on free SideStore accounts with no app-group container; only valid within 600 m of the location it was built from. - Throttled: recompute max every 10 min, or when the user moves > 400 m; wired into the location-update hook + location onChange. - Widget face, app station rows + Live Activity show the cached road distance, falling back to straight-line when absent. - 4 new cache tests (105 total).
88 lines
4.0 KiB
Swift
88 lines
4.0 KiB
Swift
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
|
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
|
// show real driving distance instead of straight-line haversine.
|
|
//
|
|
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
|
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
|
// here, not in the widget. The widget/Live Activity just read the cache.
|
|
//
|
|
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
|
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
|
|
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
|
|
|
|
import Foundation
|
|
import MapKit
|
|
import WidgetKit
|
|
|
|
enum RoadDistanceService {
|
|
/// How many nearest stations to route per pass (bounds MKDirections calls).
|
|
static let candidatesPerPass = 12
|
|
/// Don't route again more often than this (minutes).
|
|
static let throttleMinutes: Double = 10
|
|
/// Recompute when the user moves more than this (metres) from the last
|
|
/// source location.
|
|
static let moveThresholdMeters: Double = 400
|
|
|
|
/// Refreshes the cached road distances for the nearest `candidatesPerPass`
|
|
/// stations around `lat`/`lng`. Throttled by time + distance; safe to call
|
|
/// on every location fix.
|
|
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
|
guard !stations.isEmpty else { return }
|
|
|
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
|
if let cache = FuelStore.loadRoadDistances() {
|
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
|
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Candidate stations: nearest by straight-line (these are what widgets
|
|
// and the Live Activity show in-radius).
|
|
let nearest = stations
|
|
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
|
.prefix(candidatesPerPass)
|
|
|
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
|
var entries: [String: Double] = [:]
|
|
for station in nearest {
|
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
|
if let meters = await roadMeters(from: origin, to: dest) {
|
|
entries[station.id] = meters
|
|
}
|
|
}
|
|
guard !entries.isEmpty else { return }
|
|
|
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
|
// Wake the widgets so the new road distances surface immediately.
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
|
let request = MKDirections.Request()
|
|
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
|
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
|
request.transportType = .automobile
|
|
request.requestsAlternateRoutes = false
|
|
do {
|
|
let response = try await MKDirections(request: request).calculate()
|
|
return response.routes.first?.distance
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Straight-line haversine distance between two coordinates, in metres.
|
|
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
|
let r = 6371000.0
|
|
let dLat = (lat2 - lat1) * .pi / 180
|
|
let dLng = (lng2 - lng1) * .pi / 180
|
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
|
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
|
sin(dLng / 2) * sin(dLng / 2)
|
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
}
|
|
}
|