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 priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||
@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 alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||
@@ -63,15 +65,42 @@ struct ContentView: View {
|
||||
private func refreshRoadDistancesIfNeeded() {
|
||||
let origin = location ?? FuelStore.loadLocation()
|
||||
guard let origin else { return }
|
||||
let priority = worthTheDriveRoutingPriorities(location: origin)
|
||||
Task {
|
||||
await RoadDistanceService.refreshIfNeeded(
|
||||
stations: stations,
|
||||
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() {
|
||||
if alertsEnabled || liveActivityEnabled {
|
||||
locationManager.startBackgroundTracking()
|
||||
@@ -113,11 +142,11 @@ struct ContentView: View {
|
||||
@State private var bannerOffset: CGFloat = 0
|
||||
@State private var bannerClearTask: Task<Void, Never>?
|
||||
|
||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||
/// redundant — the whole country sorted nearest-first, because "nearest"
|
||||
/// must never answer with an empty state. STRICT: no fallback to
|
||||
/// out-of-radius stations in Cheapest mode.
|
||||
/// The pool the list draws from. In Cheapest and Worth-the-Drive modes the
|
||||
/// chosen miles radius bounds it ("best price within X miles"); in Closest
|
||||
/// mode the radius is redundant — the whole country sorted nearest-first,
|
||||
/// because "nearest" must never answer with an empty state. STRICT: no
|
||||
/// fallback to out-of-radius stations in radius-bound modes.
|
||||
private var poolStations: [FuelStation] {
|
||||
let selling = stations.filter { $0.prices[selectedFuel] != nil }
|
||||
guard let location else { return selling }
|
||||
@@ -180,7 +209,7 @@ struct ContentView: View {
|
||||
|
||||
private var sortedStations: [FuelStation] {
|
||||
let pool = poolStations
|
||||
switch sortMode {
|
||||
switch effectiveSortMode {
|
||||
case .closest:
|
||||
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
|
||||
return pool.sorted { lhs, rhs in
|
||||
@@ -200,9 +229,165 @@ struct ContentView: View {
|
||||
return lhs.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
|
||||
/// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites).
|
||||
private var favouriteIDs: Set<String> {
|
||||
@@ -320,6 +505,26 @@ struct ContentView: View {
|
||||
selectedFuel = 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
|
||||
// failure legs for the screenshot harness. The auto-refresh below
|
||||
// is skipped so the banner stays up (a live fetch would clear it).
|
||||
@@ -335,6 +540,11 @@ struct ContentView: View {
|
||||
}
|
||||
#else
|
||||
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
|
||||
// Onboarding runs first on a fresh install — it owns the initial
|
||||
// permission prompts (location, notifications, and the data/local
|
||||
@@ -399,6 +609,13 @@ struct ContentView: View {
|
||||
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
|
||||
if newPhase == .active {
|
||||
// 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
|
||||
/// within the compiler's type-check budget.
|
||||
private var stationsTab: some View {
|
||||
StationsView(
|
||||
let baselineStation = nearestSavingsBaseline
|
||||
return StationsView(
|
||||
stations: displayedStations,
|
||||
totalCount: sortedStations.count,
|
||||
isLoading: isLoading,
|
||||
selectedFuel: $selectedFuel,
|
||||
sortMode: $sortMode,
|
||||
sortMode: Binding(get: { effectiveSortMode }, set: { sortMode = $0 }),
|
||||
stationLimit: $stationLimit,
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
@@ -768,6 +986,10 @@ struct ContentView: View {
|
||||
location: location,
|
||||
favouriteIDs: favouriteIDs,
|
||||
stationTapAction: stationTapAction,
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||
savingsForStation: savingsLookup(baseline: baselineStation),
|
||||
savingsBaselineStationName: baselineStation?.name,
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onRefresh: { await refresh(force: true) }
|
||||
)
|
||||
@@ -776,13 +998,18 @@ struct ContentView: View {
|
||||
/// The Favourites tab, extracted from `body` for the same type-check
|
||||
/// budget reason.
|
||||
private var favouritesTab: some View {
|
||||
FavouritesView(
|
||||
let baselineStation = nearestSavingsBaseline
|
||||
return FavouritesView(
|
||||
favourites: refreshedFavourites,
|
||||
selectedFuel: selectedFuel,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
stationTapAction: stationTapAction,
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||
savingsForStation: savingsLookup(baseline: baselineStation),
|
||||
savingsBaselineStationName: baselineStation?.name,
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onReorder: reorderFavourites,
|
||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||
@@ -822,6 +1049,7 @@ struct ContentView: View {
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
stationTapAction: $stationTapAction,
|
||||
fuelSavingsSettings: $fuelSavingsSettings,
|
||||
dataRefreshMode: $dataRefreshMode,
|
||||
alertsFuel: alertsFuel,
|
||||
alertsRadiusKM: alertsRadius,
|
||||
@@ -874,12 +1102,56 @@ struct ContentView: View {
|
||||
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
|
||||
/// `force` is true (pull-to-refresh is the manual override).
|
||||
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
|
||||
}
|
||||
#if DEBUG
|
||||
print("FB-REFRESH start force=\(force) cached=\(cachedCount)")
|
||||
#endif
|
||||
showFallbackStationsWhileRefreshing()
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
@@ -890,6 +1162,9 @@ struct ContentView: View {
|
||||
radiusKM: nil // full-UK dump; device filters by miles radius
|
||||
)
|
||||
stations = fetched
|
||||
#if DEBUG
|
||||
print("FB-REFRESH ok: \(fetched.count) stations from \(LiveChainProvider.lastSource ?? "?")")
|
||||
#endif
|
||||
FuelStore.saveStations(fetched)
|
||||
FuelStore.saveLastRefresh()
|
||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||
@@ -918,6 +1193,9 @@ struct ContentView: View {
|
||||
// Live data restored — any status banner no longer applies.
|
||||
dataStatus = .live
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("FB-REFRESH fail: \(error)")
|
||||
#endif
|
||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||
FuelStore.markWatchRefreshHandled()
|
||||
}
|
||||
@@ -959,7 +1237,11 @@ struct StationRow: View {
|
||||
let baselinePrice: Double?
|
||||
let isTopResult: 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 fuelSavings: FuelSavingsAvailability?
|
||||
var onToggleFavourite: () -> Void = {}
|
||||
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||
|
||||
@@ -1025,7 +1307,11 @@ struct StationRow: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
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())
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
@@ -1050,6 +1336,9 @@ struct StationRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
if fuelSavings != nil {
|
||||
FuelSavingsSummaryView(availability: fuelSavings, amount: .init(value: 0, unit: .pounds), baselineStationName: nil, compact: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ struct FavouritesView: View {
|
||||
let distanceUnit: DistanceUnit
|
||||
let priceDisplayStyle: PriceDisplayStyle
|
||||
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 }
|
||||
/// Persists a reordered favourites array (after drag-and-drop).
|
||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||
@@ -70,6 +80,10 @@ struct FavouritesView: View {
|
||||
distanceUnit: DistanceUnit,
|
||||
priceDisplayStyle: PriceDisplayStyle,
|
||||
stationTapAction: StationTapAction,
|
||||
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||
savingsBaselineStationName: String? = nil,
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||
onHistoryUnavailable: (() -> Void)? = nil,
|
||||
@@ -80,6 +94,10 @@ struct FavouritesView: View {
|
||||
self.distanceUnit = distanceUnit
|
||||
self.priceDisplayStyle = priceDisplayStyle
|
||||
self.stationTapAction = stationTapAction
|
||||
self.fuelSavingsSettings = fuelSavingsSettings
|
||||
_fuelSavingsAmount = fuelSavingsAmount
|
||||
self.savingsForStation = savingsForStation
|
||||
self.savingsBaselineStationName = savingsBaselineStationName
|
||||
self.onToggleFavourite = onToggleFavourite
|
||||
self.onReorder = onReorder
|
||||
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 {
|
||||
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
||||
StationRow(
|
||||
@@ -133,9 +162,11 @@ struct FavouritesView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: cheapestPrice,
|
||||
isTopResult: index == 0,
|
||||
isTopResult: station.id == cheapest?.id,
|
||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||
topBadgeText: "CHEAPEST",
|
||||
tapAction: stationTapAction,
|
||||
fuelSavings: savingsForStation(station),
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
@@ -183,7 +214,10 @@ struct FavouritesView: View {
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit
|
||||
distanceUnit: distanceUnit,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
savingsBaselineStationName: savingsBaselineStationName
|
||||
)
|
||||
}
|
||||
.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 {
|
||||
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
||||
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
||||
/// still get real road distances instead of a straight-line fallback.
|
||||
static let candidatesPerPass = 40
|
||||
/// route calls. Worth-the-Drive now asks for outward AND return routes, so
|
||||
/// this must stay conservative; a larger value can make first launch feel
|
||||
/// 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
|
||||
/// 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.
|
||||
@@ -31,11 +38,34 @@ enum RoadDistanceService {
|
||||
|
||||
/// Refreshes the cached road distances for the in-radius stations around
|
||||
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
||||
///
|
||||
/// - 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 }
|
||||
|
||||
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.
|
||||
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 movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||
@@ -43,33 +73,55 @@ enum RoadDistanceService {
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate stations: the nearest-by-straight-line subset that the UI
|
||||
// could actually display, capped so a pass stays bounded.
|
||||
let nearest = stations
|
||||
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||
.prefix(candidatesPerPass)
|
||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||
// Candidates: Worth-the-Drive priorities first, then the nearest
|
||||
// stations. Stations that already have a route from this origin are
|
||||
// skipped so the per-pass budget goes on the ones still missing one.
|
||||
var candidates: [FuelStation] = []
|
||||
var seen = Set<String>()
|
||||
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)
|
||||
var entries: [String: CachedRoadDistance] = [:]
|
||||
for station in nearest {
|
||||
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
||||
for station in candidates {
|
||||
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
|
||||
// refuse to serve this value if the station later appears with
|
||||
// a different coordinate (corrected pin / other data source).
|
||||
let returnMeters = await back
|
||||
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||
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)
|
||||
// Wake the widgets so the new road distances surface immediately.
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
private static let refreshGate = RoadDistanceRefreshGate()
|
||||
|
||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||
let request = MKDirections.Request()
|
||||
@@ -96,3 +148,17 @@ enum RoadDistanceService {
|
||||
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 priceDisplayStyle: PriceDisplayStyle
|
||||
@Binding var stationTapAction: StationTapAction
|
||||
@Binding var fuelSavingsSettings: FuelSavingsSettings
|
||||
@Binding var dataRefreshMode: DataRefreshMode
|
||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||
/// 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 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() {
|
||||
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
|
||||
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.")
|
||||
}
|
||||
|
||||
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 {
|
||||
Picker("Data checking", selection: $dataRefreshMode) {
|
||||
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")
|
||||
.onAppear {
|
||||
Task { await tipStore.load() }
|
||||
@@ -565,6 +618,47 @@ struct SettingsView: View {
|
||||
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"
|
||||
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
||||
private var connectionText: String {
|
||||
|
||||
@@ -10,6 +10,9 @@ struct StationDetailView: View {
|
||||
let station: FuelStation
|
||||
let location: Coordinate?
|
||||
let distanceUnit: DistanceUnit
|
||||
var fuelSavings: FuelSavingsAvailability? = nil
|
||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||
var savingsBaselineStationName: String? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingReportIssueConfirmation = false
|
||||
|
||||
@@ -54,6 +57,16 @@ struct StationDetailView: View {
|
||||
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||
}
|
||||
}
|
||||
Section {
|
||||
FuelSavingsSummaryView(
|
||||
availability: fuelSavings,
|
||||
amount: fuelSavingsAmount,
|
||||
baselineStationName: savingsBaselineStationName
|
||||
)
|
||||
} header: {
|
||||
Text("Worth the drive?")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
if let url = station.mapsDirectionsURL {
|
||||
|
||||
@@ -16,6 +16,10 @@ struct StationsView: View {
|
||||
let location: Coordinate?
|
||||
let favouriteIDs: Set<String>
|
||||
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 onRefresh: () async -> Void = {}
|
||||
|
||||
@@ -41,7 +45,12 @@ struct StationsView: View {
|
||||
let miles = distanceUnit.displayMiles(stationLimit)
|
||||
let unit = distanceUnit.label(for: Double(miles))
|
||||
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
|
||||
? "tap a station for directions."
|
||||
: "tap a station for details."
|
||||
@@ -92,7 +101,7 @@ struct StationsView: View {
|
||||
|
||||
Section("Stations") {
|
||||
Picker("Sort by", selection: $sortMode) {
|
||||
ForEach(SortMode.allCases) { mode in
|
||||
ForEach(availableSortModes) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
@@ -102,7 +111,17 @@ struct StationsView: View {
|
||||
}
|
||||
.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) {
|
||||
ProgressView()
|
||||
Text("Fetching prices…")
|
||||
@@ -133,7 +152,9 @@ struct StationsView: View {
|
||||
baselinePrice: baselinePrice,
|
||||
isTopResult: station.id == topStationID,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
|
||||
tapAction: stationTapAction,
|
||||
fuelSavings: savingsForStation(station),
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
@@ -180,6 +201,11 @@ struct StationsView: View {
|
||||
// large title at rest, scrolls it away and fades the compact
|
||||
// inline title in. No custom toolbar needed — the native mechanism
|
||||
// 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")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
@@ -273,12 +299,23 @@ struct StationsView: View {
|
||||
StationDetailView(
|
||||
station: station,
|
||||
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,
|
||||
/// 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 {
|
||||
|
||||
Reference in New Issue
Block a user