Author SHA1 Message Date
FuelBoard Contributor 10e3b54d0c onboarding: persist completed flag keychain-first so it doesn't re-show
The completed flag lived only in app-group UserDefaults
(UserDefaults(suiteName: appGroupSuite)). On free SideStore accounts the
app-group container isn't provisioned, so the suite read as nil: save was a
silent no-op and load always returned false -> onboarding re-appeared on
every launch even after finishing and granting all permissions.

Switch to saveString/loadString (keychain-first, app-group mirror) under
onboardingCompletedKey, matching favourites/distance-unit persistence which
survives reinstall and works without a provisioning group. loadString falls
back to the app-group mirror, so anyone who previously completed on a
provisioned group keeps their flag; users on unprovisioned groups complete
once more and then it sticks.
2026-08-20 12:27:09 +01:00
6 changed files with 12 additions and 233 deletions
+1 -17
View File
@@ -50,21 +50,6 @@ 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
)
}
}
@@ -392,7 +377,6 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
}
)
@@ -965,7 +949,7 @@ struct StationRow: View {
.lineLimit(1)
.truncationMode(.tail)
if let location {
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
+1 -1
View File
@@ -57,7 +57,7 @@ enum LiveActivityManager {
brand: best.brand,
pricePence: price,
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
lat: best.lat,
lng: best.lng,
updatedAt: Date()
-93
View File
@@ -1,93 +0,0 @@
// 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. Raised from 12 so stations past the old nearest-12 cutoff
/// still get real road distances instead of a straight-line fallback.
static let candidatesPerPass = 40
/// 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.
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: the nearest-by-straight-line subset that the UI
// could actually display, capped so a pass stays bounded.
let nearest = stations
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
.prefix(candidatesPerPass)
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
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))
}
}
@@ -620,54 +620,3 @@ final class OfflineDataLabelTests: XCTestCase {
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
}
}
// MARK: - Road distance cache
final class RoadDistanceCacheTests: XCTestCase {
override func setUp() {
super.setUp()
// Keychain persists across invocations, so a cache left by an earlier
// test or run would pollute these. Overwrite with an empty, far-away
// cache (source at (0,0)) so every test starts from a clean slate.
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
}
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)
}
}
+2 -2
View File
@@ -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(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: 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(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
+8 -69
View File
@@ -934,71 +934,19 @@ struct FuelStore {
}
// MARK: Onboarding — the app shows the intro screen on first launch only
// (a test button in the Alerts tab re-opens it). Stored in the app group
// so the widget can see it too if ever needed.
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
// (with an app-group mirror) for the same reason as favourites/distance
// unit: free SideStore accounts don't provision the app-group container,
// so an app-group-only flag silently fails to save AND reloads as false,
// making onboarding re-appear on every launch. Keychain survives reinstall
// and is shared with the extension.
static func loadHasCompletedOnboarding() -> Bool {
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
}
static func saveHasCompletedOnboarding(_ completed: Bool) {
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)
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
}
// MARK: Low-level keychain helpers
@@ -1060,12 +1008,3 @@ 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]
}