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
+94
View File
@@ -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 {