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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
-10
@@ -45,6 +45,7 @@ struct FavouriteAlertSnapshot: Codable, Equatable {
|
||||
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
||||
case cheapest
|
||||
case closest
|
||||
case worthTheDrive
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -52,6 +53,7 @@ enum SortMode: String, Codable, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .cheapest: return "Cheapest"
|
||||
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 stationCountKey = "fuelboard.stationCount" // String — station count
|
||||
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
|
||||
// UserDefaults/CFPreferences (which has a roughly 4 MB limit).
|
||||
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? {
|
||||
FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite)?
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
let fm = FileManager.default
|
||||
if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite),
|
||||
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? {
|
||||
@@ -467,6 +501,10 @@ struct FuelStore {
|
||||
}
|
||||
|
||||
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),
|
||||
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
||||
!stations.isEmpty {
|
||||
@@ -500,7 +538,10 @@ struct FuelStore {
|
||||
|
||||
static func saveStations(_ stations: [FuelStation]) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
/// third digit (the forecourt style: "£1.29⁹").
|
||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||
@@ -1148,20 +1224,41 @@ struct FuelStore {
|
||||
/// catches any real pin change.
|
||||
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]) {
|
||||
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||
if let data = try? JSONEncoder().encode(cache) {
|
||||
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||
}
|
||||
roadDistanceMemo = cache
|
||||
roadDistanceMemoLoaded = true
|
||||
}
|
||||
|
||||
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||
guard let raw = loadString(service: roadDistancesKey),
|
||||
let data = Data(base64Encoded: raw),
|
||||
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
||||
else { return nil }
|
||||
return cache
|
||||
if roadDistanceMemoLoaded { return roadDistanceMemo }
|
||||
let decoded: RoadDistanceCache?
|
||||
if let raw = loadString(service: roadDistancesKey),
|
||||
let data = Data(base64Encoded: raw),
|
||||
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) {
|
||||
decoded = cache
|
||||
} else {
|
||||
decoded = nil
|
||||
}
|
||||
roadDistanceMemo = decoded
|
||||
roadDistanceMemoLoaded = true
|
||||
return decoded
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// routed.
|
||||
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(),
|
||||
let entry = cache.entries[station.id] else { return nil }
|
||||
// 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.
|
||||
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||
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
|
||||
@@ -1266,6 +1383,14 @@ struct CachedRoadDistance: Codable {
|
||||
let meters: Double
|
||||
let lat: 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 +
|
||||
|
||||
@@ -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] {
|
||||
// 1. Tiny pointer fetch — the freshest snapshot day.
|
||||
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
||||
@@ -104,10 +112,16 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
||||
throw FuelProviderError.mirrorUnavailable
|
||||
}
|
||||
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
|
||||
}
|
||||
let stations = try FuelPriceProvider.decodeStations(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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user