From 64ef260959798133cfcf5597635b47496b5cd15a Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 11:33:47 +0100 Subject: [PATCH] widget + live activity: prefer Apple-Maps road distance, computed by the app 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). --- FuelBoard/ContentView.swift | 18 +++- FuelBoard/LiveActivityManager.swift | 2 +- FuelBoard/RoadDistanceService.swift | 87 +++++++++++++++++++ .../FuelBoardSharedTests/FuelBoardTests.swift | 43 +++++++++ Shared/FuelPriceWidgetViews.swift | 4 +- Shared/FuelStore.swift | 65 ++++++++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 FuelBoard/RoadDistanceService.swift diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index a353e15..cedabd0 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -50,6 +50,21 @@ struct ContentView: View { // fires on every fix incl. background significant-change // wake-ups, so the Lock Screen pill stays live while driving. updateLiveActivity() + refreshRoadDistancesIfNeeded() + } + } + + /// Kicks off a (throttled) Apple-Maps road-distance recompute for the + /// stations around the current fix. The app owns routing — the widget and + /// Live Activity only read the cached result. + private func refreshRoadDistancesIfNeeded() { + guard let location else { return } + Task { + await RoadDistanceService.refreshIfNeeded( + stations: stations, + lat: location.lat, + lng: location.lng + ) } } @@ -377,6 +392,7 @@ struct ContentView: View { monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) updateLiveActivity() + refreshRoadDistancesIfNeeded() } } ) @@ -949,7 +965,7 @@ struct StationRow: View { .lineLimit(1) .truncationMode(.tail) if let location { - Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng))) + Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng))) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() diff --git a/FuelBoard/LiveActivityManager.swift b/FuelBoard/LiveActivityManager.swift index 936d054..cd0297a 100644 --- a/FuelBoard/LiveActivityManager.swift +++ b/FuelBoard/LiveActivityManager.swift @@ -57,7 +57,7 @@ enum LiveActivityManager { brand: best.brand, pricePence: price, priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(), - distanceKM: best.distanceKM(to: location.lat, lng2: location.lng), + distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng), lat: best.lat, lng: best.lng, updatedAt: Date() diff --git a/FuelBoard/RoadDistanceService.swift b/FuelBoard/RoadDistanceService.swift new file mode 100644 index 0000000..812ce35 --- /dev/null +++ b/FuelBoard/RoadDistanceService.swift @@ -0,0 +1,87 @@ +// 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)) + } +} diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 62fd5ab..34123de 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -620,3 +620,46 @@ final class OfflineDataLabelTests: XCTestCase { XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date")) } } + +// MARK: - Road distance cache + +final class RoadDistanceCacheTests: XCTestCase { + private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation { + FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", + lat: lat, lng: lng, prices: [:], priceUpdated: nil) + } + + func testDisplayDistanceFallsBackToStraightLineWhenNoCache() { + // London user, station ~ London -> no cache -> straight-line haversine. + let s = station("a", 51.5074, -0.1278) + let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) + } + + func testRoadDistanceUsedWhenCachedNear() { + let s = station("a", 51.5074, -0.1278) + // Cache a road distance of 3.2 km for this station from the user's fix. + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200]) + let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, 3.2, accuracy: 0.0001) + } + + func testRoadDistanceNilWhenOriginFar() { + let s = station("a", 51.5074, -0.1278) + // Cache built in London, but the user is now ~200 km away -> stale. + FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, entries: ["a": 3200]) + let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426) + XCTAssertNil(meters) + // And display falls back to straight-line. + let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426) + XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001) + } + + func testRoadDistanceUsedForOtherStationNotFound() { + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200]) + // A station that isn't in the cache falls back to straight-line. + let s = station("z", 51.51, -0.13) + let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) + } +} diff --git a/Shared/FuelPriceWidgetViews.swift b/Shared/FuelPriceWidgetViews.swift index 31e8a0d..cb898b9 100644 --- a/Shared/FuelPriceWidgetViews.swift +++ b/Shared/FuelPriceWidgetViews.swift @@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View { FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green) } if let location = entry.location { - Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away") + Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away") .font(.caption2) .foregroundStyle(.secondary) } else { @@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View { .font(.caption.weight(.semibold)) .lineLimit(1) if let location = entry.location { - Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng))) + Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng))) .font(.caption2) .foregroundStyle(.secondary) } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 6b51ed5..dc05a2b 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -945,6 +945,62 @@ struct FuelStore { UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey) } + // MARK: Road distances (Apple-Maps-matched, computed by the app) + + /// Cached road/routed distances (metres) keyed by station ID, computed by + /// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free + /// SideStore accounts where the app-group container isn't provisioned) so + /// the widget extension can read it too. Widget + Live Activity prefer + /// these over straight-line haversine for the displayed distance. + static let roadDistancesKey = "fuelboard.roadDistances" + + /// How far (metres) the cache's source location may be from the current + /// user position before a cached road distance is treated as stale. + static let roadDistanceOriginToleranceMeters: Double = 600 + + static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) { + let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng, + updatedAt: Date().timeIntervalSince1970, entries: entries) + if let data = try? JSONEncoder().encode(cache) { + saveString(data.base64EncodedString(), service: roadDistancesKey) + } + } + + static func loadRoadDistances() -> RoadDistanceCache? { + guard let raw = loadString(service: roadDistancesKey), + let data = Data(base64Encoded: raw), + let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) + else { return nil } + return cache + } + + /// Cached road distance (metres) to a station from the user's location, or + /// nil when not cached / the cache was built too far from where the user + /// is now. + static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? { + guard let cache = loadRoadDistances(), + let meters = cache.entries[stationID] else { return nil } + // The cache is only valid near the location it was built from. + let dLat = (userLat - cache.sourceLat) * .pi / 180 + let dLng = (userLng - cache.sourceLng) * .pi / 180 + let r = 6371000.0 + let a = sin(dLat / 2) * sin(dLat / 2) + + cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) * + sin(dLng / 2) * sin(dLng / 2) + let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a)) + guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil } + return meters + } + + /// Distance (km) to display for a station: cached ROAD distance when + /// available (matches Apple Maps), else straight-line haversine. + static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double { + if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) { + return meters / 1000.0 + } + return station.distanceKM(to: userLat, lng2: userLng) + } + // MARK: Low-level keychain helpers private static func keychainData(service: String) -> Data? { @@ -1004,3 +1060,12 @@ struct FuelStore { loadString(service: "widget.diag.\(intentType)") } } + +/// Cached Apple-Maps road distances for nearby stations (see +/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres. +struct RoadDistanceCache: Codable { + let sourceLat: Double + let sourceLng: Double + let updatedAt: TimeInterval + let entries: [String: Double] +}