Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50036c8a67 | ||
|
|
a71b7ae20e | ||
|
|
98cf3a5736 | ||
|
|
ed41df46de | ||
|
|
9342ccc7c9 | ||
|
|
508ccc3802 | ||
|
|
69c3c17606 | ||
|
|
0b27d3d4f6 | ||
|
|
01517ba687 | ||
|
|
d73a64023f | ||
|
|
dc6d1fd724 | ||
|
|
90fb3b7385 | ||
|
|
64ef260959 |
@@ -1,6 +1,7 @@
|
|||||||
build/
|
build/
|
||||||
build-release/
|
build-release/
|
||||||
build-sim/
|
build-sim/
|
||||||
|
build-watch/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|||||||
@@ -292,6 +292,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/App-Info.plist;
|
INFOPLIST_FILE = Config/App-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -314,6 +315,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/App-Info.plist;
|
INFOPLIST_FILE = Config/App-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -336,6 +338,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -360,6 +363,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ struct ContentView: View {
|
|||||||
// fires on every fix incl. background significant-change
|
// fires on every fix incl. background significant-change
|
||||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||||
updateLiveActivity()
|
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. Falls back to the last saved
|
||||||
|
/// location so it can run before the first fresh GPS fix arrives.
|
||||||
|
private func refreshRoadDistancesIfNeeded() {
|
||||||
|
let origin = location ?? FuelStore.loadLocation()
|
||||||
|
guard let origin else { return }
|
||||||
|
Task {
|
||||||
|
await RoadDistanceService.refreshIfNeeded(
|
||||||
|
stations: stations,
|
||||||
|
lat: origin.lat,
|
||||||
|
lng: origin.lng
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,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
|
||||||
@@ -317,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
|
||||||
@@ -362,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()
|
||||||
@@ -377,6 +399,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -949,7 +972,7 @@ struct StationRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
if let location {
|
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)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ enum LiveActivityManager {
|
|||||||
brand: best.brand,
|
brand: best.brand,
|
||||||
pricePence: price,
|
pricePence: price,
|
||||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
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,
|
lat: best.lat,
|
||||||
lng: best.lng,
|
lng: best.lng,
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// 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: CachedRoadDistance] = [:]
|
||||||
|
for station in nearest {
|
||||||
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
if let meters = await roadMeters(from: origin, to: dest) {
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ struct CheapestFuelIntent: AppIntent {
|
|||||||
struct DirectionsToCheapestFuelIntent: AppIntent {
|
struct DirectionsToCheapestFuelIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
|
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
|
||||||
static var description = IntentDescription(
|
static var description = IntentDescription(
|
||||||
"Opens Apple Maps directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
"Opens directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
||||||
)
|
)
|
||||||
|
|
||||||
@Parameter(title: "Fuel")
|
@Parameter(title: "Fuel")
|
||||||
@@ -285,7 +285,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
|
|||||||
struct DirectionsToFavouriteFuelIntent: AppIntent {
|
struct DirectionsToFavouriteFuelIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
|
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
|
||||||
static var description = IntentDescription(
|
static var description = IntentDescription(
|
||||||
"Opens Apple Maps directions to your top favourite station for a fuel."
|
"Opens directions to your top favourite station for a fuel."
|
||||||
)
|
)
|
||||||
|
|
||||||
@Parameter(title: "Fuel")
|
@Parameter(title: "Fuel")
|
||||||
@@ -404,7 +404,7 @@ struct FuelMessage: View {
|
|||||||
/// Shortcut slot and adds no phrase.
|
/// Shortcut slot and adds no phrase.
|
||||||
struct OpenDirectionsIntent: AppIntent {
|
struct OpenDirectionsIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Directions"
|
static var title: LocalizedStringResource = "Directions"
|
||||||
static var description = IntentDescription("Opens Apple Maps directions to the station.")
|
static var description = IntentDescription("Opens directions to the station.")
|
||||||
static var isDiscoverable: Bool = false
|
static var isDiscoverable: Bool = false
|
||||||
|
|
||||||
@Parameter var stationName: String
|
@Parameter var stationName: String
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -620,3 +620,82 @@ final class OfflineDataLabelTests: XCTestCase {
|
|||||||
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
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": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
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": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
let meters = FuelStore.roadDistanceMeters(for: s, 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": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
}
|
}
|
||||||
if let location = entry.location {
|
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)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
@@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let location = entry.location {
|
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)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -949,6 +949,114 @@ struct FuelStore {
|
|||||||
saveString(completed ? "1" : "0", service: 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)
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
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 / the station's own pin doesn't match the coordinate that was
|
||||||
|
/// routed.
|
||||||
|
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
guard let cache = loadRoadDistances(),
|
||||||
|
let entry = cache.entries[station.id] 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 }
|
||||||
|
// 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
|
||||||
|
/// available (matches Apple Maps), else straight-line haversine.
|
||||||
|
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||||
|
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||||
|
return meters / 1000.0
|
||||||
|
}
|
||||||
|
return station.distanceKM(to: userLat, lng2: userLng)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
|
|
||||||
private static func keychainData(service: String) -> Data? {
|
private static func keychainData(service: String) -> Data? {
|
||||||
@@ -1008,3 +1116,23 @@ struct FuelStore {
|
|||||||
loadString(service: "widget.diag.\(intentType)")
|
loadString(service: "widget.diag.\(intentType)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached Apple-Maps road distances for nearby stations (see
|
||||||
|
/// 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 {
|
||||||
|
let sourceLat: Double
|
||||||
|
let sourceLng: Double
|
||||||
|
let updatedAt: TimeInterval
|
||||||
|
let entries: [String: CachedRoadDistance]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user