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.
255 lines
11 KiB
Swift
255 lines
11 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|