Add Worth the Drive fuel savings with road-routed estimates
Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.). The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving. Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
This commit is contained in:
+301
-12
@@ -22,6 +22,8 @@ struct ContentView: View {
|
|||||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||||
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||||
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||||||
|
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||||
|
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
|
||||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
@@ -63,15 +65,42 @@ struct ContentView: View {
|
|||||||
private func refreshRoadDistancesIfNeeded() {
|
private func refreshRoadDistancesIfNeeded() {
|
||||||
let origin = location ?? FuelStore.loadLocation()
|
let origin = location ?? FuelStore.loadLocation()
|
||||||
guard let origin else { return }
|
guard let origin else { return }
|
||||||
|
let priority = worthTheDriveRoutingPriorities(location: origin)
|
||||||
Task {
|
Task {
|
||||||
await RoadDistanceService.refreshIfNeeded(
|
await RoadDistanceService.refreshIfNeeded(
|
||||||
stations: stations,
|
stations: stations,
|
||||||
lat: origin.lat,
|
lat: origin.lat,
|
||||||
lng: origin.lng
|
lng: origin.lng,
|
||||||
|
prioritising: priority
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stations worth spending Apple Maps route requests on: the best
|
||||||
|
/// Worth-the-Drive candidates by estimated net saving, plus the comparison
|
||||||
|
/// baseline (every other row is measured against it).
|
||||||
|
///
|
||||||
|
/// Ranking is by net saving, so these are typically FURTHER out than the
|
||||||
|
/// nearest stations — the ones nearest-only routing never reached, which is
|
||||||
|
/// why their rows stayed on straight-line estimates.
|
||||||
|
private func worthTheDriveRoutingPriorities(location: Coordinate) -> [FuelStation] {
|
||||||
|
guard fuelSavingsSettings.isConfigured else { return [] }
|
||||||
|
let baseline = nearestSavingsBaseline
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
poolStations,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: fuelSavingsSettings,
|
||||||
|
amount: sessionFuelSavingsAmount,
|
||||||
|
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||||||
|
)
|
||||||
|
var priorities = Array(ranked.prefix(RoadDistanceService.priorityCandidates))
|
||||||
|
if let baseline, !priorities.contains(where: { $0.id == baseline.id }) {
|
||||||
|
priorities.insert(baseline, at: 0)
|
||||||
|
}
|
||||||
|
return priorities
|
||||||
|
}
|
||||||
|
|
||||||
private func ensureBackgroundMotionTrackingIfNeeded() {
|
private func ensureBackgroundMotionTrackingIfNeeded() {
|
||||||
if alertsEnabled || liveActivityEnabled {
|
if alertsEnabled || liveActivityEnabled {
|
||||||
locationManager.startBackgroundTracking()
|
locationManager.startBackgroundTracking()
|
||||||
@@ -113,11 +142,11 @@ struct ContentView: View {
|
|||||||
@State private var bannerOffset: CGFloat = 0
|
@State private var bannerOffset: CGFloat = 0
|
||||||
@State private var bannerClearTask: Task<Void, Never>?
|
@State private var bannerClearTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
/// The pool the list draws from. In Cheapest and Worth-the-Drive modes the
|
||||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
/// chosen miles radius bounds it ("best price within X miles"); in Closest
|
||||||
/// redundant — the whole country sorted nearest-first, because "nearest"
|
/// mode the radius is redundant — the whole country sorted nearest-first,
|
||||||
/// must never answer with an empty state. STRICT: no fallback to
|
/// because "nearest" must never answer with an empty state. STRICT: no
|
||||||
/// out-of-radius stations in Cheapest mode.
|
/// fallback to out-of-radius stations in radius-bound modes.
|
||||||
private var poolStations: [FuelStation] {
|
private var poolStations: [FuelStation] {
|
||||||
let selling = stations.filter { $0.prices[selectedFuel] != nil }
|
let selling = stations.filter { $0.prices[selectedFuel] != nil }
|
||||||
guard let location else { return selling }
|
guard let location else { return selling }
|
||||||
@@ -180,7 +209,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var sortedStations: [FuelStation] {
|
private var sortedStations: [FuelStation] {
|
||||||
let pool = poolStations
|
let pool = poolStations
|
||||||
switch sortMode {
|
switch effectiveSortMode {
|
||||||
case .closest:
|
case .closest:
|
||||||
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
|
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
|
||||||
return pool.sorted { lhs, rhs in
|
return pool.sorted { lhs, rhs in
|
||||||
@@ -200,9 +229,165 @@ struct ContentView: View {
|
|||||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
||||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||||
}
|
}
|
||||||
|
case .worthTheDrive:
|
||||||
|
return sortedForWorthTheDrive(pool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row/detail savings lookup with the comparison baseline resolved ONCE.
|
||||||
|
///
|
||||||
|
/// `nearestSavingsBaseline` scans the whole station list, and StationsView
|
||||||
|
/// asks for savings once per visible row (plus again for the detail sheet),
|
||||||
|
/// so handing the view a per-station method would rescan ~8k stations
|
||||||
|
/// dozens of times per render.
|
||||||
|
private func savingsLookup(baseline: FuelStation?) -> (FuelStation) -> FuelSavingsAvailability? {
|
||||||
|
guard fuelSavingsSettings.isConfigured else { return { _ in nil } }
|
||||||
|
guard let location else { return { _ in .unavailable("Location unavailable") } }
|
||||||
|
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
|
||||||
|
return { station in
|
||||||
|
FuelSavingsEstimator.availability(
|
||||||
|
station: station,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: fuelSavingsSettings,
|
||||||
|
amount: sessionFuelSavingsAmount,
|
||||||
|
journey: savingsJourneyMiles(for: station, location: location),
|
||||||
|
baselineJourney: baselineJourney
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nearest station that sells the selected fuel — the Worth-the-Drive
|
||||||
|
/// comparison baseline.
|
||||||
|
///
|
||||||
|
/// Single O(n) pass on purpose: `min(by:)` re-evaluates its closure twice
|
||||||
|
/// per comparison, and each evaluation reads the road-distance cache. With
|
||||||
|
/// the full UK list that is thousands of redundant lookups per render, all
|
||||||
|
/// on the main thread during scene creation (see `loadRoadDistances`).
|
||||||
|
private var nearestSavingsBaseline: FuelStation? {
|
||||||
|
guard let location else { return nil }
|
||||||
|
var bestStation: FuelStation?
|
||||||
|
var bestMiles = Double.infinity
|
||||||
|
for station in stations where station.prices[selectedFuel] != nil {
|
||||||
|
let miles = savingsFallbackMiles(for: station, location: location)
|
||||||
|
if miles < bestMiles {
|
||||||
|
bestMiles = miles
|
||||||
|
bestStation = station
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestStation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sort the UI and the list actually use.
|
||||||
|
///
|
||||||
|
/// The Worth-the-Drive sort is only offered while Fuel Savings is
|
||||||
|
/// configured, but the chosen sort is persisted — so a stored
|
||||||
|
/// `.worthTheDrive` would otherwise keep the fill-up control on screen (and
|
||||||
|
/// leave the picker on a mode it no longer lists) after the feature is
|
||||||
|
/// switched off. Fall back to Cheapest for RENDER ONLY: the stored
|
||||||
|
/// preference is left untouched, so re-enabling the feature restores the
|
||||||
|
/// user's choice.
|
||||||
|
private var effectiveSortMode: SortMode {
|
||||||
|
(sortMode == .worthTheDrive && !fuelSavingsSettings.isConfigured) ? .cheapest : sortMode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Journey (out and back) miles for a station: the measured road route when
|
||||||
|
/// one has been fetched, otherwise a straight-line out-and-back estimate, so
|
||||||
|
/// that every station the app lists can carry a saving.
|
||||||
|
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
|
||||||
|
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
|
||||||
|
return FuelSavingsJourney(miles: roadMeters / 1609.34, basis: .roadRoute)
|
||||||
|
}
|
||||||
|
return FuelSavingsJourney(
|
||||||
|
miles: savingsFallbackMiles(for: station, location: location),
|
||||||
|
basis: .straightLine
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savingsFallbackMiles(for station: FuelStation, location: Coordinate) -> Double {
|
||||||
|
FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) * 0.621371 * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortedForWorthTheDrive(_ pool: [FuelStation]) -> [FuelStation] {
|
||||||
|
guard fuelSavingsSettings.isConfigured, let location else {
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-DRIVE skipped configured=\(fuelSavingsSettings.isConfigured) location=\(self.location == nil ? "none" : "ok") pool=\(pool.count)")
|
||||||
|
#endif
|
||||||
|
return pool.sorted { ($0.prices[selectedFuel] ?? .infinity) < ($1.prices[selectedFuel] ?? .infinity) }
|
||||||
|
}
|
||||||
|
// Every station in the pool is ranked, with a straight-line estimate
|
||||||
|
// until its road route has been measured. Ranking only a road-route
|
||||||
|
// shortlist hid cheaper stations a few miles further out and left later
|
||||||
|
// pages with no savings (see `FuelSavingsEstimator.ranked`).
|
||||||
|
let baseline = nearestSavingsBaseline
|
||||||
|
let ordered = FuelSavingsEstimator.ranked(
|
||||||
|
pool,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: fuelSavingsSettings,
|
||||||
|
amount: sessionFuelSavingsAmount,
|
||||||
|
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||||||
|
)
|
||||||
|
#if DEBUG
|
||||||
|
logWorthTheDriveSummary(ordered, baseline: baseline, location: location)
|
||||||
|
#endif
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Last FB-DRIVE line printed, so the ranking (which runs on every render)
|
||||||
|
/// only reports when the result actually changes.
|
||||||
|
private static var lastDriveLogLine: String?
|
||||||
|
|
||||||
|
/// One summary line per ranking pass (FB-DRIVE) so a device console shows
|
||||||
|
/// whether the Worth the Drive list is actually carrying savings for the
|
||||||
|
/// whole pool — the regression was silent otherwise.
|
||||||
|
private func logWorthTheDriveSummary(_ ordered: [FuelStation],
|
||||||
|
baseline: FuelStation?,
|
||||||
|
location: Coordinate) {
|
||||||
|
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
|
||||||
|
var rows: [(station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)] = []
|
||||||
|
for station in ordered {
|
||||||
|
// Capture the journey ONCE and print that same value: routing runs
|
||||||
|
// in the background, so re-reading it for the log would mix a stale
|
||||||
|
// journey into a fresh figure and make the numbers unreadable.
|
||||||
|
let journey = savingsJourneyMiles(for: station, location: location)
|
||||||
|
guard let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: station,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: fuelSavingsSettings,
|
||||||
|
amount: sessionFuelSavingsAmount,
|
||||||
|
journey: journey,
|
||||||
|
baselineJourney: baselineJourney
|
||||||
|
), let result = availability.result else { continue }
|
||||||
|
rows.append((station, result, journey))
|
||||||
|
}
|
||||||
|
func describe(_ row: (station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)) -> String {
|
||||||
|
let price = row.station.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a"
|
||||||
|
return "\"\(row.station.name)\" p=\(price) j=\(String(format: "%.1f", row.journey?.miles ?? 0))mi(\(row.journey?.basis.rawValue ?? "none")) gross=\(String(format: "%.2f", row.result.grossSavingGBP)) extra=\(String(format: "%.2f", row.result.extraDrivingCostGBP)) extraMi=\(String(format: "%.1f", row.result.extraDistanceMiles)) net=\(String(format: "%.2f", row.result.netSavingGBP)) \"\(row.result.headline())\""
|
||||||
|
}
|
||||||
|
let baselineLine: String
|
||||||
|
if let baseline, let baselineJourney {
|
||||||
|
let price = baseline.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a"
|
||||||
|
baselineLine = "baseline=\"\(baseline.name)\" p=\(price) j=\(String(format: "%.1f", baselineJourney.miles))mi(\(baselineJourney.basis.rawValue))"
|
||||||
|
} else {
|
||||||
|
baselineLine = "baseline=none"
|
||||||
|
}
|
||||||
|
let leaders = rows.prefix(3).map(describe).joined(separator: " | ")
|
||||||
|
let line = "FB-DRIVE pool=\(ordered.count) withSavings=\(rows.count) \(baselineLine) | \(leaders)"
|
||||||
|
guard ContentView.lastDriveLogLine != line else { return }
|
||||||
|
ContentView.lastDriveLogLine = line
|
||||||
|
print(line)
|
||||||
|
// `FB_DRIVE_FOCUS=<text>` prints one named station's row too, so a
|
||||||
|
// specific complaint can be checked against real routed numbers.
|
||||||
|
if let focus = ProcessInfo.processInfo.environment["FB_DRIVE_FOCUS"], !focus.isEmpty,
|
||||||
|
let row = rows.first(where: { $0.station.name.localizedCaseInsensitiveContains(focus) }) {
|
||||||
|
print("FB-DRIVE focus \(describe(row))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Station IDs favourited for the CURRENTLY selected fuel — the star on a
|
/// Station IDs favourited for the CURRENTLY selected fuel — the star on a
|
||||||
/// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites).
|
/// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites).
|
||||||
private var favouriteIDs: Set<String> {
|
private var favouriteIDs: Set<String> {
|
||||||
@@ -320,6 +505,26 @@ struct ContentView: View {
|
|||||||
selectedFuel = f
|
selectedFuel = f
|
||||||
FuelStore.saveSelectedFuel(f)
|
FuelStore.saveSelectedFuel(f)
|
||||||
}
|
}
|
||||||
|
// QA temp: `FB_DRIVE_DEBUG=1` (DEBUG builds only) forces the
|
||||||
|
// optional Fuel Savings feature ON and selects the Worth the Drive
|
||||||
|
// sort, so the ranking can be exercised from the device console
|
||||||
|
// (FB-DRIVE) without tapping through Settings.
|
||||||
|
//
|
||||||
|
// It must NOT substitute the stored MPG / fill amount: doing that
|
||||||
|
// planted the DEFAULT values (45 MPG) over the stored ones and made
|
||||||
|
// every logged figure wrong — the exact opposite of the hook's
|
||||||
|
// purpose. Only `enabled` is overridden; the store is still the
|
||||||
|
// source for MPG and fill.
|
||||||
|
//
|
||||||
|
// An ENVIRONMENT variable, not a launch argument: devicectl clusters
|
||||||
|
// dash-options per character, so a word like `-worthTheDrive` is
|
||||||
|
// parsed as its own `-t` (--timeout) and the launch is rejected.
|
||||||
|
if ProcessInfo.processInfo.environment["FB_DRIVE_DEBUG"] == "1" {
|
||||||
|
var forced = FuelStore.loadFuelSavingsSettings()
|
||||||
|
forced.enabled = true
|
||||||
|
fuelSavingsSettings = forced
|
||||||
|
sortMode = .worthTheDrive
|
||||||
|
}
|
||||||
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||||||
// failure legs for the screenshot harness. The auto-refresh below
|
// failure legs for the screenshot harness. The auto-refresh below
|
||||||
// is skipped so the banner stays up (a live fetch would clear it).
|
// is skipped so the banner stays up (a live fetch would clear it).
|
||||||
@@ -335,6 +540,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
showWidgetMock = false
|
showWidgetMock = false
|
||||||
|
#endif
|
||||||
|
// Diagnostic only: `effectiveSortMode` already handles a stored
|
||||||
|
// `.worthTheDrive` that the feature can no longer back.
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-SAVINGS launch storedSort=\(sortMode.displayName) effective=\(effectiveSortMode.displayName) configured=\(fuelSavingsSettings.isConfigured) enabled=\(fuelSavingsSettings.enabled) mpg=\(fuelSavingsSettings.mpg) amount=\(fuelSavingsSettings.preferredAmount.value)")
|
||||||
#endif
|
#endif
|
||||||
// Onboarding runs first on a fresh install — it owns the initial
|
// Onboarding runs first on a fresh install — it owns the initial
|
||||||
// permission prompts (location, notifications, and the data/local
|
// permission prompts (location, notifications, and the data/local
|
||||||
@@ -399,6 +609,13 @@ struct ContentView: View {
|
|||||||
Task { await refresh(force: true) }
|
Task { await refresh(force: true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: fuelSavingsSettings) { old, new in
|
||||||
|
// Fuel Savings changed in Settings while the app was running: the
|
||||||
|
// effective sort follows automatically (no stored sort is touched).
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
// Never start location tracking while onboarding is on screen —
|
// Never start location tracking while onboarding is on screen —
|
||||||
@@ -754,12 +971,13 @@ struct ContentView: View {
|
|||||||
/// The Stations tab, extracted from `body` so the TabView expression stays
|
/// The Stations tab, extracted from `body` so the TabView expression stays
|
||||||
/// within the compiler's type-check budget.
|
/// within the compiler's type-check budget.
|
||||||
private var stationsTab: some View {
|
private var stationsTab: some View {
|
||||||
StationsView(
|
let baselineStation = nearestSavingsBaseline
|
||||||
|
return StationsView(
|
||||||
stations: displayedStations,
|
stations: displayedStations,
|
||||||
totalCount: sortedStations.count,
|
totalCount: sortedStations.count,
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
selectedFuel: $selectedFuel,
|
selectedFuel: $selectedFuel,
|
||||||
sortMode: $sortMode,
|
sortMode: Binding(get: { effectiveSortMode }, set: { sortMode = $0 }),
|
||||||
stationLimit: $stationLimit,
|
stationLimit: $stationLimit,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
@@ -768,6 +986,10 @@ struct ContentView: View {
|
|||||||
location: location,
|
location: location,
|
||||||
favouriteIDs: favouriteIDs,
|
favouriteIDs: favouriteIDs,
|
||||||
stationTapAction: stationTapAction,
|
stationTapAction: stationTapAction,
|
||||||
|
fuelSavingsSettings: fuelSavingsSettings,
|
||||||
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||||
|
savingsForStation: savingsLookup(baseline: baselineStation),
|
||||||
|
savingsBaselineStationName: baselineStation?.name,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onRefresh: { await refresh(force: true) }
|
onRefresh: { await refresh(force: true) }
|
||||||
)
|
)
|
||||||
@@ -776,13 +998,18 @@ struct ContentView: View {
|
|||||||
/// The Favourites tab, extracted from `body` for the same type-check
|
/// The Favourites tab, extracted from `body` for the same type-check
|
||||||
/// budget reason.
|
/// budget reason.
|
||||||
private var favouritesTab: some View {
|
private var favouritesTab: some View {
|
||||||
FavouritesView(
|
let baselineStation = nearestSavingsBaseline
|
||||||
|
return FavouritesView(
|
||||||
favourites: refreshedFavourites,
|
favourites: refreshedFavourites,
|
||||||
selectedFuel: selectedFuel,
|
selectedFuel: selectedFuel,
|
||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
stationTapAction: stationTapAction,
|
stationTapAction: stationTapAction,
|
||||||
|
fuelSavingsSettings: fuelSavingsSettings,
|
||||||
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||||
|
savingsForStation: savingsLookup(baseline: baselineStation),
|
||||||
|
savingsBaselineStationName: baselineStation?.name,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites,
|
onReorder: reorderFavourites,
|
||||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||||
@@ -822,6 +1049,7 @@ struct ContentView: View {
|
|||||||
distanceUnit: $distanceUnit,
|
distanceUnit: $distanceUnit,
|
||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
stationTapAction: $stationTapAction,
|
stationTapAction: $stationTapAction,
|
||||||
|
fuelSavingsSettings: $fuelSavingsSettings,
|
||||||
dataRefreshMode: $dataRefreshMode,
|
dataRefreshMode: $dataRefreshMode,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
alertsRadiusKM: alertsRadius,
|
alertsRadiusKM: alertsRadius,
|
||||||
@@ -874,12 +1102,56 @@ struct ContentView: View {
|
|||||||
await refresh(force: true)
|
await refresh(force: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows whatever prices we already have BEFORE the network call settles, so
|
||||||
|
/// a refresh never hides data behind a full-screen spinner. It deliberately
|
||||||
|
/// does NOT touch `dataStatus`: a fetch that is still in flight must not
|
||||||
|
/// flash an offline/connection banner, and a successful fetch clears the
|
||||||
|
/// state anyway. The catch path below labels the state honestly ONLY if the
|
||||||
|
/// fetch really fails.
|
||||||
|
private func showFallbackStationsWhileRefreshing() {
|
||||||
|
guard stations.isEmpty else { return }
|
||||||
|
if let cached = MirrorFuelProvider.cachedStations(
|
||||||
|
near: location?.lat,
|
||||||
|
lng: location?.lng,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
radiusKM: nil
|
||||||
|
), !cached.isEmpty {
|
||||||
|
stations = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let saved = FuelStore.loadStations()
|
||||||
|
if !saved.isEmpty {
|
||||||
|
stations = saved
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Bundled snapshot — real but possibly dated. Shown so the list is
|
||||||
|
// never blank while the fetch is in flight; if the fetch fails, the
|
||||||
|
// catch path re-labels it with the honest bundled-dump date.
|
||||||
|
if let dump = BundledDumpProvider.stations, !dump.isEmpty {
|
||||||
|
stations = dump
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches fresh prices, but only when the cache is stale — unless
|
/// Fetches fresh prices, but only when the cache is stale — unless
|
||||||
/// `force` is true (pull-to-refresh is the manual override).
|
/// `force` is true (pull-to-refresh is the manual override).
|
||||||
private func refresh(force: Bool = false) async {
|
private func refresh(force: Bool = false) async {
|
||||||
if !force, FuelStore.isCacheFresh {
|
// Freshness ALONE must never skip the fetch: `lastRefresh` is stored
|
||||||
|
// keychain-first (survives reinstall) while the station file lives in
|
||||||
|
// the App Group container (wiped on reinstall). A "fresh" cache with
|
||||||
|
// no cached stations used to short-circuit here and leave the list
|
||||||
|
// empty for ever after every reinstall. Only skip when there is
|
||||||
|
// genuinely something to show.
|
||||||
|
let cachedCount = FuelStore.loadStations().count
|
||||||
|
if !force, FuelStore.isCacheFresh, cachedCount > 0 {
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-REFRESH skip: cache fresh, \(cachedCount) cached stations")
|
||||||
|
#endif
|
||||||
return // data already fresh — skip network entirely
|
return // data already fresh — skip network entirely
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-REFRESH start force=\(force) cached=\(cachedCount)")
|
||||||
|
#endif
|
||||||
|
showFallbackStationsWhileRefreshing()
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
@@ -890,6 +1162,9 @@ struct ContentView: View {
|
|||||||
radiusKM: nil // full-UK dump; device filters by miles radius
|
radiusKM: nil // full-UK dump; device filters by miles radius
|
||||||
)
|
)
|
||||||
stations = fetched
|
stations = fetched
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-REFRESH ok: \(fetched.count) stations from \(LiveChainProvider.lastSource ?? "?")")
|
||||||
|
#endif
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||||
@@ -918,6 +1193,9 @@ struct ContentView: View {
|
|||||||
// Live data restored — any status banner no longer applies.
|
// Live data restored — any status banner no longer applies.
|
||||||
dataStatus = .live
|
dataStatus = .live
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-REFRESH fail: \(error)")
|
||||||
|
#endif
|
||||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||||
FuelStore.markWatchRefreshHandled()
|
FuelStore.markWatchRefreshHandled()
|
||||||
}
|
}
|
||||||
@@ -959,7 +1237,11 @@ struct StationRow: View {
|
|||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
let isTopResult: Bool
|
let isTopResult: Bool
|
||||||
let isFavourite: Bool
|
let isFavourite: Bool
|
||||||
|
/// Label for the cheapest-pump-price badge ("TOP" in price/distance order,
|
||||||
|
/// "CHEAPEST" when the list is ranked by net saving).
|
||||||
|
var topBadgeText: String = "TOP"
|
||||||
var tapAction: StationTapAction = .openMap
|
var tapAction: StationTapAction = .openMap
|
||||||
|
var fuelSavings: FuelSavingsAvailability?
|
||||||
var onToggleFavourite: () -> Void = {}
|
var onToggleFavourite: () -> Void = {}
|
||||||
var onShowDetails: (FuelStation) -> Void = { _ in }
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||||
|
|
||||||
@@ -1025,7 +1307,11 @@ struct StationRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
if isTopResult {
|
if isTopResult {
|
||||||
Text("TOP")
|
// The badge always marks the CHEAPEST PUMP PRICE (never
|
||||||
|
// "best saving"), so in Worth the Drive order — where
|
||||||
|
// the top row is the best net saving — it may sit lower
|
||||||
|
// down the list, and says so.
|
||||||
|
Text(topBadgeText)
|
||||||
.font(.caption2.bold())
|
.font(.caption2.bold())
|
||||||
.padding(.horizontal, 5)
|
.padding(.horizontal, 5)
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
@@ -1050,6 +1336,9 @@ struct StationRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
if fuelSavings != nil {
|
||||||
|
FuelSavingsSummaryView(availability: fuelSavings, amount: .init(value: 0, unit: .pounds), baselineStationName: nil, compact: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ struct FavouritesView: View {
|
|||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
let stationTapAction: StationTapAction
|
let stationTapAction: StationTapAction
|
||||||
|
/// Fuel Savings config. When it is configured this tab shows the SAME
|
||||||
|
/// fill-up control and per-row estimate as the Stations list: one session
|
||||||
|
/// amount is shared, so adjusting it in either list updates both, and it
|
||||||
|
/// still never touches the Settings default.
|
||||||
|
let fuelSavingsSettings: FuelSavingsSettings
|
||||||
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||||
|
/// Estimated saving for a favourite, against the nearest eligible station —
|
||||||
|
/// the same baseline and definition the Stations list uses.
|
||||||
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
|
var savingsBaselineStationName: String?
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||||
@@ -70,6 +80,10 @@ struct FavouritesView: View {
|
|||||||
distanceUnit: DistanceUnit,
|
distanceUnit: DistanceUnit,
|
||||||
priceDisplayStyle: PriceDisplayStyle,
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
stationTapAction: StationTapAction,
|
stationTapAction: StationTapAction,
|
||||||
|
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||||
|
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||||
|
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||||
|
savingsBaselineStationName: String? = nil,
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
onHistoryUnavailable: (() -> Void)? = nil,
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
@@ -80,6 +94,10 @@ struct FavouritesView: View {
|
|||||||
self.distanceUnit = distanceUnit
|
self.distanceUnit = distanceUnit
|
||||||
self.priceDisplayStyle = priceDisplayStyle
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
self.stationTapAction = stationTapAction
|
self.stationTapAction = stationTapAction
|
||||||
|
self.fuelSavingsSettings = fuelSavingsSettings
|
||||||
|
_fuelSavingsAmount = fuelSavingsAmount
|
||||||
|
self.savingsForStation = savingsForStation
|
||||||
|
self.savingsBaselineStationName = savingsBaselineStationName
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
self.onHistoryUnavailable = onHistoryUnavailable
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
@@ -124,6 +142,17 @@ struct FavouritesView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fuelSavingsSettings.isConfigured {
|
||||||
|
Section {
|
||||||
|
FuelSavingsAdjustmentView(
|
||||||
|
amount: $fuelSavingsAmount,
|
||||||
|
referencePricePencePerLitre: cheapestPrice
|
||||||
|
)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
||||||
StationRow(
|
StationRow(
|
||||||
@@ -133,9 +162,11 @@ struct FavouritesView: View {
|
|||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: index == 0,
|
isTopResult: station.id == cheapest?.id,
|
||||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||||
|
topBadgeText: "CHEAPEST",
|
||||||
tapAction: stationTapAction,
|
tapAction: stationTapAction,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||||
onShowDetails: { detailStation = $0 }
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
@@ -183,7 +214,10 @@ struct FavouritesView: View {
|
|||||||
StationDetailView(
|
StationDetailView(
|
||||||
station: station,
|
station: station,
|
||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit
|
distanceUnit: distanceUnit,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsBaselineStationName: savingsBaselineStationName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FuelSavingsAdjustmentView: View {
|
||||||
|
@Binding var amount: FuelSavingsFillAmount
|
||||||
|
let referencePricePencePerLitre: Double?
|
||||||
|
|
||||||
|
@State private var textValue: String = ""
|
||||||
|
|
||||||
|
private var range: ClosedRange<Double> {
|
||||||
|
amount.unit == .pounds ? 10...100 : 10...80
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("How much are you filling up?")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Picker("Fill-up unit", selection: unitBinding) {
|
||||||
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||||
|
Text(unit.displayName).tag(unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
HStack {
|
||||||
|
Slider(value: sliderBinding, in: range, step: amount.unit == .pounds ? 5 : 1)
|
||||||
|
TextField(amount.unit == .pounds ? "£" : "Litres", text: valueBinding)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 72)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.onAppear { syncText() }
|
||||||
|
.onChange(of: amount.value) { _, _ in syncText() }
|
||||||
|
.onChange(of: amount.unit) { _, _ in syncText() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unitBinding: Binding<FuelSavingsFillUnit> {
|
||||||
|
Binding(
|
||||||
|
get: { amount.unit },
|
||||||
|
set: { newUnit in
|
||||||
|
guard newUnit != amount.unit else { return }
|
||||||
|
if let referencePricePencePerLitre,
|
||||||
|
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
|
||||||
|
amount = converted
|
||||||
|
} else {
|
||||||
|
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
|
||||||
|
}
|
||||||
|
syncText()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sliderBinding: Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: { min(max(amount.value, range.lowerBound), range.upperBound) },
|
||||||
|
set: { amount.value = min(max($0, range.lowerBound), range.upperBound) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var valueBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { textValue },
|
||||||
|
set: { raw in
|
||||||
|
textValue = raw
|
||||||
|
if let value = Double(raw), value > 0 {
|
||||||
|
amount.value = min(value, FuelSavingsCalculator.maximumFillAmount(for: amount.unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncText() {
|
||||||
|
let value = amount.value
|
||||||
|
textValue = value.rounded() == value ? String(format: "%.0f", value) : String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsSummaryView: View {
|
||||||
|
let availability: FuelSavingsAvailability?
|
||||||
|
let amount: FuelSavingsFillAmount
|
||||||
|
let baselineStationName: String?
|
||||||
|
var compact: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch availability {
|
||||||
|
case .available(let result):
|
||||||
|
VStack(alignment: .leading, spacing: compact ? 2 : 8) {
|
||||||
|
Label(result.status.displayName, systemImage: symbol(for: result.status))
|
||||||
|
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||||
|
Text(result.headline(compact: compact))
|
||||||
|
.font(compact ? .caption2.bold().monospacedDigit() : .subheadline.bold().monospacedDigit())
|
||||||
|
if !compact {
|
||||||
|
if result.status == .baseline {
|
||||||
|
Text("Your closest station with a price for this fuel — every other saving is measured against it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
|
||||||
|
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
|
||||||
|
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
||||||
|
LabeledContent("Extra distance", value: String(format: "%.1f miles", result.extraDistanceMiles))
|
||||||
|
if result.distanceBasis.isEstimate {
|
||||||
|
Text("Estimated from straight-line distance — refined once the route is measured.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .unavailable(let reason):
|
||||||
|
Text(compact ? "Savings unavailable" : "Worth the Drive unavailable: \(reason)")
|
||||||
|
.font(compact ? .caption2 : .caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case nil:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var amountText: String {
|
||||||
|
switch amount.unit {
|
||||||
|
case .pounds: return String(format: "£%.0f", amount.value)
|
||||||
|
case .litres: return String(format: "%.0f litres", amount.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func symbol(for status: FuelSavingsStatus) -> String {
|
||||||
|
switch status {
|
||||||
|
case .worthTheDrive: return "checkmark.circle.fill"
|
||||||
|
case .breakEven: return "equal.circle.fill"
|
||||||
|
case .notWorthTheDrive: return "xmark.circle.fill"
|
||||||
|
case .baseline: return "location.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Puts a Done button above the keyboard.
|
||||||
|
///
|
||||||
|
/// The numeric fields (Settings → Average fuel economy and Typical
|
||||||
|
/// fill-up, and the Worth the Drive fill-up box in the station list) use
|
||||||
|
/// `.decimalPad`, which has NO Return key. Without this there is no way to
|
||||||
|
/// dismiss the keyboard once such a field is tapped, and a
|
||||||
|
/// `TextField(value:format:)` only commits the typed value when editing
|
||||||
|
/// ends — so the number never stuck either.
|
||||||
|
///
|
||||||
|
/// Apply ONCE per screen, to the list or form containing the fields: the
|
||||||
|
/// keyboard toolbar belongs to the focused field, so a single attachment
|
||||||
|
/// covers every field inside it. Attaching it to each field would show
|
||||||
|
/// several Done buttons at once.
|
||||||
|
///
|
||||||
|
/// - Parameter action: clears the screen's focus state. Prefer this where a
|
||||||
|
/// `@FocusState` exists; the default resigns the current first responder,
|
||||||
|
/// which works for fields inside subviews that have no focus binding.
|
||||||
|
func keyboardDoneButton(action: (() -> Void)? = nil) -> some View {
|
||||||
|
toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
Spacer()
|
||||||
|
Button("Done") {
|
||||||
|
if let action {
|
||||||
|
action()
|
||||||
|
} else {
|
||||||
|
UIApplication.shared.sendAction(
|
||||||
|
#selector(UIResponder.resignFirstResponder),
|
||||||
|
to: nil,
|
||||||
|
from: nil,
|
||||||
|
for: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,16 @@ import WidgetKit
|
|||||||
|
|
||||||
enum RoadDistanceService {
|
enum RoadDistanceService {
|
||||||
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
/// 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
|
/// route calls. Worth-the-Drive now asks for outward AND return routes, so
|
||||||
/// still get real road distances instead of a straight-line fallback.
|
/// this must stay conservative; a larger value can make first launch feel
|
||||||
static let candidatesPerPass = 40
|
/// unresponsive while MapKit drains dozens of route requests.
|
||||||
|
static let candidatesPerPass = 12
|
||||||
|
/// How many Worth-the-Drive candidates may claim slots in a pass. Those
|
||||||
|
/// rows are ranked by NET SAVING and are typically FURTHER out than the
|
||||||
|
/// nearest stations — routing only the nearest stations (the old behaviour)
|
||||||
|
/// meant they never got a measured route and stayed on straight-line
|
||||||
|
/// estimates forever.
|
||||||
|
static let priorityCandidates = 6
|
||||||
/// Only route stations within this straight-line radius (km). Covers the
|
/// 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
|
/// 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.
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
||||||
@@ -31,11 +38,34 @@ enum RoadDistanceService {
|
|||||||
|
|
||||||
/// Refreshes the cached road distances for the in-radius stations around
|
/// Refreshes the cached road distances for the in-radius stations around
|
||||||
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||||
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
///
|
||||||
|
/// - Parameter priorityStations: stations to route BEFORE the nearest ones
|
||||||
|
/// (the Worth-the-Drive candidates and the comparison baseline), so the
|
||||||
|
/// numbers the user actually reads are road-routed rather than estimated.
|
||||||
|
static func refreshIfNeeded(stations: [FuelStation],
|
||||||
|
lat: Double,
|
||||||
|
lng: Double,
|
||||||
|
prioritising priorityStations: [FuelStation] = []) async {
|
||||||
|
guard await refreshGate.begin() else { return }
|
||||||
|
defer { Task { await refreshGate.end() } }
|
||||||
|
|
||||||
guard !stations.isEmpty else { return }
|
guard !stations.isEmpty else { return }
|
||||||
|
|
||||||
|
let existing = FuelStore.loadRoadDistances()
|
||||||
|
// Entries can only be merged when they were routed from (near enough)
|
||||||
|
// this same origin — the read path rejects anything else anyway.
|
||||||
|
let originCompatible = existing.map {
|
||||||
|
haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters
|
||||||
|
} ?? false
|
||||||
|
|
||||||
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||||
if let cache = FuelStore.loadRoadDistances() {
|
// `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook).
|
||||||
|
#if DEBUG
|
||||||
|
let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1"
|
||||||
|
#else
|
||||||
|
let forcePass = false
|
||||||
|
#endif
|
||||||
|
if !forcePass, let cache = existing {
|
||||||
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||||
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||||
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||||
@@ -43,33 +73,55 @@ enum RoadDistanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidate stations: the nearest-by-straight-line subset that the UI
|
// Candidates: Worth-the-Drive priorities first, then the nearest
|
||||||
// could actually display, capped so a pass stays bounded.
|
// stations. Stations that already have a route from this origin are
|
||||||
let nearest = stations
|
// skipped so the per-pass budget goes on the ones still missing one.
|
||||||
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
var candidates: [FuelStation] = []
|
||||||
.prefix(candidatesPerPass)
|
var seen = Set<String>()
|
||||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
func consider(_ station: FuelStation) {
|
||||||
|
guard candidates.count < candidatesPerPass else { return }
|
||||||
|
guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return }
|
||||||
|
guard seen.insert(station.id).inserted else { return }
|
||||||
|
guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return }
|
||||||
|
candidates.append(station)
|
||||||
|
}
|
||||||
|
for station in priorityStations.prefix(priorityCandidates) { consider(station) }
|
||||||
|
let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||||
|
for station in nearest {
|
||||||
|
if candidates.count >= candidatesPerPass { break }
|
||||||
|
consider(station)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !candidates.isEmpty else { return }
|
||||||
|
|
||||||
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||||
var entries: [String: CachedRoadDistance] = [:]
|
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
||||||
for station in nearest {
|
for station in candidates {
|
||||||
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) {
|
async let outward = roadMeters(from: origin, to: dest)
|
||||||
|
async let back = roadMeters(from: dest, to: origin)
|
||||||
|
if let meters = await outward {
|
||||||
// Store the exact pin that was routed so the display layer can
|
// Store the exact pin that was routed so the display layer can
|
||||||
// refuse to serve this value if the station later appears with
|
// refuse to serve this value if the station later appears with
|
||||||
// a different coordinate (corrected pin / other data source).
|
// a different coordinate (corrected pin / other data source).
|
||||||
|
let returnMeters = await back
|
||||||
entries[station.id] = CachedRoadDistance(meters: meters,
|
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||||
lat: station.lat,
|
lat: station.lat,
|
||||||
lng: station.lng)
|
lng: station.lng,
|
||||||
|
returnMeters: returnMeters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard !entries.isEmpty else { return }
|
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
||||||
// Wake the widgets so the new road distances surface immediately.
|
// Wake the widgets so the new road distances surface immediately.
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let refreshGate = RoadDistanceRefreshGate()
|
||||||
|
|
||||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||||
let request = MKDirections.Request()
|
let request = MKDirections.Request()
|
||||||
@@ -96,3 +148,17 @@ enum RoadDistanceService {
|
|||||||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private actor RoadDistanceRefreshGate {
|
||||||
|
private var isRefreshing = false
|
||||||
|
|
||||||
|
func begin() -> Bool {
|
||||||
|
guard !isRefreshing else { return false }
|
||||||
|
isRefreshing = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct SettingsView: View {
|
|||||||
@Binding var distanceUnit: DistanceUnit
|
@Binding var distanceUnit: DistanceUnit
|
||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
@Binding var stationTapAction: StationTapAction
|
@Binding var stationTapAction: StationTapAction
|
||||||
|
@Binding var fuelSavingsSettings: FuelSavingsSettings
|
||||||
@Binding var dataRefreshMode: DataRefreshMode
|
@Binding var dataRefreshMode: DataRefreshMode
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
/// tab) so the test notification matches what real alerts will say.
|
/// tab) so the test notification matches what real alerts will say.
|
||||||
@@ -70,6 +71,16 @@ struct SettingsView: View {
|
|||||||
@State private var mediumWidgetDiag: String?
|
@State private var mediumWidgetDiag: String?
|
||||||
@State private var installedWidgets: String = ""
|
@State private var installedWidgets: String = ""
|
||||||
|
|
||||||
|
/// Which numeric field in the Fuel Savings section is being edited. Those
|
||||||
|
/// fields use `.decimalPad`, which has no Return key, so the keyboard's
|
||||||
|
/// Done button clears this to dismiss AND commit the typed value.
|
||||||
|
@FocusState private var focusedSavingsField: SavingsField?
|
||||||
|
|
||||||
|
private enum SavingsField: Hashable {
|
||||||
|
case averageMPG
|
||||||
|
case fillAmount
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshWidgetDiag() {
|
private func refreshWidgetDiag() {
|
||||||
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
|
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
|
||||||
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
|
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
|
||||||
@@ -137,6 +148,45 @@ struct SettingsView: View {
|
|||||||
Text("Tapping a station either opens directions straight away or shows its details first.")
|
Text("Tapping a station either opens directions straight away or shows its details first.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Enable Worth the Drive", isOn: fuelSavingsEnabledBinding)
|
||||||
|
HStack {
|
||||||
|
Text("Average fuel economy")
|
||||||
|
Spacer()
|
||||||
|
TextField("45", value: fuelSavingsMPGBinding, format: .number.precision(.fractionLength(0...1)))
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 80)
|
||||||
|
.focused($focusedSavingsField, equals: .averageMPG)
|
||||||
|
Text("MPG")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
||||||
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||||
|
Text(unit.displayName).tag(unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
HStack {
|
||||||
|
Text("Typical fill-up")
|
||||||
|
Spacer()
|
||||||
|
TextField("40", value: fuelSavingsAmountBinding, format: .number.precision(.fractionLength(0...1)))
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 90)
|
||||||
|
.focused($focusedSavingsField, equals: .fillAmount)
|
||||||
|
}
|
||||||
|
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
||||||
|
Text("Enter a realistic UK MPG and fill-up amount before Worth the Drive appears in sorting.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Fuel Savings")
|
||||||
|
} footer: {
|
||||||
|
Text("Worth the Drive estimates fuel cost only, using UK imperial MPG, your typical purchase amount, fuel prices and Apple Maps road distances. Real-world average MPG is better than manufacturer figures; the fill-up amount is your usual purchase, not tank capacity.")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Data checking", selection: $dataRefreshMode) {
|
Picker("Data checking", selection: $dataRefreshMode) {
|
||||||
ForEach(DataRefreshMode.allCases) { mode in
|
ForEach(DataRefreshMode.allCases) { mode in
|
||||||
@@ -446,6 +496,9 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
||||||
|
.keyboardDoneButton { focusedSavingsField = nil }
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await tipStore.load() }
|
Task { await tipStore.load() }
|
||||||
@@ -565,6 +618,47 @@ struct SettingsView: View {
|
|||||||
return "\(version) (\(build))"
|
return "\(version) (\(build))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { fuelSavingsSettings.enabled },
|
||||||
|
set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fuelSavingsMPGBinding: Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: { fuelSavingsSettings.mpg },
|
||||||
|
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fuelSavingsAmountBinding: Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: { fuelSavingsSettings.preferredAmount.value },
|
||||||
|
set: {
|
||||||
|
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
||||||
|
persistFuelSavingsSettings()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
||||||
|
Binding(
|
||||||
|
get: { fuelSavingsSettings.preferredAmount.unit },
|
||||||
|
set: { newUnit in
|
||||||
|
fuelSavingsSettings.preferredAmount.unit = newUnit
|
||||||
|
if !fuelSavingsSettings.preferredAmount.isValid {
|
||||||
|
fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30
|
||||||
|
}
|
||||||
|
persistFuelSavingsSettings()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistFuelSavingsSettings() {
|
||||||
|
FuelStore.saveFuelSavingsSettings(fuelSavingsSettings)
|
||||||
|
}
|
||||||
|
|
||||||
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
||||||
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
||||||
private var connectionText: String {
|
private var connectionText: String {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ struct StationDetailView: View {
|
|||||||
let station: FuelStation
|
let station: FuelStation
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
|
var fuelSavings: FuelSavingsAvailability? = nil
|
||||||
|
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||||
|
var savingsBaselineStationName: String? = nil
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showingReportIssueConfirmation = false
|
@State private var showingReportIssueConfirmation = false
|
||||||
|
|
||||||
@@ -54,6 +57,16 @@ struct StationDetailView: View {
|
|||||||
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Section {
|
||||||
|
FuelSavingsSummaryView(
|
||||||
|
availability: fuelSavings,
|
||||||
|
amount: fuelSavingsAmount,
|
||||||
|
baselineStationName: savingsBaselineStationName
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
Text("Worth the drive?")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
if let url = station.mapsDirectionsURL {
|
if let url = station.mapsDirectionsURL {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ struct StationsView: View {
|
|||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
let stationTapAction: StationTapAction
|
let stationTapAction: StationTapAction
|
||||||
|
let fuelSavingsSettings: FuelSavingsSettings
|
||||||
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||||
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
|
var savingsBaselineStationName: String?
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
@@ -41,7 +45,12 @@ struct StationsView: View {
|
|||||||
let miles = distanceUnit.displayMiles(stationLimit)
|
let miles = distanceUnit.displayMiles(stationLimit)
|
||||||
let unit = distanceUnit.label(for: Double(miles))
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
let fuel = selectedFuel.displayName
|
let fuel = selectedFuel.displayName
|
||||||
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
let mode: String
|
||||||
|
switch sortMode {
|
||||||
|
case .closest: mode = "Closest"
|
||||||
|
case .cheapest: mode = "Cheapest"
|
||||||
|
case .worthTheDrive: mode = "Worth the Drive"
|
||||||
|
}
|
||||||
let tapHint = stationTapAction == .openMap
|
let tapHint = stationTapAction == .openMap
|
||||||
? "tap a station for directions."
|
? "tap a station for directions."
|
||||||
: "tap a station for details."
|
: "tap a station for details."
|
||||||
@@ -92,7 +101,7 @@ struct StationsView: View {
|
|||||||
|
|
||||||
Section("Stations") {
|
Section("Stations") {
|
||||||
Picker("Sort by", selection: $sortMode) {
|
Picker("Sort by", selection: $sortMode) {
|
||||||
ForEach(SortMode.allCases) { mode in
|
ForEach(availableSortModes) { mode in
|
||||||
Text(mode.displayName).tag(mode)
|
Text(mode.displayName).tag(mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +111,17 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
|
|
||||||
if isLoading {
|
// Only offered while the feature is actually configured — a
|
||||||
|
// stale stored sort mode must not keep the control on screen
|
||||||
|
// for a switched-off feature.
|
||||||
|
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
||||||
|
FuelSavingsAdjustmentView(
|
||||||
|
amount: $fuelSavingsAmount,
|
||||||
|
referencePricePencePerLitre: baselinePrice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoading && stations.isEmpty {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("Fetching prices…")
|
Text("Fetching prices…")
|
||||||
@@ -133,7 +152,9 @@ struct StationsView: View {
|
|||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
isTopResult: station.id == topStationID,
|
isTopResult: station.id == topStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
|
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
|
||||||
tapAction: stationTapAction,
|
tapAction: stationTapAction,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||||
onShowDetails: { detailStation = $0 }
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
@@ -180,6 +201,11 @@ struct StationsView: View {
|
|||||||
// large title at rest, scrolls it away and fades the compact
|
// large title at rest, scrolls it away and fades the compact
|
||||||
// inline title in. No custom toolbar needed — the native mechanism
|
// inline title in. No custom toolbar needed — the native mechanism
|
||||||
// only renders text, which is exactly what we want here.
|
// only renders text, which is exactly what we want here.
|
||||||
|
// The Worth the Drive fill-up box uses `.decimalPad`, which has no
|
||||||
|
// Return key — give the keyboard a Done button and let a scroll
|
||||||
|
// dismiss it too.
|
||||||
|
.keyboardDoneButton()
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.navigationTitle("FuelBoard")
|
.navigationTitle("FuelBoard")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -273,12 +299,23 @@ struct StationsView: View {
|
|||||||
StationDetailView(
|
StationDetailView(
|
||||||
station: station,
|
station: station,
|
||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit
|
distanceUnit: distanceUnit,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsBaselineStationName: savingsBaselineStationName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var availableSortModes: [SortMode] {
|
||||||
|
var modes: [SortMode] = [.closest, .cheapest]
|
||||||
|
if fuelSavingsSettings.isConfigured {
|
||||||
|
modes.append(.worthTheDrive)
|
||||||
|
}
|
||||||
|
return modes
|
||||||
|
}
|
||||||
|
|
||||||
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
||||||
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
||||||
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
|
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/FuelSavings.swift
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Ranking rules for the Worth the Drive sort.
|
||||||
|
///
|
||||||
|
/// Regression: the sort used to rank only a shortlist of the nearest stations
|
||||||
|
/// whose ROAD ROUTES had been measured, and everything else was listed as
|
||||||
|
/// unavailable. Cheaper stations a few miles further out — the exact stations
|
||||||
|
/// the sort exists to surface — were dropped from the top of the list, and
|
||||||
|
/// stations on later pages showed no savings at all.
|
||||||
|
final class FuelSavingsEstimateTests: XCTestCase {
|
||||||
|
private let settings = FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: 45,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
)
|
||||||
|
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
|
private func station(_ id: String,
|
||||||
|
price: Double?,
|
||||||
|
lat: Double = 51.5,
|
||||||
|
lng: Double = -0.1) -> FuelStation {
|
||||||
|
FuelStation(
|
||||||
|
id: id,
|
||||||
|
name: id,
|
||||||
|
brand: "X",
|
||||||
|
address: "",
|
||||||
|
postcode: "",
|
||||||
|
lat: lat,
|
||||||
|
lng: lng,
|
||||||
|
prices: price.map { [FuelType.e10: $0] } ?? [:],
|
||||||
|
priceUpdated: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Straight-line out-and-back estimate, as the app builds it when no road
|
||||||
|
/// route has been measured yet.
|
||||||
|
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
|
||||||
|
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
||||||
|
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
||||||
|
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("cheaper-3.5", price: 138.9, lat: 51.55)
|
||||||
|
let filler = (0..<14).map { station("filler-\($0)", price: 150 + Double($0)) }
|
||||||
|
let pool = [baseline, candidate] + filler
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
pool,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journeyMiles: { s in
|
||||||
|
switch s.id {
|
||||||
|
case "baseline": return self.straightLine(2.0)
|
||||||
|
case "cheaper-3.5": return self.straightLine(7.0)
|
||||||
|
default: return self.straightLine(20.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.count, pool.count)
|
||||||
|
XCTAssertEqual(ranked.first?.id, "cheaper-3.5",
|
||||||
|
"a cheaper station 3.5 miles away must rank above the nearest station")
|
||||||
|
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(7.0),
|
||||||
|
baselineJourney: straightLine(2.0)
|
||||||
|
)?.result
|
||||||
|
|
||||||
|
XCTAssertNotNil(result, "the station must show a saving, not an unavailable state")
|
||||||
|
XCTAssertGreaterThan(result?.netSavingGBP ?? -1, 0)
|
||||||
|
XCTAssertEqual(result?.status, .worthTheDrive)
|
||||||
|
XCTAssertEqual(result?.distanceBasis, .straightLine,
|
||||||
|
"unmeasured routes are reported as straight-line estimates")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
||||||
|
// 40 stations, but only the first one has a measured road route — the
|
||||||
|
// old implementation capped the whole ranking to 12 shortlisted
|
||||||
|
// stations and returned "Outside route shortlist" for the rest.
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let pool = [baseline] + (0..<39).map { station("s\($0)", price: 135 + Double($0)) }
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
pool,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journeyMiles: { s in
|
||||||
|
s.id == "baseline"
|
||||||
|
? FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
: self.straightLine(2.0 + (Double(s.id.dropFirst()) ?? 6))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.count, pool.count)
|
||||||
|
|
||||||
|
for station in pool where station.id != "baseline" {
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: station,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(10.0),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(availability?.result,
|
||||||
|
"\(station.id) should carry an estimated saving, not an unavailable state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMeasuredRoadRoutesAreReportedAsRoadRouteBasis() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("candidate", price: 138.9)
|
||||||
|
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: FuelSavingsJourney(miles: 7.0, basis: .roadRoute),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
)?.result
|
||||||
|
|
||||||
|
XCTAssertEqual(result?.distanceBasis, .roadRoute)
|
||||||
|
XCTAssertFalse(FuelSavingsDistanceBasis.roadRoute.isEstimate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMixedBasisIsReportedAsAnEstimate() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("candidate", price: 138.9)
|
||||||
|
|
||||||
|
// Baseline measured, candidate not: the comparison is only as good as
|
||||||
|
// its weakest leg, so it must be labelled an estimate.
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(7.0),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
)?.result
|
||||||
|
|
||||||
|
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationWithoutAPriceIsUnavailable() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let noPrice = station("no-price", price: nil)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: noPrice,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(4.0),
|
||||||
|
baselineJourney: straightLine(2.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability, .unavailable("No price"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFeatureOffProducesNoSavingsRow() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("candidate", price: 138.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: .defaults,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(4.0),
|
||||||
|
baselineJourney: straightLine(2.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Required wording and baseline labelling
|
||||||
|
|
||||||
|
private var briefSettings: FuelSavingsSettings {
|
||||||
|
FuelSavingsSettings(enabled: true,
|
||||||
|
mpg: 30,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var briefAmount: FuelSavingsFillAmount {
|
||||||
|
FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scenario from the brief: 30 MPG, £60 fill, baseline Rontec Shelf
|
||||||
|
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
||||||
|
///
|
||||||
|
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
||||||
|
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
|
||||||
|
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
|
||||||
|
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
|
||||||
|
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
||||||
|
let baseline = station("rontec-shelf", price: 171.9)
|
||||||
|
let candidate = station("shell-whetley-hill", price: 167.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(7.6), // 3.8 mi out and back
|
||||||
|
baselineJourney: straightLine(0.8) // 0.4 mi out and back
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let result = availability?.result else {
|
||||||
|
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
|
||||||
|
"the £60 budget buys litres at the BASELINE pump price")
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
||||||
|
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
|
XCTAssertEqual(result.headline(compact: true), "£0.33 worse off after travel (est.)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
||||||
|
let baseline = station("baseline", price: 171.9)
|
||||||
|
let candidate = station("candidate", price: 167.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: FuelSavingsJourney(miles: 7.6, basis: .roadRoute),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 0.8, basis: .roadRoute)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
||||||
|
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
|
||||||
|
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBaselineRowIsLabelledClosestStation() {
|
||||||
|
let baseline = station("nearest", price: 171.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: baseline,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(0.8),
|
||||||
|
baselineJourney: straightLine(0.8)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability?.result?.status, .baseline)
|
||||||
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
|
||||||
|
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
|
||||||
|
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||||||
|
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPositiveResultHeadlineSaysSaveAfterTravel() {
|
||||||
|
// 4p cheaper AND almost the same distance, so the saving survives travel.
|
||||||
|
let baseline = station("baseline", price: 160.0)
|
||||||
|
let candidate = station("candidate", price: 150.0)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(6.4),
|
||||||
|
baselineJourney: straightLine(6.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let result = availability?.result else {
|
||||||
|
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
|
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
|
||||||
|
// return miles cost ~£0.09. Net ≈ £3.66.
|
||||||
|
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.headline(compact: true), "Save £3.66 after travel (est.)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeutralResultHeadlineIsBreakEven() {
|
||||||
|
// 0.1p cheaper over the same distance: inside the break-even tolerance.
|
||||||
|
let baseline = station("baseline", price: 150.0)
|
||||||
|
let candidate = station("candidate", price: 149.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(6.0),
|
||||||
|
baselineJourney: straightLine(6.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability?.result?.status, .breakEven)
|
||||||
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Break even")
|
||||||
|
XCTAssertEqual(availability?.result?.headline(), "Break even")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact strings the brief asks for.
|
||||||
|
func testHeadlineWordingMatchesTheBrief() {
|
||||||
|
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
|
||||||
|
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||||||
|
|
||||||
|
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 0.88,
|
||||||
|
extraDistanceMiles: 3.5,
|
||||||
|
netSavingGBP: 0.52,
|
||||||
|
status: .worthTheDrive,
|
||||||
|
distanceBasis: .roadRoute)
|
||||||
|
XCTAssertEqual(saving.headline(), "Save £0.52 after travel")
|
||||||
|
|
||||||
|
let loss = FuelSavingsResult(fillLitres: 34.9,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 1.71,
|
||||||
|
extraDistanceMiles: 6.8,
|
||||||
|
netSavingGBP: -0.31,
|
||||||
|
status: .notWorthTheDrive,
|
||||||
|
distanceBasis: .roadRoute)
|
||||||
|
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sort must order by NET saving, not pump price: a pricier station can
|
||||||
|
/// win when the cheaper one is far enough away.
|
||||||
|
func testSortingRanksByNetSavingNotPumpPrice() {
|
||||||
|
let baseline = station("baseline", price: 171.9)
|
||||||
|
let cheapButFar = station("cheap-but-far", price: 167.9)
|
||||||
|
let pricierButNear = station("pricier-but-near", price: 169.9)
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
[baseline, cheapButFar, pricierButNear],
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journeyMiles: { s in
|
||||||
|
switch s.id {
|
||||||
|
case "baseline": return self.straightLine(0.8)
|
||||||
|
case "cheap-but-far": return self.straightLine(7.6)
|
||||||
|
default: return self.straightLine(1.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
||||||
|
"net saving decides the order, not the pump price")
|
||||||
|
XCTAssertEqual(ranked.last?.id, "cheap-but-far")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class FuelSavingsRoutingCacheTests: XCTestCase {
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
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: [.e10: 140], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJourneyDistanceUsesSeparateOutAndBackRoadRoutes() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(
|
||||||
|
sourceLat: 51.6,
|
||||||
|
sourceLng: -0.1,
|
||||||
|
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278, returnMeters: 3_700)]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_900)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacyOneWayCacheDoublesForJourneyDistance() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(
|
||||||
|
sourceLat: 51.6,
|
||||||
|
sourceLng: -0.1,
|
||||||
|
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278)]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class FuelSavingsCalculatorTests: XCTestCase {
|
||||||
|
func testUKMPGConversionUsesImperialGallons() {
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
|
||||||
|
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLitresAmountPassesThrough() throws {
|
||||||
|
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
|
||||||
|
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(litres, 35, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNetSavingSubtractsAdditionalDrivingFuelCost() throws {
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 160,
|
||||||
|
candidatePricePencePerLitre: 150,
|
||||||
|
baselineJourneyMiles: 2,
|
||||||
|
candidateJourneyMiles: 8
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNegativeAndBreakEvenOutcomes() throws {
|
||||||
|
let negative = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
|
||||||
|
baselinePricePencePerLitre: 150,
|
||||||
|
candidatePricePencePerLitre: 149,
|
||||||
|
baselineJourneyMiles: 1,
|
||||||
|
candidateJourneyMiles: 20
|
||||||
|
)
|
||||||
|
XCTAssertEqual(negative.status, .notWorthTheDrive)
|
||||||
|
XCTAssertLessThan(negative.netSavingGBP, 0)
|
||||||
|
|
||||||
|
let even = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
|
||||||
|
baselinePricePencePerLitre: 150,
|
||||||
|
candidatePricePencePerLitre: 150,
|
||||||
|
baselineJourneyMiles: 2,
|
||||||
|
candidateJourneyMiles: 2
|
||||||
|
)
|
||||||
|
XCTAssertEqual(even.status, .breakEven)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidInputsRejected() {
|
||||||
|
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0))
|
||||||
|
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||||
|
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSwitchingPoundsToLitresPreservesPhysicalQuantity() throws {
|
||||||
|
let pounds = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
let litres = try FuelSavingsCalculator.convert(amount: pounds, to: .litres, referencePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(litres.unit, .litres)
|
||||||
|
XCTAssertEqual(litres.value, 25, accuracy: 0.0001)
|
||||||
|
let back = try FuelSavingsCalculator.convert(amount: litres, to: .pounds, referencePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(back.value, 40, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSortingPlacesUnavailableAfterSavingsAndBreaksTies() {
|
||||||
|
let a = FuelSavingsRankedStation(stationID: "a", result: .available(.init(fillLitres: 25, grossSavingGBP: 2, extraDrivingCostGBP: 1, extraDistanceMiles: 3, netSavingGBP: 1, status: .worthTheDrive)), distanceMiles: 5, pricePencePerLitre: 150)
|
||||||
|
let b = FuelSavingsRankedStation(stationID: "b", result: .available(.init(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)), distanceMiles: 8, pricePencePerLitre: 149)
|
||||||
|
let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140)
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FuelSavingsSettingsTests: XCTestCase {
|
||||||
|
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
|
||||||
|
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
|
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
|
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 0, unit: .pounds)).isConfigured)
|
||||||
|
XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// Shared payload storage must survive an install whose provisioning profile
|
||||||
|
/// has NO `com.apple.security.application-groups` entitlement (free/personal
|
||||||
|
/// team and SideStore installs) — the exact situation this test runner is in.
|
||||||
|
///
|
||||||
|
/// Regression guard: `appGroupFileURL` used to return nil without a group
|
||||||
|
/// container, so every save silently failed and every load returned empty. That
|
||||||
|
/// left `loadStations()` permanently empty (letting the freshness gate skip the
|
||||||
|
/// fetch for ever, i.e. a blank station list) and re-downloaded the ~2.8 MB
|
||||||
|
/// dump on every refresh. Storage now falls back to the app's own container.
|
||||||
|
final class FuelStoreFileCacheTests: XCTestCase {
|
||||||
|
|
||||||
|
func testSharedFileURLExistsWithoutAppGroupContainer() {
|
||||||
|
XCTAssertNotNil(
|
||||||
|
FuelStore.appGroupFileURL("fuelboard.test.probe.json"),
|
||||||
|
"storage must resolve to a writable location even when the App Group container is unprovisioned"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSharedFileRoundTripsWithoutAppGroupContainer() throws {
|
||||||
|
let name = "fuelboard.test.roundtrip.json"
|
||||||
|
let payload = Data("hello-cache".utf8)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
FuelStore.saveAppGroupFile(payload, name: name),
|
||||||
|
"save must succeed without an App Group container"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(FuelStore.loadAppGroupFile(name), payload)
|
||||||
|
|
||||||
|
if let url = FuelStore.appGroupFileURL(name) {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dump cache is what stops a ~2.8 MB re-download on every refresh, so
|
||||||
|
/// it must round-trip too — not just the station list.
|
||||||
|
func testDumpCacheRoundTripsWithoutAppGroupContainer() {
|
||||||
|
let day = "2026-01-01"
|
||||||
|
let data = Data(repeating: 7, count: 4096)
|
||||||
|
|
||||||
|
MirrorFuelProvider.saveDumpCache(day: day, data: data)
|
||||||
|
let cache = MirrorFuelProvider.loadDumpCache()
|
||||||
|
|
||||||
|
XCTAssertEqual(cache?.day, day, "cached dump day must survive a round-trip")
|
||||||
|
XCTAssertEqual(cache?.data, data, "cached dump payload must survive a round-trip")
|
||||||
|
|
||||||
|
if let url = FuelStore.appGroupFileURL(MirrorFuelProvider.liveDumpCacheFileName) {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The road-distance cache is memoized in memory because re-reading it per
|
||||||
|
/// station (it lives in the keychain) stalled the main thread past the iOS
|
||||||
|
/// launch watchdog. A save must therefore invalidate the memo — a stale
|
||||||
|
/// in-memory cache would show wrong distances after a route refresh.
|
||||||
|
func testRoadDistanceMemoRefreshesAfterSave() {
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.0, sourceLng: -1.0, entries: [:])
|
||||||
|
XCTAssertEqual(FuelStore.loadRoadDistances()?.sourceLat, 51.0)
|
||||||
|
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 52.0, sourceLng: -2.0, entries: [:])
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelStore.loadRoadDistances()?.sourceLat, 52.0,
|
||||||
|
"load must reflect the newest save, not a stale memoized cache"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,8 +242,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
||||||
}
|
}
|
||||||
case .cheapest:
|
case .cheapest, .worthTheDrive:
|
||||||
// Cheapest first; distance only breaks ties.
|
// Cheapest first; distance only breaks ties. Worth-the-Drive is app-only;
|
||||||
|
// widgets fall back to cheapest ordering if a stored config ever carries it.
|
||||||
ordered = filtered.sorted { lhs, rhs in
|
ordered = filtered.sorted { lhs, rhs in
|
||||||
let lPrice = lhs.prices[fuel]!
|
let lPrice = lhs.prices[fuel]!
|
||||||
let rPrice = rhs.prices[fuel]!
|
let rPrice = rhs.prices[fuel]!
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FuelSavingsFillUnit: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case pounds
|
||||||
|
case litres
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .pounds: return "£"
|
||||||
|
case .litres: return "Litres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsFillAmount: Codable, Equatable {
|
||||||
|
var value: Double
|
||||||
|
var unit: FuelSavingsFillUnit
|
||||||
|
|
||||||
|
var isValid: Bool {
|
||||||
|
value.isFinite && value > 0 && value <= FuelSavingsCalculator.maximumFillAmount(for: unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsSettings: Codable, Equatable {
|
||||||
|
var enabled: Bool
|
||||||
|
var mpg: Double
|
||||||
|
var preferredAmount: FuelSavingsFillAmount
|
||||||
|
|
||||||
|
static let defaultMPG: Double = 45
|
||||||
|
static let defaultPreferredAmount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
|
static var defaults: FuelSavingsSettings {
|
||||||
|
FuelSavingsSettings(enabled: false, mpg: defaultMPG, preferredAmount: defaultPreferredAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isConfigured: Bool {
|
||||||
|
enabled && FuelSavingsCalculator.isValidMPG(mpg) && preferredAmount.isValid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsStatus: String, Codable, Equatable {
|
||||||
|
case worthTheDrive
|
||||||
|
case breakEven
|
||||||
|
case notWorthTheDrive
|
||||||
|
/// The comparison baseline itself — the station every other saving is
|
||||||
|
/// measured against. Shown as "Closest station", never "Break even".
|
||||||
|
case baseline
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .worthTheDrive: return "Worth the drive"
|
||||||
|
case .breakEven: return "Break even"
|
||||||
|
case .notWorthTheDrive: return "Not worth the drive"
|
||||||
|
case .baseline: return FuelSavingsResult.baselineHeadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the journey distances behind an estimate were obtained.
|
||||||
|
enum FuelSavingsDistanceBasis: String, Codable, Equatable {
|
||||||
|
/// Measured road route, out and back, from the routing service.
|
||||||
|
case roadRoute
|
||||||
|
/// Straight-line out-and-back estimate, used until a route is measured.
|
||||||
|
case straightLine
|
||||||
|
|
||||||
|
var isEstimate: Bool { self == .straightLine }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsResult: Codable, Equatable {
|
||||||
|
let fillLitres: Double
|
||||||
|
let grossSavingGBP: Double
|
||||||
|
let extraDrivingCostGBP: Double
|
||||||
|
let extraDistanceMiles: Double
|
||||||
|
let netSavingGBP: Double
|
||||||
|
let status: FuelSavingsStatus
|
||||||
|
let distanceBasis: FuelSavingsDistanceBasis
|
||||||
|
|
||||||
|
init(fillLitres: Double,
|
||||||
|
grossSavingGBP: Double,
|
||||||
|
extraDrivingCostGBP: Double,
|
||||||
|
extraDistanceMiles: Double,
|
||||||
|
netSavingGBP: Double,
|
||||||
|
status: FuelSavingsStatus,
|
||||||
|
distanceBasis: FuelSavingsDistanceBasis = .roadRoute) {
|
||||||
|
self.fillLitres = fillLitres
|
||||||
|
self.grossSavingGBP = grossSavingGBP
|
||||||
|
self.extraDrivingCostGBP = extraDrivingCostGBP
|
||||||
|
self.extraDistanceMiles = extraDistanceMiles
|
||||||
|
self.netSavingGBP = netSavingGBP
|
||||||
|
self.status = status
|
||||||
|
self.distanceBasis = distanceBasis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Money formatting for savings strings, in one place so the exact wording the
|
||||||
|
/// UI shows is testable.
|
||||||
|
enum FuelSavingsFormat {
|
||||||
|
static func money(_ value: Double) -> String {
|
||||||
|
String(format: "£%.2f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FuelSavingsResult {
|
||||||
|
/// Baseline row label — required wording.
|
||||||
|
static let baselineHeadline = "Closest station"
|
||||||
|
/// Neutral result label — required wording.
|
||||||
|
static let breakEvenHeadline = "Break even"
|
||||||
|
|
||||||
|
/// The same estimate relabelled as the comparison baseline: nothing to save
|
||||||
|
/// or lose against itself, and no extra travel.
|
||||||
|
func asBaseline() -> FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: fillLitres,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: 0,
|
||||||
|
status: .baseline,
|
||||||
|
distanceBasis: distanceBasis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row headline, e.g. "Save £0.52 after travel", "£0.31 worse off after
|
||||||
|
/// travel", "Break even", "Closest station".
|
||||||
|
///
|
||||||
|
/// `compact` (list rows) also marks a straight-line estimate with "(est.)";
|
||||||
|
/// the detail view spells that out in full instead.
|
||||||
|
func headline(compact: Bool = false) -> String {
|
||||||
|
let estimate = (compact && distanceBasis.isEstimate) ? " (est.)" : ""
|
||||||
|
switch status {
|
||||||
|
case .baseline:
|
||||||
|
return Self.baselineHeadline
|
||||||
|
case .breakEven:
|
||||||
|
return Self.breakEvenHeadline
|
||||||
|
case .worthTheDrive:
|
||||||
|
return "Save \(FuelSavingsFormat.money(netSavingGBP)) after travel\(estimate)"
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return "\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel\(estimate)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One station's journey distance, plus how it was obtained.
|
||||||
|
struct FuelSavingsJourney: Equatable {
|
||||||
|
let miles: Double
|
||||||
|
let basis: FuelSavingsDistanceBasis
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsAvailability: Equatable {
|
||||||
|
case available(FuelSavingsResult)
|
||||||
|
case unavailable(String)
|
||||||
|
|
||||||
|
var result: FuelSavingsResult? {
|
||||||
|
if case .available(let result) = self { return result }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsRankedStation: Equatable {
|
||||||
|
let stationID: String
|
||||||
|
let result: FuelSavingsAvailability
|
||||||
|
let distanceMiles: Double
|
||||||
|
let pricePencePerLitre: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsError: Error, Equatable {
|
||||||
|
case invalidMPG
|
||||||
|
case invalidAmount
|
||||||
|
case invalidPrice
|
||||||
|
case invalidDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsCalculator {
|
||||||
|
static let imperialGallonLitres = 4.54609
|
||||||
|
static let breakEvenToleranceGBP = 0.05
|
||||||
|
static let minimumMPG: Double = 5
|
||||||
|
static let maximumMPG: Double = 200
|
||||||
|
|
||||||
|
static func maximumFillAmount(for unit: FuelSavingsFillUnit) -> Double {
|
||||||
|
switch unit {
|
||||||
|
case .pounds: return 500
|
||||||
|
case .litres: return 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isValidMPG(_ mpg: Double) -> Bool {
|
||||||
|
mpg.isFinite && mpg >= minimumMPG && mpg <= maximumMPG
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fuelUsedLitres(distanceMiles: Double, mpg: Double) -> Double {
|
||||||
|
distanceMiles / mpg * imperialGallonLitres
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double {
|
||||||
|
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||||||
|
switch amount.unit {
|
||||||
|
case .litres:
|
||||||
|
return amount.value
|
||||||
|
case .pounds:
|
||||||
|
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
|
||||||
|
throw FuelSavingsError.invalidPrice
|
||||||
|
}
|
||||||
|
return amount.value / (baselinePricePencePerLitre / 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func convert(amount: FuelSavingsFillAmount,
|
||||||
|
to unit: FuelSavingsFillUnit,
|
||||||
|
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
|
||||||
|
guard referencePricePencePerLitre.isFinite, referencePricePencePerLitre > 0 else {
|
||||||
|
throw FuelSavingsError.invalidPrice
|
||||||
|
}
|
||||||
|
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||||||
|
guard amount.unit != unit else { return amount }
|
||||||
|
switch (amount.unit, unit) {
|
||||||
|
case (.pounds, .litres):
|
||||||
|
return FuelSavingsFillAmount(
|
||||||
|
value: amount.value / (referencePricePencePerLitre / 100.0),
|
||||||
|
unit: .litres
|
||||||
|
)
|
||||||
|
case (.litres, .pounds):
|
||||||
|
return FuelSavingsFillAmount(
|
||||||
|
value: amount.value * (referencePricePencePerLitre / 100.0),
|
||||||
|
unit: .pounds
|
||||||
|
)
|
||||||
|
case (.pounds, .pounds), (.litres, .litres):
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func calculate(mpg: Double,
|
||||||
|
amount: FuelSavingsFillAmount,
|
||||||
|
baselinePricePencePerLitre: Double,
|
||||||
|
candidatePricePencePerLitre: Double,
|
||||||
|
baselineJourneyMiles: Double,
|
||||||
|
candidateJourneyMiles: Double,
|
||||||
|
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
|
||||||
|
breakEvenToleranceGBP: Double = Self.breakEvenToleranceGBP) throws -> FuelSavingsResult {
|
||||||
|
guard isValidMPG(mpg) else { throw FuelSavingsError.invalidMPG }
|
||||||
|
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0,
|
||||||
|
candidatePricePencePerLitre.isFinite, candidatePricePencePerLitre > 0 else {
|
||||||
|
throw FuelSavingsError.invalidPrice
|
||||||
|
}
|
||||||
|
guard baselineJourneyMiles.isFinite, candidateJourneyMiles.isFinite,
|
||||||
|
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
|
||||||
|
throw FuelSavingsError.invalidDistance
|
||||||
|
}
|
||||||
|
let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre)
|
||||||
|
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
|
||||||
|
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
||||||
|
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
||||||
|
let extraDistance = max(0, candidateJourneyMiles - baselineJourneyMiles)
|
||||||
|
let extraFuelLitres = fuelUsedLitres(distanceMiles: extraDistance, mpg: mpg)
|
||||||
|
let extraFuelCost = extraFuelLitres * candidatePriceGBP
|
||||||
|
let netSaving = grossSaving - extraFuelCost
|
||||||
|
let status: FuelSavingsStatus
|
||||||
|
if netSaving > breakEvenToleranceGBP {
|
||||||
|
status = .worthTheDrive
|
||||||
|
} else if netSaving < -breakEvenToleranceGBP {
|
||||||
|
status = .notWorthTheDrive
|
||||||
|
} else {
|
||||||
|
status = .breakEven
|
||||||
|
}
|
||||||
|
return FuelSavingsResult(
|
||||||
|
fillLitres: fillLitres,
|
||||||
|
grossSavingGBP: grossSaving,
|
||||||
|
extraDrivingCostGBP: extraFuelCost,
|
||||||
|
extraDistanceMiles: extraDistance,
|
||||||
|
netSavingGBP: netSaving,
|
||||||
|
status: status,
|
||||||
|
distanceBasis: distanceBasis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sortedForWorthTheDrive(_ stations: [FuelSavingsRankedStation]) -> [FuelSavingsRankedStation] {
|
||||||
|
stations.sorted { lhs, rhs in
|
||||||
|
switch (lhs.result.result, rhs.result.result) {
|
||||||
|
case let (l?, r?):
|
||||||
|
if l.netSavingGBP != r.netSavingGBP { return l.netSavingGBP > r.netSavingGBP }
|
||||||
|
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
|
||||||
|
if lhs.pricePencePerLitre != rhs.pricePencePerLitre { return lhs.pricePencePerLitre < rhs.pricePencePerLitre }
|
||||||
|
return lhs.stationID < rhs.stationID
|
||||||
|
case (_?, nil):
|
||||||
|
return true
|
||||||
|
case (nil, _?):
|
||||||
|
return false
|
||||||
|
case (nil, nil):
|
||||||
|
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
|
||||||
|
return lhs.stationID < rhs.stationID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns stations into savings estimates.
|
||||||
|
///
|
||||||
|
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
|
||||||
|
/// rules are unit tested directly.
|
||||||
|
enum FuelSavingsEstimator {
|
||||||
|
/// Savings for a single station against `baseline`, or nil when there is
|
||||||
|
/// nothing to show at all (feature off).
|
||||||
|
static func availability(station: FuelStation,
|
||||||
|
fuel: FuelType,
|
||||||
|
baseline: FuelStation?,
|
||||||
|
settings: FuelSavingsSettings,
|
||||||
|
amount: FuelSavingsFillAmount,
|
||||||
|
journey: FuelSavingsJourney?,
|
||||||
|
baselineJourney: FuelSavingsJourney?) -> FuelSavingsAvailability? {
|
||||||
|
guard settings.isConfigured else { return nil }
|
||||||
|
guard let baseline, let baselineJourney else { return .unavailable("No comparison station") }
|
||||||
|
guard let baselinePrice = baseline.prices[fuel] else { return .unavailable("No comparison station") }
|
||||||
|
guard let price = station.prices[fuel] else { return .unavailable("No price") }
|
||||||
|
guard let journey else { return .unavailable("Distance unavailable") }
|
||||||
|
// Both legs must be measured for the estimate to count as a road-route
|
||||||
|
// one; otherwise it is reported as a straight-line estimate.
|
||||||
|
let basis: FuelSavingsDistanceBasis =
|
||||||
|
(journey.basis == .roadRoute && baselineJourney.basis == .roadRoute) ? .roadRoute : .straightLine
|
||||||
|
do {
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: settings.mpg,
|
||||||
|
amount: amount,
|
||||||
|
baselinePricePencePerLitre: baselinePrice,
|
||||||
|
candidatePricePencePerLitre: price,
|
||||||
|
baselineJourneyMiles: baselineJourney.miles,
|
||||||
|
candidateJourneyMiles: journey.miles,
|
||||||
|
distanceBasis: basis
|
||||||
|
)
|
||||||
|
// The baseline compares against itself: label it as the closest
|
||||||
|
// station rather than a meaningless "Break even".
|
||||||
|
return .available(station.id == baseline.id ? result.asBaseline() : result)
|
||||||
|
} catch {
|
||||||
|
return .unavailable("Distance unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ranks a whole pool for the Worth the Drive sort.
|
||||||
|
///
|
||||||
|
/// EVERY priced station in the pool gets an estimate, not just the handful
|
||||||
|
/// whose road routes happen to have been measured: journeys that have no
|
||||||
|
/// measured route fall back to a straight-line out-and-back estimate (see
|
||||||
|
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper
|
||||||
|
/// stations that were a few miles further away — the exact stations the
|
||||||
|
/// sort exists to surface — and left later pages with no savings at all.
|
||||||
|
static func ranked(_ stations: [FuelStation],
|
||||||
|
fuel: FuelType,
|
||||||
|
baseline: FuelStation?,
|
||||||
|
settings: FuelSavingsSettings,
|
||||||
|
amount: FuelSavingsFillAmount,
|
||||||
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
|
||||||
|
var journeys: [String: FuelSavingsJourney] = [:]
|
||||||
|
journeys.reserveCapacity(stations.count)
|
||||||
|
for station in stations {
|
||||||
|
if let journey = journeyMiles(station) {
|
||||||
|
journeys[station.id] = journey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let baselineJourney = baseline.flatMap { journeys[$0.id] }
|
||||||
|
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
||||||
|
let journey = journeys[station.id]
|
||||||
|
let availability = availability(station: station,
|
||||||
|
fuel: fuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: journey,
|
||||||
|
baselineJourney: baselineJourney) ?? .unavailable("Unavailable")
|
||||||
|
return FuelSavingsRankedStation(
|
||||||
|
stationID: station.id,
|
||||||
|
result: availability,
|
||||||
|
distanceMiles: journey?.miles ?? .greatestFiniteMagnitude,
|
||||||
|
pricePencePerLitre: station.prices[fuel] ?? .infinity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let order = Dictionary(
|
||||||
|
uniqueKeysWithValues: FuelSavingsCalculator.sortedForWorthTheDrive(ranked)
|
||||||
|
.enumerated()
|
||||||
|
.map { ($0.element.stationID, $0.offset) }
|
||||||
|
)
|
||||||
|
return stations.sorted { lhs, rhs in
|
||||||
|
let left = order[lhs.id] ?? Int.max
|
||||||
|
let right = order[rhs.id] ?? Int.max
|
||||||
|
if left != right { return left < right }
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-9
@@ -45,6 +45,7 @@ struct FavouriteAlertSnapshot: Codable, Equatable {
|
|||||||
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
||||||
case cheapest
|
case cheapest
|
||||||
case closest
|
case closest
|
||||||
|
case worthTheDrive
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ enum SortMode: String, Codable, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .cheapest: return "Cheapest"
|
case .cheapest: return "Cheapest"
|
||||||
case .closest: return "Closest"
|
case .closest: return "Closest"
|
||||||
|
case .worthTheDrive: return "Worth the Drive"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,15 +421,47 @@ struct FuelStore {
|
|||||||
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
||||||
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
||||||
static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time
|
static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time
|
||||||
|
static let fuelSavingsEnabledKey = "fuelboard.fuelSavings.enabled"
|
||||||
|
static let fuelSavingsMPGKey = "fuelboard.fuelSavings.mpg"
|
||||||
|
static let fuelSavingsAmountKey = "fuelboard.fuelSavings.amount"
|
||||||
|
static let fuelSavingsUnitKey = "fuelboard.fuelSavings.unit"
|
||||||
|
|
||||||
// Large shared payloads belong in the App Group container, not
|
// Large shared payloads belong in the App Group container, not
|
||||||
// UserDefaults/CFPreferences (which has a roughly 4 MB limit).
|
// UserDefaults/CFPreferences (which has a roughly 4 MB limit).
|
||||||
static let stationsFileName = "fuelboard.stations.json"
|
static let stationsFileName = "fuelboard.stations.json"
|
||||||
|
|
||||||
|
/// Where a shared payload is written: the App Group container when the
|
||||||
|
/// provisioning profile grants it, otherwise the app's OWN container.
|
||||||
|
///
|
||||||
|
/// This fallback is essential in the field. Free/personal-team and SideStore
|
||||||
|
/// profiles do NOT include the `com.apple.security.application-groups`
|
||||||
|
/// entitlement, so `containerURL(forSecurityApplicationGroupIdentifier:)`
|
||||||
|
/// returns nil at runtime. Without this fallback NOTHING persisted on those
|
||||||
|
/// installs: `loadStations()` was permanently empty (so the freshness gate
|
||||||
|
/// could skip the fetch for ever and leave a blank list) and the ~2.8 MB
|
||||||
|
/// dump was re-downloaded on every refresh. The app's own container is not
|
||||||
|
/// shared with the widget/watch extension, but the app itself must keep a
|
||||||
|
/// working cache.
|
||||||
static func appGroupFileURL(_ name: String) -> URL? {
|
static func appGroupFileURL(_ name: String) -> URL? {
|
||||||
FileManager.default
|
let fm = FileManager.default
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite)?
|
if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite),
|
||||||
.appendingPathComponent(name, isDirectory: false)
|
fm.fileExists(atPath: group.path) {
|
||||||
|
// Only use the App Group container when the system actually
|
||||||
|
// provisioned it at install time (i.e. the profile carries the
|
||||||
|
// `com.apple.security.application-groups` entitlement). Never try to
|
||||||
|
// create it: when the entitlement is missing the path either does
|
||||||
|
// not exist or is not writable, and probing it that way failed every
|
||||||
|
// write instead of falling back.
|
||||||
|
return group.appendingPathComponent(name, isDirectory: false)
|
||||||
|
}
|
||||||
|
guard let dir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !fm.fileExists(atPath: dir.path),
|
||||||
|
(try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dir.appendingPathComponent(name, isDirectory: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadAppGroupFile(_ name: String) -> Data? {
|
static func loadAppGroupFile(_ name: String) -> Data? {
|
||||||
@@ -467,6 +501,10 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadStations() -> [FuelStation] {
|
static func loadStations() -> [FuelStation] {
|
||||||
|
#if DEBUG
|
||||||
|
let probe = appGroupFileURL(stationsFileName)
|
||||||
|
print("FB-STORAGE load \(stationsFileName) url=\(probe?.path ?? "nil") present=\(probe.map { FileManager.default.fileExists(atPath: $0.path) } ?? false)")
|
||||||
|
#endif
|
||||||
if let data = loadAppGroupFile(stationsFileName),
|
if let data = loadAppGroupFile(stationsFileName),
|
||||||
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
||||||
!stations.isEmpty {
|
!stations.isEmpty {
|
||||||
@@ -500,7 +538,10 @@ struct FuelStore {
|
|||||||
|
|
||||||
static func saveStations(_ stations: [FuelStation]) {
|
static func saveStations(_ stations: [FuelStation]) {
|
||||||
if let data = try? JSONEncoder().encode(stations) {
|
if let data = try? JSONEncoder().encode(stations) {
|
||||||
_ = saveAppGroupFile(data, name: stationsFileName)
|
let ok = saveAppGroupFile(data, name: stationsFileName)
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-STORAGE save \(stationsFileName) bytes=\(data.count) ok=\(ok) url=\(appGroupFileURL(stationsFileName)?.path ?? "nil")")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +673,41 @@ struct FuelStore {
|
|||||||
saveString(action.rawValue, service: stationTapActionKey)
|
saveString(action.rawValue, service: stationTapActionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Fuel savings — Worth the Drive?
|
||||||
|
|
||||||
|
static func loadFuelSavingsSettings() -> FuelSavingsSettings {
|
||||||
|
let enabled = loadString(service: fuelSavingsEnabledKey) == "1"
|
||||||
|
let mpg = loadString(service: fuelSavingsMPGKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultMPG
|
||||||
|
let amount = loadString(service: fuelSavingsAmountKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultPreferredAmount.value
|
||||||
|
let unit = loadString(service: fuelSavingsUnitKey).flatMap(FuelSavingsFillUnit.init(rawValue:)) ?? FuelSavingsSettings.defaultPreferredAmount.unit
|
||||||
|
#if DEBUG
|
||||||
|
// The two stores are mirrors and can DISAGREE (the keychain survives
|
||||||
|
// app deletion, the app-group defaults do not). Print both raw copies
|
||||||
|
// plus which one won, so a setting that flips between launches is
|
||||||
|
// diagnosable instead of guesswork.
|
||||||
|
func raw(_ service: String) -> (keychain: String, appGroup: String) {
|
||||||
|
let keychain = keychainData(service: service).flatMap { String(data: $0, encoding: .utf8) } ?? "-"
|
||||||
|
let appGroup = UserDefaults(suiteName: appGroupSuite)?.string(forKey: service) ?? "-"
|
||||||
|
return (keychain, appGroup)
|
||||||
|
}
|
||||||
|
let enabledRaw = raw(fuelSavingsEnabledKey)
|
||||||
|
let mpgRaw = raw(fuelSavingsMPGKey)
|
||||||
|
print("FB-SAVINGS read enabled=\(enabled ? 1 : 0) [kc=\(enabledRaw.keychain) ag=\(enabledRaw.appGroup)] mpg=\(mpg) [kc=\(mpgRaw.keychain) ag=\(mpgRaw.appGroup)]")
|
||||||
|
#endif
|
||||||
|
return FuelSavingsSettings(
|
||||||
|
enabled: enabled,
|
||||||
|
mpg: mpg,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: amount, unit: unit)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFuelSavingsSettings(_ settings: FuelSavingsSettings) {
|
||||||
|
saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
|
||||||
|
saveString(String(settings.mpg), service: fuelSavingsMPGKey)
|
||||||
|
saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
|
||||||
|
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||||
/// third digit (the forecourt style: "£1.29⁹").
|
/// third digit (the forecourt style: "£1.29⁹").
|
||||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
@@ -1148,20 +1224,41 @@ struct FuelStore {
|
|||||||
/// catches any real pin change.
|
/// catches any real pin change.
|
||||||
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
||||||
|
|
||||||
|
/// In-memory copy of the road-distance cache.
|
||||||
|
///
|
||||||
|
/// `loadRoadDistances()` reads the KEYCHAIN — a synchronous `securityd` XPC
|
||||||
|
/// round-trip plus a full base64+JSON decode — and `displayDistanceKM` is
|
||||||
|
/// called PER STATION inside comparisons (`min(by:)`, `sorted`). Re-reading
|
||||||
|
/// it on every call meant tens of thousands of synchronous XPC round-trips
|
||||||
|
/// on the main thread during scene creation, which stalled past the 20 s iOS
|
||||||
|
/// launch watchdog and got the app SIGKILLed (0x8BADF00D). Decode once per
|
||||||
|
/// process, reuse, and refresh on save.
|
||||||
|
private static var roadDistanceMemo: RoadDistanceCache?
|
||||||
|
/// Distinguishes "not loaded yet" from "loaded, and there is no cache".
|
||||||
|
private static var roadDistanceMemoLoaded = false
|
||||||
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
|
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) {
|
||||||
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||||
}
|
}
|
||||||
|
roadDistanceMemo = cache
|
||||||
|
roadDistanceMemoLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadRoadDistances() -> RoadDistanceCache? {
|
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||||
guard let raw = loadString(service: roadDistancesKey),
|
if roadDistanceMemoLoaded { return roadDistanceMemo }
|
||||||
|
let decoded: RoadDistanceCache?
|
||||||
|
if let raw = loadString(service: roadDistancesKey),
|
||||||
let data = Data(base64Encoded: raw),
|
let data = Data(base64Encoded: raw),
|
||||||
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) {
|
||||||
else { return nil }
|
decoded = cache
|
||||||
return cache
|
} else {
|
||||||
|
decoded = nil
|
||||||
|
}
|
||||||
|
roadDistanceMemo = decoded
|
||||||
|
roadDistanceMemoLoaded = true
|
||||||
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -1169,6 +1266,26 @@ struct FuelStore {
|
|||||||
/// is now / the station's own pin doesn't match the coordinate that was
|
/// is now / the station's own pin doesn't match the coordinate that was
|
||||||
/// routed.
|
/// routed.
|
||||||
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.meters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete out-and-back routed journey distance (metres). New caches store
|
||||||
|
/// the return route separately because Apple Maps may choose a different
|
||||||
|
/// return path; legacy one-way caches safely fall back to doubling outward.
|
||||||
|
static func roadJourneyMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
guard let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) else { return nil }
|
||||||
|
return entry.meters + (entry.returnMeters ?? entry.meters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a usable routed distance is already cached for this station from
|
||||||
|
/// (near enough) this origin. The routing pass uses it to spend its per-pass
|
||||||
|
/// budget on stations that still need a route instead of re-routing the
|
||||||
|
/// same nearest handful forever.
|
||||||
|
static func hasRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool {
|
||||||
|
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func roadDistanceEntry(for station: FuelStation, userLat: Double, userLng: Double) -> CachedRoadDistance? {
|
||||||
guard let cache = loadRoadDistances(),
|
guard let cache = loadRoadDistances(),
|
||||||
let entry = cache.entries[station.id] 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.
|
||||||
@@ -1185,7 +1302,7 @@ struct FuelStore {
|
|||||||
// live / cached station sets from injecting each other's road metres.
|
// live / cached station sets from injecting each other's road metres.
|
||||||
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||||
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
||||||
return entry.meters
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distance (km) to display for a station: cached ROAD distance when
|
/// Distance (km) to display for a station: cached ROAD distance when
|
||||||
@@ -1266,6 +1383,14 @@ struct CachedRoadDistance: Codable {
|
|||||||
let meters: Double
|
let meters: Double
|
||||||
let lat: Double
|
let lat: Double
|
||||||
let lng: Double
|
let lng: Double
|
||||||
|
let returnMeters: Double?
|
||||||
|
|
||||||
|
init(meters: Double, lat: Double, lng: Double, returnMeters: Double? = nil) {
|
||||||
|
self.meters = meters
|
||||||
|
self.lat = lat
|
||||||
|
self.lng = lng
|
||||||
|
self.returnMeters = returnMeters
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func cachedStations(near lat: Double? = nil, lng: Double? = nil,
|
||||||
|
fuel: FuelType = .e10, radiusKM: Double? = nil) -> [FuelStation]? {
|
||||||
|
guard let cache = loadDumpCache(),
|
||||||
|
let stations = try? FuelPriceProvider.decodeStations(from: cache.data) else { return nil }
|
||||||
|
latestMeta = FuelPriceProvider.decodeRelayMeta(from: cache.data)
|
||||||
|
return focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
|
}
|
||||||
|
|
||||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
// 1. Tiny pointer fetch — the freshest snapshot day.
|
// 1. Tiny pointer fetch — the freshest snapshot day.
|
||||||
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
||||||
@@ -104,10 +112,16 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
throw FuelProviderError.mirrorUnavailable
|
throw FuelProviderError.mirrorUnavailable
|
||||||
}
|
}
|
||||||
Self.saveDumpCache(day: day, data: fetched)
|
Self.saveDumpCache(day: day, data: fetched)
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-STORAGE dump cached day=\(day) bytes=\(fetched.count) reuse=\(Self.canReuseCache(cachedDay: Self.loadDumpCache()?.day, latestDay: day))")
|
||||||
|
#endif
|
||||||
data = fetched
|
data = fetched
|
||||||
}
|
}
|
||||||
let stations = try FuelPriceProvider.decodeStations(from: data)
|
let stations = try FuelPriceProvider.decodeStations(from: data)
|
||||||
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-MIRROR day=\(day) bytes=\(data.count) decoded=\(stations.count)")
|
||||||
|
#endif
|
||||||
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user