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.
This commit is contained in:
FuelBoard Contributor
2026-10-11 17:19:10 +01:00
parent d7a0ecb79a
commit d0a89d97d5
10 changed files with 780 additions and 51 deletions
+67 -18
View File
@@ -296,7 +296,7 @@ struct ContentView: View {
/// that every station the app lists can carry a saving.
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
return FuelSavingsJourney(miles: roadMeters / 1609.34, basis: .roadRoute)
return FuelSavingsJourney(miles: roadMeters / FuelSavingsCalculator.metresPerMile, basis: .roadRoute)
}
return FuelSavingsJourney(
miles: savingsFallbackMiles(for: station, location: location),
@@ -305,7 +305,7 @@ struct ContentView: View {
}
private func savingsFallbackMiles(for station: FuelStation, location: Coordinate) -> Double {
FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) * 0.621371 * 2
FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) / FuelSavingsCalculator.kilometresPerMile * 2
}
private func sortedForWorthTheDrive(_ pool: [FuelStation]) -> [FuelStation] {
@@ -424,7 +424,12 @@ struct ContentView: View {
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
distanceUnit: distanceUnit,
fuelSavings: savingsLookup(baseline: nearestSavingsBaseline)(station),
fuelSavingsAmount: sessionFuelSavingsAmount,
savingsBaselineStationName: nearestSavingsBaseline?.name,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
)
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
@@ -525,6 +530,13 @@ struct ContentView: View {
fuelSavingsSettings = forced
sortMode = .worthTheDrive
}
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
// "Tap station = More info". The stored default is open-Apple-Maps,
// so on a simulator a tap bounces out to Maps with no way back and
// the Worth the Drive detail section can't be checked at all.
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
stationTapAction = .showDetails
}
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
// failure legs for the screenshot harness. The auto-refresh below
// is skipped so the banner stays up (a live fetch would clear it).
@@ -1242,6 +1254,10 @@ struct StationRow: View {
var topBadgeText: String = "TOP"
var tapAction: StationTapAction = .openMap
var fuelSavings: FuelSavingsAvailability?
/// The session fill-up figure (the one the adjustment control edits). The
/// compact banner does not print it today, but a row that carried a hard
/// zero would print "for £0" the moment it did — pass the real figure.
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in }
@@ -1263,6 +1279,13 @@ struct StationRow: View {
return String(format: "%+.1fp", delta)
}
/// True when this row IS the cheapest in range — its delta reads "best",
/// so labelling it "vs cheapest" would be nonsense.
private var isCheapestPrice: Bool {
guard let price = station.prices[fuel], let baselinePrice else { return false }
return abs(price - baselinePrice) <= 0.05
}
private func handleTap() {
switch tapAction {
case .openMap:
@@ -1279,6 +1302,7 @@ struct StationRow: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
// LEFT — round brand logo (or generic fuel pump fallback)
Group {
@@ -1306,18 +1330,6 @@ struct StationRow: View {
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
if isTopResult {
// The badge always marks the CHEAPEST PUMP PRICE (never
// "best saving"), so in Worth the Drive order — where
// the top row is the best net saving — it may sit lower
// down the list, and says so.
Text(topBadgeText)
.font(.caption2.bold())
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Capsule().fill(.blue.opacity(0.15)))
.foregroundStyle(.blue)
}
ForEach(StationNotice.notices(for: station)) { notice in
Image(systemName: notice.symbolName)
.font(.caption2.weight(.semibold))
@@ -1336,14 +1348,27 @@ struct StationRow: View {
.foregroundStyle(.secondary)
.monospacedDigit()
}
if fuelSavings != nil {
FuelSavingsSummaryView(availability: fuelSavings, amount: .init(value: 0, unit: .pounds), baselineStationName: nil, compact: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// RIGHT — price in £ (no p) + difference vs cheapest
VStack(alignment: .trailing, spacing: 2) {
if isTopResult {
// The badge always marks the CHEAPEST PUMP PRICE (never
// "best saving"), so in Worth the Drive order — where the
// top row is the best net saving — it may sit lower down
// the list, and says so. It sits ABOVE the price it
// annotates rather than beside the name, so it reads as
// part of the price and the name keeps its width.
Text(topBadgeText)
.font(.caption2.bold())
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Capsule().fill(.blue.opacity(0.15)))
.foregroundStyle(.blue)
.lineLimit(1)
.fixedSize()
}
if let price = station.prices[fuel] {
HStack(spacing: 5) {
Circle()
@@ -1355,6 +1380,16 @@ struct StationRow: View {
Text(deltaText)
.font(.caption2.bold().monospaced())
.foregroundStyle(ragColor)
if !isCheapestPrice {
// "+8.0p" on its own never said what it measured
// against; the delta is against the cheapest price
// in range in EVERY sort mode.
Text("vs cheapest")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
}
}
@@ -1367,6 +1402,20 @@ struct StationRow: View {
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
.buttonStyle(.borderless)
}
if fuelSavings != nil {
// Full-width verdict banner under the row. Inline in the middle
// column it truncated ("Save £2.75 after tra…") because the
// price block squeezes that column — and the verdict is the
// point of this sort mode, so it gets its own line.
FuelSavingsSummaryView(
availability: fuelSavings,
amount: fuelSavingsAmount,
baselineStationName: nil,
compact: true,
distanceUnit: distanceUnit
)
}
}
.contentShape(Rectangle())
.onTapGesture(perform: handleTap)
+6 -1
View File
@@ -167,6 +167,7 @@ struct FavouritesView: View {
topBadgeText: "CHEAPEST",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 }
)
@@ -217,7 +218,11 @@ struct FavouritesView: View {
distanceUnit: distanceUnit,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName
savingsBaselineStationName: savingsBaselineStationName,
// Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars.
isFavourite: true,
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
)
}
.onAppear {
+90 -8
View File
@@ -5,6 +5,10 @@ struct FuelSavingsAdjustmentView: View {
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
@@ -27,12 +31,25 @@ struct FuelSavingsAdjustmentView: View {
.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() }
.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() }
}
@@ -83,16 +100,53 @@ struct FuelSavingsSummaryView: View {
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):
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 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)
@@ -101,7 +155,6 @@ struct FuelSavingsSummaryView: View {
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)
@@ -129,6 +182,35 @@ struct FuelSavingsSummaryView: View {
}
}
/// 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"
+60 -22
View File
@@ -1,5 +1,6 @@
import SwiftUI
import UIKit
import Combine
extension View {
/// Puts a Done button above the keyboard.
@@ -11,31 +12,68 @@ extension View {
/// `TextField(value:format:)` only commits the typed value when editing
/// ends — so the number never stuck either.
///
/// Apply ONCE per screen, to the list or form containing the fields: the
/// keyboard toolbar belongs to the focused field, so a single attachment
/// covers every field inside it. Attaching it to each field would show
/// several Done buttons at once.
/// WHY IT IS DRAWN HERE RATHER THAN AS `.toolbar(placement: .keyboard)`:
/// the keyboard-toolbar form is device-dependent. The same build showed the
/// Done button on the iPhone 15 Pro and NOT on the iPhone SE (both iOS
/// 27.0.1, same install, same binary), leaving the SE with no way to
/// dismiss or commit a numeric field. Drawing the bar in the screen's own
/// bottom safe area renders identically on every device, so the accessibility
/// of the numeric fields no longer depends on the accessory view appearing.
///
/// Apply ONCE per screen, to the list or form containing the fields: any
/// keyboard on that screen shows the bar, so attaching it to each field
/// would stack several Done buttons.
///
/// - Parameter action: clears the screen's focus state. Prefer this where a
/// `@FocusState` exists; the default resigns the current first responder,
/// which works for fields inside subviews that have no focus binding.
/// which works for fields inside subviews that have no focus binding (the
/// fill-up box in the station list is one).
func keyboardDoneButton(action: (() -> Void)? = nil) -> some View {
toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
if let action {
action()
} else {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
}
}
}
modifier(KeyboardDoneBar(action: action))
}
}
/// Shows a Done bar while a keyboard is up on this screen.
///
/// Visibility comes from the keyboard notifications rather than a `@FocusState`
/// so the bar also works for fields inside subviews that have no focus binding.
private struct KeyboardDoneBar: ViewModifier {
let action: (() -> Void)?
@State private var isKeyboardVisible = false
func body(content: Content) -> some View {
content
.safeAreaInset(edge: .bottom, spacing: 0) {
if isKeyboardVisible {
HStack {
Spacer()
Button("Done") {
if let action {
action()
} else {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
isKeyboardVisible = false
}
.font(.body.weight(.semibold))
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(.bar)
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
isKeyboardVisible = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
isKeyboardVisible = false
}
}
}
+22 -1
View File
@@ -13,6 +13,12 @@ struct StationDetailView: View {
var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsBaselineStationName: String? = nil
/// Whether this station is starred for the fuel currently being viewed, and
/// the toggle that flips it. Passed from the list that opened the sheet, so
/// the star here and the star on the row are the same fuel-scoped
/// favourite and cannot drift apart.
var isFavourite: Bool = false
var onToggleFavourite: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var showingReportIssueConfirmation = false
@@ -61,7 +67,8 @@ struct StationDetailView: View {
FuelSavingsSummaryView(
availability: fuelSavings,
amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit
)
} header: {
Text("Worth the drive?")
@@ -96,6 +103,20 @@ struct StationDetailView: View {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button {
onToggleFavourite?()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
}
.tint(isFavourite ? Color.yellow : nil)
.disabled(onToggleFavourite == nil)
.accessibilityLabel(
isFavourite
? String(localized: "Remove from favourites", comment: "Unstar a station from the More info sheet")
: String(localized: "Add to favourites", comment: "Star a station from the More info sheet")
)
}
}
}
}
+16 -1
View File
@@ -155,6 +155,7 @@ struct StationsView: View {
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
@@ -215,6 +216,18 @@ struct StationsView: View {
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
showKey = true
}
#endif
}
.task(id: stations.count) {
#if DEBUG
// QA hook: `-showDetail` opens the More info sheet for the first
// listed station, so the sheet can be screenshotted without a
// tap. Waits for the list to arrive first.
guard ProcessInfo.processInfo.arguments.contains("-showDetail"),
detailStation == nil,
let first = stations.first else { return }
try? await Task.sleep(nanoseconds: 2_000_000_000)
detailStation = first
#endif
}
.toolbar {
@@ -302,7 +315,9 @@ struct StationsView: View {
distanceUnit: distanceUnit,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName
savingsBaselineStationName: savingsBaselineStationName,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
)
}
}
+3
View File
@@ -31,6 +31,9 @@
"More than 4p over the cheapest" = "More than 4p over the cheapest";
"Top result for the current sort" = "Top result for the current sort";
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
/* Star button in the More info sheet heading (accessibility labels) */
"Add to favourites" = "Add to favourites";
"Remove from favourites" = "Remove from favourites";
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
/* Favourites tab */