Files
fuelboard/FuelBoard/FuelSavingsViews.swift
T
FuelBoard Contributor 8153a36168 Make Closest a station label and retire Top
Top and Cheapest were two words for one state: both meant "the lowest price
among the stations on screen", differing only in the candidate set (radius vs
Favourites). Which word a surface used had drifted by device — the phone's
rows said Cheapest while the watch said Top for the identical state. Top is
gone from the enum, the Key legend, the badge and the watch's hand-rolled
label; Cheapest survives because it names the measure and is already the word
the favourites copy uses.

Closest is now a real station label rather than a verdict. StationRecommendationBadge
gains .closest with a total precedence — Best value, then Cheapest, then Closest
— because a row has room for one label and proximity is the weakest claim: a
short drive says nothing about the price, and unlike the other two it reports no
saving at all. The badge sits above the price, where the other two sit. The
closest row's verdict capsule is suppressed: its only word for that state was
"Closest", so printing it below as well would state the same fact twice, three
lines apart. Other rows keep their Saves / Break even / Costs capsules unchanged.

"Closest station" becomes "Closest", read from the single
FuelSavingsResult.baselineHeadline constant so the badge, the verdict and the Key
cannot word it differently. The Key legend renders all three station labels with
the shared StationLabelBadge; the old KeyStationLabelIndicator, which
special-cased the closest station to a wider verdict pill so it read as a
different class of thing from the two above it, is deleted.

One closest icon everywhere. The listing and detail verdict symbols used
location.circle and location.circle.fill for the state the Key taught as
location.north.fill; both now read StationLabelKind.closest.symbol, so the three
surfaces resolve one glyph from one place.

The detail sheet gains the Closest capsule below the map, ordered to match the
Key. Favourites gains the nearest favourite as its Closest, measured on the same
one-way distance its rows print and restricted to favourites with a price for the
active fuel — matching the radius tab, where the closest station is by definition
the savings baseline.

closestStationID flows from the one scaffold baseline the savings lookup already
compares against, so a row's badge and the reference station cannot disagree.
No savings calculation, threshold, ranking, eligibility or station-selection
logic changed.

The Key sheet's detent is now bound rather than fixed, so dragging it up to read
the Station labels section sticks instead of snapping back; a QA launch argument
opens it at full height, since the 0.6 detent puts that section below the fold on
every device. That argument is read unconditionally, unlike the other harness
hooks which are #if DEBUG-gated.

Tests: 278 pass, 9 new — the label precedence ladder across every combination,
capsule order in the sheet, Closest alone, and the retired label pinned absent.
2026-10-19 14:59:47 +01:00

255 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount
@State private var showingComparisonHelp = false
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) {
HStack {
Text("How much fuel?")
.font(.subheadline.weight(.semibold))
Spacer()
Button {
showingComparisonHelp = true
} label: {
Image(systemName: "info.circle")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel("About comparison modes")
.popover(isPresented: $showingComparisonHelp) {
VStack(alignment: .leading, spacing: 12) {
Text("Comparison modes")
.font(.headline)
Text("£: Compare how much fuel your budget buys at each station.")
.fixedSize(horizontal: false, vertical: true)
Text("Litres: Compare the cost of buying the same amount of fuel.")
.fixedSize(horizontal: false, vertical: true)
}
.font(.footnote)
.frame(width: 280, alignment: .leading)
.padding()
.presentationCompactAdaptation(.popover)
}
}
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("Adjust your fill-up amount for this visit. Your default won’t change.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var unitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { amount.unit },
set: { newUnit in
guard newUnit != amount.unit else { return }
// No one litre figure equals a budget at every candidate price,
// so switching modes starts from that mode's configured default.
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: 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) }
)
}
}
/// The colour-coded monetary verdict capsule used by the detail sheet and other
/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
///
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
/// a Label lays its icon and title out across the row and leaves a visible gap
/// between them (22pt in the listing row), which read as the text having
/// drifted away from its symbol. The icon is decorative here — the verdict is
/// in the text — so VoiceOver reads the capsule as one phrase.
struct FuelSavingsCapsule: View {
let result: FuelSavingsResult
var compact: Bool = true
/// One line of `.headline` / `.caption`, scaled with Dynamic Type, so the
/// shared shape rule can tell one drawn line from two.
@ScaledMetric(relativeTo: .headline) private var headlineLineHeight: CGFloat = 22
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
private var tone: CapsuleTone { Self.tone(for: result.status) }
var body: some View {
AdaptiveCapsule(
fill: tone.background,
lineHeight: compact ? captionLineHeight : headlineLineHeight,
horizontalPadding: compact ? 8 : 12,
verticalPadding: compact ? 4 : 7
) {
HStack(spacing: compact ? 3 : 5) {
Image(systemName: Self.symbol(for: result.status))
.accessibilityHidden(true)
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
// The detail sheet's capsule sits beside a headline and has the
// width for its wording; the listing's is narrow and scales.
.lineLimit(compact ? 1 : nil)
.minimumScaleFactor(compact ? 0.7 : 1)
}
.font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(tone.foreground)
}
}
/// Verdict tone: green save, red worse off, grey break even, neutral-grey
/// baseline — the same meanings the price dot's RAG family carries.
static func tone(for status: FuelSavingsStatus) -> CapsuleTone {
switch status {
case .worthTheDrive: return .saves
case .notWorthTheDrive: return .costs
case .breakEven: return .breakEven
case .baseline: return .closest
}
}
/// Foreground-only form, for a `Text` that is tinted rather than filled (the
/// detail sheet's Total row). Keeps one source for the verdict colour.
static func color(for status: FuelSavingsStatus) -> Color {
tone(for: status).foreground
}
static 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"
// The ONE closest glyph, shared with the Key legend and the station
// badges (`StationLabelKind.closest`). The other three verdicts are
// circular because they report an outcome; closest reports a position,
// and a north arrow is what the legend teaches for it.
case .baseline: return StationLabelKind.closest.symbol
}
}
}
/// The station listing's verdict capsule: the words on the first line, the figure
/// on its own line beneath, and "(approx.)" on a third when the journey is an
/// estimate.
///
/// Stations and Favourites share `StationRow`, so both render this. The detail
/// sheet keeps `FuelSavingsCapsule`'s colour-coded monetary verdict on one line;
/// the Key sheet's legend renders this with `showsAmount: false` so it never
/// displays an invented figure.
struct ListingFuelSavingsCapsule: View {
let result: FuelSavingsResult
/// Station rows carry the figure; the Key sheet's legend shows the wording only.
var showsAmount: Bool = true
/// One caption line's height, scaled with Dynamic Type, for the shared shape
/// rule. The drawn height is divided by this, so the shape follows the lines
/// actually on screen rather than the number the verdict declares.
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
private var verdict: FuelSavingsResult.ListingVerdict {
result.listingVerdict(showsAmount: showsAmount)
}
private var tone: CapsuleTone { FuelSavingsCapsule.tone(for: result.status) }
var body: some View {
AdaptiveCapsule(
fill: tone.background,
lineHeight: captionLineHeight,
declaredLineCount: verdict.lines.count,
horizontalPadding: 8,
verticalPadding: 4
) {
HStack(alignment: .firstTextBaseline, spacing: 3) {
Image(systemName: symbol)
.accessibilityHidden(true)
// One Text per line, not a "\n" inside one string: each line keeps its
// own localisation key and the amount can be monospaced on its own.
// Left-aligned: the word and its figure share a left edge, so the
// block reads as a column rather than a ragged centre.
VStack(alignment: .leading, spacing: 0) {
Text(verdict.headline)
if let amount = verdict.amount {
Text(amount)
.monospacedDigit()
}
if let qualifier = verdict.qualifier {
Text(qualifier)
}
}
.multilineTextAlignment(.leading)
// Never compress or truncate: the capsule wraps and the row grows taller.
.fixedSize(horizontal: false, vertical: true)
}
// Tinted foreground on a tinted fill. These capsules carry a figure the
// eye is asked to read at a glance, so they are filled rather than
// tinted-text-only; every badge above them stays the quieter tinted form.
.font(.caption.weight(.semibold))
.foregroundStyle(tone.foreground)
}
// Saves and Costs share one minimum width, so their trailing edges line
// up down the list whatever the figure's length. Break even and Closest
// station carry no figure and keep their natural width.
.frame(minWidth: minimumWidth, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityLabel(verdict.spokenText)
}
/// Saves and Costs only — the two verdicts that carry a figure. 84pt is the
/// width the widest common amount needs ("£123.45 more"), so shorter figures
/// pad rather than shrink the pill.
private var minimumWidth: CGFloat {
switch result.status {
case .worthTheDrive, .notWorthTheDrive: return 84
case .breakEven, .baseline: return 0
}
}
private var symbol: String {
switch result.status {
case .worthTheDrive: return "checkmark.circle"
case .notWorthTheDrive: return "arrow.up.circle"
case .breakEven: return "equal.circle"
// Same closest glyph as the detail verdict and the Key — this capsule is
// the listing's copy of the same state, not a third icon for it.
case .baseline: return StationLabelKind.closest.symbol
}
}
}