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.
289 lines
11 KiB
Swift
289 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
/// The More info sheet's facts row: the selected fuel's price on the left, the
|
|
/// distance on the right, separated by a hairline down the middle.
|
|
///
|
|
/// Every figure comes from the sheet's existing sources — the price through
|
|
/// `FuelStore.priceText` and the distance through
|
|
/// `FuelStore.stationDistanceDisplay(...)` — so this row can never disagree with
|
|
/// the listing row, the widget or the Live Activity, and both columns keep the
|
|
/// shared "Approx." confidence marking.
|
|
struct StationFactsRow: View {
|
|
let station: FuelStation
|
|
let selectedFuel: FuelType
|
|
let priceAge: FuelPriceFreshness.Assessment
|
|
let distance: StationDistanceDisplay?
|
|
let distanceUnit: DistanceUnit
|
|
/// The user's price display style, from the same Settings choice the listing
|
|
/// rows and the widgets read.
|
|
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
|
|
|
/// The price's point size, scaled with Dynamic Type: the price is built from
|
|
/// an explicit size (its "/L" suffix is sized relative to it inside
|
|
/// `priceTextAttributed`), so it needs a metric rather than a font style.
|
|
/// 22pt matches the listing row's price.
|
|
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
fuelColumn
|
|
StationFactsDivider()
|
|
distanceColumn
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
private var fuelColumn: some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(selectedFuel.displayName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
priceView
|
|
StationPriceAgeView(assessment: priceAge, compact: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
|
|
private var distanceColumn: some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Distance")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(distanceText)
|
|
.font(.title2.weight(.bold))
|
|
.monospacedDigit()
|
|
Text(returnText)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
|
|
/// The selected grade's price, in the user's chosen display style — the same
|
|
/// `priceTextAttributed` the listing row uses, so the sheet prints the bare
|
|
/// forecourt figure ("167.9") in Station sign and the superscripted
|
|
/// "£1.67⁹/L" in Pounds & pence, and the two surfaces cannot disagree.
|
|
/// The returned Text carries its own fonts, so no `.font(...)` goes on top.
|
|
///
|
|
/// A missing grade shows an em dash rather than hiding the column, so the row
|
|
/// keeps its two-column shape.
|
|
@ViewBuilder
|
|
private var priceView: some View {
|
|
if let price = station.prices[selectedFuel] {
|
|
FuelStore.priceTextAttributed(price, style: priceStyle, size: priceSize, weight: .bold)
|
|
} else {
|
|
Text("—")
|
|
.font(.title2.weight(.bold))
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
|
|
/// One-way distance, with its confidence marker, from the shared display.
|
|
private var distanceText: String {
|
|
distance?.label(unit: distanceUnit) ?? "—"
|
|
}
|
|
|
|
/// Out-and-back distance, same source and same marker rule.
|
|
private var returnText: String {
|
|
distance?.returnLabel(unit: distanceUnit) ?? ""
|
|
}
|
|
}
|
|
|
|
/// A one-pixel vertical hairline between the facts row's two columns, drawn in
|
|
/// the List's own separator colour so it matches every other line on the sheet.
|
|
/// Decorative: VoiceOver reads each column as one phrase instead.
|
|
private struct StationFactsDivider: View {
|
|
var body: some View {
|
|
Rectangle()
|
|
.fill(Color(.separator))
|
|
.frame(width: 0.5)
|
|
.frame(maxHeight: .infinity)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
/// The More info sheet's Best Value card: the verdict, then the figures it is
|
|
/// built from.
|
|
///
|
|
/// Nothing is recomputed here — every number comes from the shared
|
|
/// `FuelSavingsResult`, the verdict capsule is the same `FuelSavingsCapsule` the
|
|
/// listing rows use, and the total is the shared `headline()`. Wording and
|
|
/// colour therefore cannot drift between the sheet and the list.
|
|
struct BestValueDetailCard: View {
|
|
let availability: FuelSavingsAvailability
|
|
let amount: FuelSavingsFillAmount
|
|
let baselineStationName: String?
|
|
let distanceUnit: DistanceUnit
|
|
|
|
var body: some View {
|
|
switch availability {
|
|
case .available(let result):
|
|
availableBody(result)
|
|
case .unavailable(let reason):
|
|
Text("Best Value unavailable: \(reason)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func availableBody(_ result: FuelSavingsResult) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
header(result)
|
|
panels(result)
|
|
workings(result)
|
|
}
|
|
}
|
|
|
|
/// Card title on the left, the verdict on the right — ONE capsule, in the
|
|
/// same large form the sheet used to repeat on its own line above the
|
|
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
|
|
/// with it, the same way the listing rows lay the pair out.
|
|
///
|
|
/// "Value breakdown" rather than "Best Value": the card is the working behind
|
|
/// whatever verdict the capsule shows, and that verdict is not always a Best
|
|
/// Value one — a Costs station shows its extra cost here. Naming the card after
|
|
/// the feature promised the reader a Best Value result it might not get.
|
|
private func header(_ result: FuelSavingsResult) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
Text("Value breakdown")
|
|
.font(.headline)
|
|
Spacer(minLength: 8)
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
FuelSavingsCapsule(result: result, compact: false)
|
|
.accessibilityLabel(result.detailCapsuleSpokenHeadline())
|
|
comparison(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The compact comparison caption ("vs closest station" / "your baseline"),
|
|
/// under the capsule it belongs to.
|
|
private func comparison(_ result: FuelSavingsResult) -> some View {
|
|
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
/// The two figures the estimate is made OF, side by side: what the user
|
|
/// spends, and how much fuel that buys at this station.
|
|
private func panels(_ result: FuelSavingsResult) -> some View {
|
|
HStack(spacing: 10) {
|
|
panel(symbol: "fuelpump.fill",
|
|
title: "Your fill-up",
|
|
value: Self.amountText(amount))
|
|
panel(symbol: "drop.fill",
|
|
title: "Fuel you'll receive",
|
|
value: String(format: "%.2f litres", result.fillLitres))
|
|
}
|
|
}
|
|
|
|
private func panel(symbol: String, title: String, value: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: symbol)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityHidden(true)
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
Text(value)
|
|
.font(.title3.weight(.semibold))
|
|
.monospacedDigit()
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.06)))
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
|
|
/// The working: what the extra travel adds, what the pump saves, and the
|
|
/// total. A baseline station has nothing to save against itself, so it stops
|
|
/// after the panels — the capsule above already says "Closest".
|
|
@ViewBuilder
|
|
private func workings(_ result: FuelSavingsResult) -> some View {
|
|
if result.status != .baseline {
|
|
Divider()
|
|
extraTravelRow(result)
|
|
savingRows(result)
|
|
Divider()
|
|
totalRow(result)
|
|
if let baselineStationName {
|
|
Text("Compared with \(baselineStationName).")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if result.journeyConfidence.isEstimated {
|
|
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
/// The extra driving a saving has to beat, named in the row label and
|
|
/// measured in the value — same figure and same "Approx." rule as the shared
|
|
/// caption, from the shared formatter.
|
|
@ViewBuilder
|
|
private func extraTravelRow(_ result: FuelSavingsResult) -> some View {
|
|
if let extraTravel = FuelSavingsFormat.extraTravelDistanceLabel(
|
|
extraDistanceMiles: result.extraDistanceMiles,
|
|
unit: distanceUnit,
|
|
confidence: result.journeyConfidence
|
|
) {
|
|
LabeledContent("Extra return travel", value: extraTravel)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func savingRows(_ result: FuelSavingsResult) -> some View {
|
|
LabeledContent(
|
|
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
|
|
value: result.grossSavingGBP >= 0
|
|
? FuelSavingsFormat.money(result.grossSavingGBP)
|
|
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
|
|
)
|
|
.monospacedDigit()
|
|
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
|
.monospacedDigit()
|
|
}
|
|
|
|
/// The Total row is the WORKINGS; the capsule above is the quick verdict.
|
|
/// Both take their wording and colour from the shared result.
|
|
private func totalRow(_ result: FuelSavingsResult) -> some View {
|
|
LabeledContent {
|
|
Text(result.headline())
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(FuelSavingsCapsule.color(for: result.status))
|
|
.monospacedDigit()
|
|
} label: {
|
|
Text("Total")
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
|
|
/// "£60" or "60 litres" — the same strings the sheet printed before the
|
|
/// redesign, so the fill-up reads identically in both units.
|
|
static func amountText(_ amount: FuelSavingsFillAmount) -> String {
|
|
switch amount.unit {
|
|
case .pounds: return String(format: "£%.0f", amount.value)
|
|
case .litres: return String(format: "%.0f litres", amount.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "How does FuelBoard calculate value?" lives in the shared `HowBestValueRow`
|
|
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
|
|
/// the Key cannot drift.
|