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:
FuelBoard Contributor
2026-10-11 14:34:53 +01:00
parent 1044d009ae
commit d7a0ecb79a
17 changed files with 1865 additions and 46 deletions
+41 -4
View File
@@ -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 {