Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.). The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving. Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
165 lines
7.8 KiB
Swift
165 lines
7.8 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 {
|
|
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
|
/// route calls. Worth-the-Drive now asks for outward AND return routes, so
|
|
/// this must stay conservative; a larger value can make first launch feel
|
|
/// unresponsive while MapKit drains dozens of route requests.
|
|
static let candidatesPerPass = 12
|
|
/// How many Worth-the-Drive candidates may claim slots in a pass. Those
|
|
/// rows are ranked by NET SAVING and are typically FURTHER out than the
|
|
/// nearest stations — routing only the nearest stations (the old behaviour)
|
|
/// meant they never got a measured route and stayed on straight-line
|
|
/// estimates forever.
|
|
static let priorityCandidates = 6
|
|
/// Only route stations within this straight-line radius (km). Covers the
|
|
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
|
static let maxRadiusKM: Double = 25
|
|
/// 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 in-radius stations around
|
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
|
///
|
|
/// - Parameter priorityStations: stations to route BEFORE the nearest ones
|
|
/// (the Worth-the-Drive candidates and the comparison baseline), so the
|
|
/// numbers the user actually reads are road-routed rather than estimated.
|
|
static func refreshIfNeeded(stations: [FuelStation],
|
|
lat: Double,
|
|
lng: Double,
|
|
prioritising priorityStations: [FuelStation] = []) async {
|
|
guard await refreshGate.begin() else { return }
|
|
defer { Task { await refreshGate.end() } }
|
|
|
|
guard !stations.isEmpty else { return }
|
|
|
|
let existing = FuelStore.loadRoadDistances()
|
|
// Entries can only be merged when they were routed from (near enough)
|
|
// this same origin — the read path rejects anything else anyway.
|
|
let originCompatible = existing.map {
|
|
haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters
|
|
} ?? false
|
|
|
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
|
// `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook).
|
|
#if DEBUG
|
|
let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1"
|
|
#else
|
|
let forcePass = false
|
|
#endif
|
|
if !forcePass, let cache = existing {
|
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
|
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Candidates: Worth-the-Drive priorities first, then the nearest
|
|
// stations. Stations that already have a route from this origin are
|
|
// skipped so the per-pass budget goes on the ones still missing one.
|
|
var candidates: [FuelStation] = []
|
|
var seen = Set<String>()
|
|
func consider(_ station: FuelStation) {
|
|
guard candidates.count < candidatesPerPass else { return }
|
|
guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return }
|
|
guard seen.insert(station.id).inserted else { return }
|
|
guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return }
|
|
candidates.append(station)
|
|
}
|
|
for station in priorityStations.prefix(priorityCandidates) { consider(station) }
|
|
let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
|
for station in nearest {
|
|
if candidates.count >= candidatesPerPass { break }
|
|
consider(station)
|
|
}
|
|
|
|
guard !candidates.isEmpty else { return }
|
|
|
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
|
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
|
for station in candidates {
|
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
|
async let outward = roadMeters(from: origin, to: dest)
|
|
async let back = roadMeters(from: dest, to: origin)
|
|
if let meters = await outward {
|
|
// Store the exact pin that was routed so the display layer can
|
|
// refuse to serve this value if the station later appears with
|
|
// a different coordinate (corrected pin / other data source).
|
|
let returnMeters = await back
|
|
entries[station.id] = CachedRoadDistance(meters: meters,
|
|
lat: station.lat,
|
|
lng: station.lng,
|
|
returnMeters: returnMeters)
|
|
}
|
|
}
|
|
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
|
|
return
|
|
}
|
|
|
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
|
// Wake the widgets so the new road distances surface immediately.
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
private static let refreshGate = RoadDistanceRefreshGate()
|
|
|
|
/// 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))
|
|
}
|
|
}
|
|
|
|
private actor RoadDistanceRefreshGate {
|
|
private var isRefreshing = false
|
|
|
|
func begin() -> Bool {
|
|
guard !isRefreshing else { return false }
|
|
isRefreshing = true
|
|
return true
|
|
}
|
|
|
|
func end() {
|
|
isRefreshing = false
|
|
}
|
|
}
|