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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user