Author SHA1 Message Date
FuelBoard Contributor 98cf3a5736 Git: ignore build-watch output 2026-08-28 16:35:32 +01:00
FuelBoard Contributor ed41df46de siri: drop generic shortcut and broaden fuel-word phrases
Siri was hit-and-miss on 'unleaded near me' (\u2018can't do that, searching
in app\u2019 fallback). The generic parameterized AppShortcut ('cheapest
${fuel}') competed with the fixed-fuel literal-word shortcuts for the same
intent, degrading NLU ranking; it was already documented as flaky. Remove it
(now 9 shortcuts, under the 10 cap) and add natural phrase variants for
unleaded/petrol ('What's the cheapest unleaded near me', 'Cheapest unleaded
near me', 'Where's the cheapest petrol'). All phrases keep .applicationName
per the iOS 26 metadata requirement. Build-verified in extract.actionsdata:
generic gone, new phrases registered.
2026-08-20 19:27:34 +01:00
FuelBoard Contributor 9342ccc7c9 trends: redraw lines on 7/30/90 selection
Callers previously morphed the existing lines on a reload, so picking a new
range only glided in place. Now every range/fuel selection (the only reloads;
the sheet has no periodic refetch) replays the staggered per-series draw-in
via revealSeries(), so the lines visibly redraw left->right when the user
switches 7/30/90. Price<->vs-cheapest still morphs (pure view toggle, no
reload). Reduce Motion jump-cuts.
2026-08-20 18:49:54 +01:00
FuelBoard Contributor 508ccc3802 trends: staggered per-series line draw-in
The single-waterline trace-in was invisible with sparse data (2 points draw
in one step). Replace it with a per-series waterline: each station's line now
traces left->right on first appearance, series revealed 0.18s apart in a
cascade so the draw is clearly visible. Newly-added favourites get a full
reveal immediately on reload; Reduce Motion still jump-cuts. Range/mode
morphs and numeric headline transition unchanged.
2026-08-20 18:40:09 +01:00
FuelBoard Contributor 69c3c17606 trends: animate the price-history lines (subtle, Reduce-Motion aware)
- Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing
  lines + y-axis to the new data via withAnimation/.animation(value: mode)
  instead of snapping.
- One-time left->right trace-in on first data appearance by revealing an
  increasing prefix of each series' points (area + line stay in sync); later
  switches morph rather than re-trace.
- Headline average rolls its digits with .contentTransition(.numericText).
- All animation honours .accessibilityReduceMotion (jump-cut to final state).
Kept two-pass area/line ordering, explicit series:, bounded fill bands, and
tight chartYScale unchanged.
2026-08-20 17:54:55 +01:00
FuelBoard Contributor 0b27d3d4f6 road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute
The road-distance cache was keyed purely by station ID with no coordinate, so
a value routed to one pin could be served for a same-ID station whose stored
coordinate came from a different source (live fetch vs bundled offline dump
vs a corrected pin), reproducing Maps mismatches.
- RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters,
  lat, lng}) and roadDistanceMeters() only serves a value when the displayed
  station's coordinate matches the pinned one (within ~11 m).
- Kept the existing origin-distance staleness guard.
- Verified embedded sample data (ids y1/se1/...) never collides with real
  relay IDs, and the widget STRICT radius filter drops far-offline samples, so
  no actual leak existed in practice — this closes the theoretical stale-pin
  channel and future-proofs against coordinate fixes.
2026-08-20 17:26:33 +01:00
FuelBoard Contributor 01517ba687 onboarding: replay on a fresh/reinstalled install, not every launch
The keychain-first completed flag persists across reinstalls (same bundle id),
so a new install was treated as already-completed and onboarding silently
skipped while permissions still prompted. Add install identity:
- Store an install id in BOTH the app-own container defaults (wiped on
  reinstall, persists across launches) and keychain (survives reinstall).
- isFreshInstall() is true on first-ever launch or when the local id is
  missing/different from keychain (i.e. reinstalled); it seeds a fresh common
  id so the next launch of the same install is not 'fresh' again.
- shouldShowOnboarding() = not completed || fresh install -> onboarding now
  replays once per fresh/reinstall, never on ordinary launches.
Works on free SideStore (no app-group), using the app's own defaults domain
as the same-install signal.
2026-08-20 13:12:53 +01:00
FuelBoard Contributor d73a64023f road distance: trigger at launch + foreground, fall back to saved location
Road-distance routing previously only fired once a fresh GPS fix arrived via
the location hook/onChange. A returning user on a cold launch waited for that
fix, and if onboarding/flag issues kept tracking from starting, distances
stayed straight-line. Now:
- refreshRoadDistancesIfNeeded() uses the last saved location when the live
  fix isn't set yet, so it can run immediately.
- It's called explicitly on launch (already-onboarded branch), on foreground
  activation, and on the existing location paths.
2026-08-20 12:56:16 +01:00
FuelBoard Contributor dc6d1fd724 Merge branch 'fix/onboarding-persistence' into test/road-onboarding 2026-08-20 12:50:38 +01:00
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
7 changed files with 191 additions and 38 deletions
+1
View File
@@ -1,6 +1,7 @@
build/ build/
build-release/ build-release/
build-sim/ build-sim/
build-watch/
.DS_Store .DS_Store
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
+12 -5
View File
@@ -56,14 +56,16 @@ struct ContentView: View {
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the /// Kicks off a (throttled) Apple-Maps road-distance recompute for the
/// stations around the current fix. The app owns routing — the widget and /// stations around the current fix. The app owns routing — the widget and
/// Live Activity only read the cached result. /// Live Activity only read the cached result. Falls back to the last saved
/// location so it can run before the first fresh GPS fix arrives.
private func refreshRoadDistancesIfNeeded() { private func refreshRoadDistancesIfNeeded() {
guard let location else { return } let origin = location ?? FuelStore.loadLocation()
guard let origin else { return }
Task { Task {
await RoadDistanceService.refreshIfNeeded( await RoadDistanceService.refreshIfNeeded(
stations: stations, stations: stations,
lat: location.lat, lat: origin.lat,
lng: location.lng lng: origin.lng
) )
} }
} }
@@ -319,7 +321,7 @@ struct ContentView: View {
#else #else
let shouldSkipOnboarding = false let shouldSkipOnboarding = false
#endif #endif
if FuelStore.loadHasCompletedOnboarding() if !FuelStore.shouldShowOnboarding()
|| shouldSkipOnboarding { || shouldSkipOnboarding {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
// Geofences and the Live Activity must follow the user even in // Geofences and the Live Activity must follow the user even in
@@ -332,6 +334,10 @@ struct ContentView: View {
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
monitor.setEnabled(alertsEnabled) monitor.setEnabled(alertsEnabled)
updateLiveActivity() updateLiveActivity()
// Compute road distances early (throttled; falls back to the
// last saved location) so distance surfaces are road-matched
// as soon as stations are available.
refreshRoadDistancesIfNeeded()
// Refresh only when the cache is stale (twice-a-day policy). // Refresh only when the cache is stale (twice-a-day policy).
// Skipped under the force-* hooks so the banner stays up. // Skipped under the force-* hooks so the banner stays up.
#if DEBUG #if DEBUG
@@ -377,6 +383,7 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity() updateLiveActivity()
refreshRoadDistancesIfNeeded()
// No network fetch on foreground — pull-to-refresh is the override. // No network fetch on foreground — pull-to-refresh is the override.
} else { } else {
locationManager.stopForegroundTracking() locationManager.stopForegroundTracking()
+7 -2
View File
@@ -51,11 +51,16 @@ enum RoadDistanceService {
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM } .filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
var entries: [String: Double] = [:] var entries: [String: CachedRoadDistance] = [:]
for station in nearest { for station in nearest {
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
if let meters = await roadMeters(from: origin, to: dest) { if let meters = await roadMeters(from: origin, to: dest) {
entries[station.id] = meters // 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).
entries[station.id] = CachedRoadDistance(meters: meters,
lat: station.lat,
lng: station.lng)
} }
} }
guard !entries.isEmpty else { return } guard !entries.isEmpty else { return }
+12 -13
View File
@@ -489,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
struct FuelBoardShortcuts: AppShortcutsProvider { struct FuelBoardShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] { static var appShortcuts: [AppShortcut] {
// Generic parameterized shortcut — matches whatever fuel word Siri // NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
// resolves. Parameter resolution is flaky on-device ("cheapest diesel" // ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel // shortcuts for the same intent (parameter resolution was already flaky
// entries below carry the fuel word as LITERAL phrase text. // on-device), which degraded Siri's NLU ranking and caused the
AppShortcut( // hit-and-miss "can't do that, searching in app" fallback. Now Siri
intent: CheapestFuelIntent(), // routes on literal fuel words only, and the freed slot keeps us under
phrases: [ // the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me", // metadata-processor requirement) and mirrors how people actually ask.
],
shortTitle: "Cheapest Fuel",
systemImageName: "fuelpump"
)
AppShortcut( AppShortcut(
intent: CheapestFuelIntent(fuel: .e10), intent: CheapestFuelIntent(fuel: .e10),
phrases: [ phrases: [
@@ -509,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
"Ask \(.applicationName) for the cheapest petrol near me", "Ask \(.applicationName) for the cheapest petrol near me",
"Find the cheapest petrol near me \(.applicationName)", "Find the cheapest petrol near me \(.applicationName)",
"Find the cheapest unleaded near me \(.applicationName)", "Find the cheapest unleaded near me \(.applicationName)",
"What's the cheapest unleaded near me \(.applicationName)",
"What's the cheapest petrol near me \(.applicationName)",
"Cheapest unleaded near me \(.applicationName)",
"Where's the cheapest petrol \(.applicationName)",
], ],
shortTitle: "Cheapest Unleaded", shortTitle: "Cheapest Unleaded",
systemImageName: "fuelpump" systemImageName: "fuelpump"
+48 -2
View File
@@ -25,6 +25,7 @@ struct TrendsView: View {
var onHistoryRecovered: (() -> Void)? = nil var onHistoryRecovered: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var fuel: FuelType = .e10 @State private var fuel: FuelType = .e10
@State private var rangeDays: Int = 30 @State private var rangeDays: Int = 30
@@ -34,6 +35,13 @@ struct TrendsView: View {
@State private var loadFailed = false @State private var loadFailed = false
@State private var firstSnapshot: String? @State private var firstSnapshot: String?
/// Per-series draw-in waterline: maps stationID → how many leading points
/// are revealed, so each line traces left→right on first appearance.
/// Series reveal in a short staggered cascade so the draw is clearly
/// visible even with only 2 points per line. Stays full after the first
/// reveal so range/mode switches morph instead of re-tracing.
@State private var revealed: [String: Int] = [:]
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet /// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
/// opens where the user was — same pattern as FavouritesView. /// opens where the user was — same pattern as FavouritesView.
init(favourites: [FavouriteEntry], init(favourites: [FavouriteEntry],
@@ -157,7 +165,13 @@ struct TrendsView: View {
// pointer probe already done above. // pointer probe already done above.
loadFailed = firstSnapshot == nil loadFailed = firstSnapshot == nil
} }
// Always redraw the lines on a selection change. `.task(id:)` fires on
// the initial appear and on every fuel/range switch (there's no
// periodic refetch in this sheet), so a staggered per-series draw-in
// replays exactly when the user picks 7/30/90 (or switches fuel) while
// a width-stable morph keeps the x-axis/y-range from jumping abruptly.
series = fetched series = fetched
revealSeries(fetched)
// A failure with no data IS a connection problem — raise the global // A failure with no data IS a connection problem — raise the global
// banner so the user isn't stuck with a silent retry state. Success // banner so the user isn't stuck with a silent retry state. Success
// clears it (only if the banner is the connection banner). // clears it (only if the banner is the connection banner).
@@ -168,6 +182,32 @@ struct TrendsView: View {
} }
} }
/// Replays the staggered per-series draw-in for the given histories:
/// resets the waterline, then cascades each station's line left→right,
/// 0.18s apart, on every range/fuel selection. Reduce Motion jump-cuts
/// straight to the full state.
private func revealSeries(_ histories: [StationHistory]) {
let hasPoints = histories.contains { !$0.points.isEmpty }
guard hasPoints else { return }
if reduceMotion {
revealed = histories.reduce(into: [:]) { $0[$1.stationID] = $1.points.count }
return
}
revealed = [:]
let cascadeNS = UInt64(0.18 * 1_000_000_000)
for (i, h) in histories.enumerated() {
let sid = h.stationID
let total = h.points.count
Task {
try? await Task.sleep(nanoseconds: UInt64(i) * cascadeNS)
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.5)) {
revealed[sid] = total
}
}
}
}
private func yLabel(_ pence: Double) -> String { private func yLabel(_ pence: Double) -> String {
switch mode { switch mode {
case .price: case .price:
@@ -330,6 +370,9 @@ struct TrendsView: View {
.font(.system(size: 30, weight: .bold, design: .default)) .font(.system(size: 30, weight: .bold, design: .default))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.primary) .foregroundStyle(.primary)
// Roll the digits to the new figure on range/mode change
// (fires inside the animated transaction above).
.contentTransition(.numericText(value: headAvg))
if let delta = headlineDelta, delta != 0 { if let delta = headlineDelta, delta != 0 {
Label( Label(
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p", "\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
@@ -389,13 +432,13 @@ struct TrendsView: View {
// type-checker's budget.) // type-checker's budget.)
Chart { Chart {
ForEach(displaySeries) { history in ForEach(displaySeries) { history in
ForEach(history.points) { point in ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
areaMark(point, series: history.name, areaMark(point, series: history.name,
color: seriesColor(index(of: history.stationID))) color: seriesColor(index(of: history.stationID)))
} }
} }
ForEach(displaySeries) { history in ForEach(displaySeries) { history in
ForEach(history.points) { point in ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
lineMark(point, series: history.name, lineMark(point, series: history.name,
color: seriesColor(index(of: history.stationID))) color: seriesColor(index(of: history.stationID)))
} }
@@ -424,6 +467,9 @@ struct TrendsView: View {
} }
} }
.frame(height: 190) .frame(height: 190)
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
// lines + axis gliding to the rebased series.
.animation(.easeInOut(duration: 0.35), value: mode)
} }
/// One gradient-filled area band under a single point of a series. /// One gradient-filled area band under a single point of a series.
@@ -647,7 +647,8 @@ final class RoadDistanceCacheTests: XCTestCase {
func testRoadDistanceUsedWhenCachedNear() { func testRoadDistanceUsedWhenCachedNear() {
let s = station("a", 51.5074, -0.1278) let s = station("a", 51.5074, -0.1278)
// Cache a road distance of 3.2 km for this station from the user's fix. // 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]) FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, 3.2, accuracy: 0.0001) XCTAssertEqual(km, 3.2, accuracy: 0.0001)
} }
@@ -655,8 +656,9 @@ final class RoadDistanceCacheTests: XCTestCase {
func testRoadDistanceNilWhenOriginFar() { func testRoadDistanceNilWhenOriginFar() {
let s = station("a", 51.5074, -0.1278) let s = station("a", 51.5074, -0.1278)
// Cache built in London, but the user is now ~200 km away -> stale. // Cache built in London, but the user is now ~200 km away -> stale.
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, entries: ["a": 3200]) FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426) entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426)
XCTAssertNil(meters) XCTAssertNil(meters)
// And display falls back to straight-line. // And display falls back to straight-line.
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426) let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
@@ -664,10 +666,36 @@ final class RoadDistanceCacheTests: XCTestCase {
} }
func testRoadDistanceUsedForOtherStationNotFound() { func testRoadDistanceUsedForOtherStationNotFound() {
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200]) FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// A station that isn't in the cache falls back to straight-line. // A station that isn't in the cache falls back to straight-line.
let s = station("z", 51.51, -0.13) let s = station("z", 51.51, -0.13)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) 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) XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
} }
func testRoadDistanceNotServedWhenStationPinDiffers() {
// Route a road distance to station "a" at pin P1.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// The SAME station id appears with a moved pin (corrected coordinate /
// different embedded vs live source): the cached route to P1 must NOT
// be served — it belongs to a different location.
let moved = station("a", 51.5400, -0.1600)
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
"road value routed to the old pin leaked onto a different coordinate")
}
}
// MARK: - Install identity
final class InstallIdentityTests: XCTestCase {
func testFreshInstallIsStableAfterFirstCall() {
// The first call seeds the common install id (local == keychain), so
// any subsequent call in the same process must report NOT-fresh. This
// holds regardless of persisted keychain/defaults state from prior runs.
_ = FuelStore.isFreshInstall()
XCTAssertFalse(FuelStore.isFreshInstall())
}
} }
+79 -12
View File
@@ -934,15 +934,56 @@ struct FuelStore {
} }
// MARK: Onboarding — the app shows the intro screen on first launch only // 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 // (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
// so the widget can see it too if ever needed. // (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 { static func loadHasCompletedOnboarding() -> Bool {
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false (loadString(service: onboardingCompletedKey) ?? "0") == "1"
} }
static func saveHasCompletedOnboarding(_ completed: Bool) { static func saveHasCompletedOnboarding(_ completed: Bool) {
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey) saveString(completed ? "1" : "0", service: onboardingCompletedKey)
}
// MARK: Install identity — distinguish a genuinely fresh install (where
// onboarding should replay) from later launches of the same install.
/// Keychain copy of the install id (survives reinstall).
private static let installIDKey = "fuelboard.installID"
/// Local (app-own container) copy — wiped on reinstall, persists across
/// normal launches. Free SideStore accounts have no app-group container,
/// so this app-own defaults domain is the reliable "same install" signal.
private static let localInstallIDKey = "fuelboard.installID.local"
/// True when this is a first-ever install OR the app was just reinstalled
/// (local install id missing/different from the keychain id). Seeds a fresh
/// id into both stores so the next launch within the same install is not a
/// "fresh install" any more.
static func isFreshInstall() -> Bool {
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
let remote = loadString(service: installIDKey)
if let local, let remote, local == remote { return false }
// Fresh / mismatched install (or first launch). Pattern a common id so
// subsequent launches of this install are recognised as the same one.
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: localInstallIDKey)
saveString(id, service: installIDKey)
return true
}
/// Onboarding should present when it hasn't been completed in this install
/// OR this is a freshly-installed app (so the walkthrough replays on a new
/// install, e.g. after a SideStore reinstall, without re-showing on every
/// ordinary launch). The install check always runs (it seeds the id) rather
/// than short-circuiting, so a brand-new install is recorded before the
/// user ever fills in onboarding.
static func shouldShowOnboarding() -> Bool {
let fresh = isFreshInstall()
return !loadHasCompletedOnboarding() || fresh
} }
// MARK: Road distances (Apple-Maps-matched, computed by the app) // MARK: Road distances (Apple-Maps-matched, computed by the app)
@@ -958,7 +999,16 @@ struct FuelStore {
/// user position before a cached road distance is treated as stale. /// user position before a cached road distance is treated as stale.
static let roadDistanceOriginToleranceMeters: Double = 600 static let roadDistanceOriginToleranceMeters: Double = 600
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) { /// How far (degrees) a station's stored coordinate may drift from the pin
/// a road distance was actually routed to before that cached value is
/// treated as belonging to a different station. Guards against one data
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
/// road distance that was computed for a different coordinate under the
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
/// catches any real pin change.
static let roadDistancePinToleranceDegrees: Double = 1e-4
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng, let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries) updatedAt: Date().timeIntervalSince1970, entries: entries)
if let data = try? JSONEncoder().encode(cache) { if let data = try? JSONEncoder().encode(cache) {
@@ -976,10 +1026,11 @@ struct FuelStore {
/// Cached road distance (metres) to a station from the user's location, or /// 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 /// nil when not cached / the cache was built too far from where the user
/// is now. /// is now / the station's own pin doesn't match the coordinate that was
static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? { /// routed.
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
guard let cache = loadRoadDistances(), guard let cache = loadRoadDistances(),
let meters = cache.entries[stationID] else { return nil } let entry = cache.entries[station.id] else { return nil }
// The cache is only valid near the location it was built from. // The cache is only valid near the location it was built from.
let dLat = (userLat - cache.sourceLat) * .pi / 180 let dLat = (userLat - cache.sourceLat) * .pi / 180
let dLng = (userLng - cache.sourceLng) * .pi / 180 let dLng = (userLng - cache.sourceLng) * .pi / 180
@@ -989,13 +1040,18 @@ struct FuelStore {
sin(dLng / 2) * sin(dLng / 2) sin(dLng / 2) * sin(dLng / 2)
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a)) let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil } guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
return meters // Pin fingerprint: never serve a routed value for a coordinate we
// didn't actually route to. This is the guard that keeps embedded /
// live / cached station sets from injecting each other's road metres.
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
return entry.meters
} }
/// Distance (km) to display for a station: cached ROAD distance when /// Distance (km) to display for a station: cached ROAD distance when
/// available (matches Apple Maps), else straight-line haversine. /// available (matches Apple Maps), else straight-line haversine.
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double { static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) { if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
return meters / 1000.0 return meters / 1000.0
} }
return station.distanceKM(to: userLat, lng2: userLng) return station.distanceKM(to: userLat, lng2: userLng)
@@ -1062,10 +1118,21 @@ struct FuelStore {
} }
/// Cached Apple-Maps road distances for nearby stations (see /// Cached Apple-Maps road distances for nearby stations (see
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres. /// A single cached road distance plus the station pin it was routed to. Keeping
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
/// *different* coordinate under the same ID — the guard that stops embedded /
/// live / cached station sets cross-contaminating the distance display.
struct CachedRoadDistance: Codable {
let meters: Double
let lat: Double
let lng: Double
}
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
/// the routed pin.
struct RoadDistanceCache: Codable { struct RoadDistanceCache: Codable {
let sourceLat: Double let sourceLat: Double
let sourceLng: Double let sourceLng: Double
let updatedAt: TimeInterval let updatedAt: TimeInterval
let entries: [String: Double] let entries: [String: CachedRoadDistance]
} }