Files
fuelboard/FuelBoard/FuelSavingsViews.swift
T
FuelBoard Contributor d7a0ecb79a 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.
2026-10-11 14:34:53 +01:00

141 lines
5.8 KiB
Swift

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"
}
}
}