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:
@@ -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