Files
fuelboard/FuelBoard/FuelSavingsViews.swift
T
FuelBoard Contributor d0a89d97d5 Worth the Drive: calculation audit + listing/detail polish
Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):

- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
  metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
  used in the savings path. Effect ~2 ppm (invisible), but the conversions
  are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
  MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
  MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
  imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
  boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
  estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
  price), pump saving, round-trip extra distance clamped at zero, travel fuel
  priced at the candidate station, +/-5p break even, baseline labelled
  "Closest station", sorting on the unrounded net saving. The brief's "£0.18"
  for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
  "2.0 mi"), not a rounding bug in the money.

UI (uncommitted since d7a0ecb):

- Done bar drawn in the bottom safe area from keyboard notifications:
  .toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
  absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
  toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
  (the compact banner does not print it today; a future row layout would).

153 tests, 0 failures. Simulator and device builds succeed.
2026-10-11 17:19:10 +01:00

223 lines
9.7 KiB
Swift

import SwiftUI
struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount
let referencePricePencePerLitre: Double?
@State private var textValue: String = ""
/// Focus for the numeric field — also lets the DEBUG QA hook below raise the
/// keyboard on launch so the Done bar can be screenshotted (a script cannot
/// tap the simulator).
@FocusState private var isAmountFieldFocused: Bool
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)
.focused($isAmountFieldFocused)
.accessibilityIdentifier("fillAmountField")
}
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.onAppear {
syncText()
#if DEBUG
// QA hook: raise the keyboard without a tap so the Done bar can be
// screenshotted on a simulator (synthetic taps do not reach it).
if ProcessInfo.processInfo.arguments.contains("-focusFillAmount") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
isAmountFieldFocused = true
}
}
#endif
}
.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
/// 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"
}
}
}