Files
fuelboard/FuelBoard/FuelSavingsViews.swift
T
FuelBoard Contributor cf3abb3fa7 Fuel Savings: MPG and fill-up are sliders, not typed fields
The two Fuel Savings figures and the in-list fill-up control were text
fields. A typed figure could sit outside the slider's own bounds, so the
control and the number it claimed to show could disagree, and the decimal
pad needed a Done button to escape.

- Settings: "Average fuel economy" and "Typical fill-up" are now sliders
  with a read-out ("45 MPG", "£40" / "40 L") in place of the field. The
  warning copy reads "Set a realistic..." rather than "Enter...".
- FuelSavingsAdjustmentView (shared by the Stations Worth-the-Drive header
  and the Favourites tab) drops its text field and gains the same read-out;
  the typed-value machinery and the -focusFillAmount QA hook go with it.
- Slider bounds live in FuelSavingsCalculator (fillAmountSliderRange/Step,
  mpgSliderRange/Step) so the Settings control and the in-list control can
  never offer different ranges, and the fill-up slider range stays inside
  the calculator's validity ceiling (asserted by test).
- Two tests pin those invariants: every slider position is a valid amount,
  and both MPG bounds are valid MPG values. 155 tests, 0 failures.
- Comments that described the removed fields were updated; the keyboard
  Done-bar plumbing is kept (no numeric field remains in the app, so it can
  no longer fire) rather than deleted, so any field added later still has an
  escape route.

Verified on the simulator: Settings shows "Average fuel economy / 45 MPG"
with no field, and the Worth-the-Drive fill-up control shows the unit picker
with a "£40" read-out and a slider. The Settings "Typical fill-up" row sits
below the fold on every simulator available here (and synthetic input cannot
scroll it), so those pixels are unverified.
2026-10-11 17:41:53 +01:00

198 lines
8.7 KiB
Swift

import SwiftUI
struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount
let referencePricePencePerLitre: Double?
private var range: ClosedRange<Double> {
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
}
private var step: Double {
FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit)
}
/// Read-out of the figure the slider is on. Deliberately NOT a text field:
/// a typed figure could sit outside the slider's own bounds, leaving the
/// control and the value it claimed to show disagreeing. Slider-only means
/// what you see is the figure the estimate actually uses.
private var valueText: String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f L", amount.value)
}
}
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: step)
.accessibilityLabel("Fill-up amount")
.accessibilityValue(valueText)
Text(valueText)
.monospacedDigit()
.frame(width: 72, alignment: .trailing)
}
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
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)
}
}
)
}
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) }
)
}
}
struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount
let baselineStationName: String?
var compact: Bool = false
/// Sizes the "more driving than closest" caption in the user's own unit (the
/// stored figure is miles) — used by both the row banner and the detail sheet.
var distanceUnit: DistanceUnit = .miles
var body: some View {
switch availability {
case .available(let result):
if compact {
// FULL-WIDTH banner under the row. Inline inside the middle
// column the capsule truncated ("Save £2.75 after tra…") because
// the column is squeezed by the price block — the verdict is the
// point of this sort mode, so it gets its own line with room.
// Colour carries the verdict: green save, red worse off, grey
// break even, blue baseline. lineLimit + minimumScaleFactor keep
// it to one line, so rows stay even.
VStack(alignment: .leading, spacing: 2) {
capsule(for: result, compact: true)
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit
) {
Text(extraTravel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
}
// Aligns the banner with the row's text column (46 pt logo + 12
// pt spacing) so it reads as part of the row, not the cell.
.padding(.leading, 58)
} else {
// Detail sheet: the SAME verdict capsule and the SAME caption as
// the listing rows (one implementation, so wording and colour
// cannot drift), followed by the breakdown only this view has
// room for. The old shape led with a status word + headline that
// matched neither the listing nor each other.
VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false)
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit
) {
Text(extraTravel)
.font(.subheadline)
.foregroundStyle(.secondary)
}
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))
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)
}
}
/// The colour-coded verdict capsule — ONE implementation for the listing rows
/// and the detail sheet, so the verdict reads identically in both; the sheet
/// just gets the larger font and padding.
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
Label {
Text(result.capsuleHeadline())
.lineLimit(1)
.minimumScaleFactor(0.8)
} icon: {
Image(systemName: symbol(for: result.status))
}
.font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(capsuleColor(for: result.status))
.padding(.horizontal, compact ? 8 : 12)
.padding(.vertical, compact ? 4 : 7)
.background(Capsule().fill(capsuleColor(for: result.status).opacity(0.15)))
}
/// Verdict colour: green save, red worse off, grey break even, blue
/// baseline — the same green/amber/red family the price dot already uses.
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
switch status {
case .worthTheDrive: return .green
case .notWorthTheDrive: return .red
case .breakEven: return .secondary
case .baseline: return .blue
}
}
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"
}
}
}