The listing's verdict capsule now carries white text on muted semantic fills instead of the neutral secondary tint: Saves #45825E, Costs #A9635B, Break even #707070, Closest #58789D. Fixed tones, not translucent tints, because white text on a 15% tint over a white row measures ~1.8:1. Each tone sits at a relative luminance of ~0.18 — the band where white clears 4.5:1 AND the pill still separates ~3:1 from both a white row and the dark card fill — so one value each works in light and dark without a dynamic colour. The saturated palette colours cannot carry white text at all: systemGreen measures 2.2:1, systemRed 3.6:1. "Save" becomes "Saves", matching the detail sheet and reading as a statement of the outcome; amounts, symbols, padding, radius and the shape-follows-line-count rule are unchanged. Saves and Costs share an 84pt minimum width so their trailing edges line up down the list. The price and its capsule are now one vertically grouped element (5pt), with the badge keeping the column's full 12pt above, so the row no longer reads as three equal lines. Station names cap at three lines with a tail ellipsis: unbounded wrap let one very long forecourt name set the height of the whole row. 59 of the 8,073 names in the dump exceed three lines and now ellipsise; the full name is always in Station Details. Addresses still wrap freely. The baseline pill moves onto ListingFuelSavingsCapsule so the listing shows the same soft blue and white text as its siblings, leaving FuelSavingsCapsule to the detail sheet, which it now alone uses. Badges keep their tinted styling: they annotate a price rather than report an outcome. Verified: 261/261 swift test; Debug simulator and Release device builds; all four fills and their white text measured from simulator pixels in both appearances, and the detail sheet's .blue absent from the listing entirely.
270 lines
12 KiB
Swift
270 lines
12 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
|
||
|
||
var body: some View {
|
||
HStack(spacing: compact ? 3 : 5) {
|
||
Image(systemName: Self.symbol(for: result.status))
|
||
.accessibilityHidden(true)
|
||
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
||
.lineLimit(1)
|
||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
||
// which the icon, its spacing and the 8pt padding either side
|
||
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
||
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
||
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
||
// which clears it at ~10pt type: scaled, never truncated.
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
.font(compact ? .caption.weight(.semibold) : .headline)
|
||
.foregroundStyle(Self.color(for: result.status))
|
||
.padding(.horizontal, compact ? 8 : 12)
|
||
.padding(.vertical, compact ? 4 : 7)
|
||
.background(Capsule().fill(Self.color(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.
|
||
static func color(for status: FuelSavingsStatus) -> Color {
|
||
switch status {
|
||
case .worthTheDrive: return .green
|
||
case .notWorthTheDrive: return .red
|
||
case .breakEven: return .secondary
|
||
case .baseline: return .blue
|
||
}
|
||
}
|
||
|
||
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"
|
||
case .baseline: return "location.circle.fill"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
|
||
private var verdict: FuelSavingsResult.ListingVerdict {
|
||
result.listingVerdict(showsAmount: showsAmount)
|
||
}
|
||
|
||
var body: some View {
|
||
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)
|
||
}
|
||
// White on the muted fill. These capsules carry a figure the eye is asked
|
||
// to read at a glance, which is why they are the only filled-and-white
|
||
// surfaces in the app; every badge stays tinted with coloured text.
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
// 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)
|
||
.background(shape.fill(fill))
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(verdict.spokenText)
|
||
}
|
||
|
||
/// Saves and Costs only — the two verdicts that carry a figure. See the
|
||
/// frame above: 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 fill: Color {
|
||
ListingCapsuleFill.color(for: result.status)
|
||
}
|
||
|
||
/// One line keeps the capsule. Two or more switch to a rounded rect: a pill's
|
||
/// semicircular ends crowd the text corners at that height, while an 8pt
|
||
/// radius does not. Keyed to the LINE COUNT, so Break even flips shape only
|
||
/// when "(approx.)" pushes it onto a second line.
|
||
private var shape: AnyShape {
|
||
verdict.lines.count > 1
|
||
? AnyShape(RoundedRectangle(cornerRadius: 8))
|
||
: AnyShape(Capsule())
|
||
}
|
||
|
||
private var symbol: String {
|
||
switch result.status {
|
||
case .worthTheDrive: return "checkmark.circle"
|
||
case .notWorthTheDrive: return "arrow.up.circle"
|
||
case .breakEven: return "equal.circle"
|
||
case .baseline: return "location.circle"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The station listing's verdict-capsule fills: muted semantic colours carrying
|
||
/// white text and icon.
|
||
///
|
||
/// Muted AND fixed, deliberately not the translucent tints used elsewhere in the
|
||
/// app: white text on a 15% tint over a white row measures ~1.8:1 and is
|
||
/// unreadable. Each tone below sits at a relative luminance of ~0.18 — the band
|
||
/// where white text clears 4.5:1 AND the pill still separates ~3:1 from BOTH a
|
||
/// white row and the dark card fill — so one value each works in both
|
||
/// appearances without a dynamic colour.
|
||
///
|
||
/// The saturated palette colours cannot carry white text at all: measured
|
||
/// white-on-fill is 2.2:1 for `systemGreen`, 3.6:1 for `systemRed` and 4.0:1 for
|
||
/// `systemBlue`, every one of them below the 4.5:1 a caption needs. Muting is
|
||
/// therefore a contrast requirement here, not only the mockup's taste.
|
||
enum ListingCapsuleFill {
|
||
// Trailing comments record, per colour: white-on-fill / fill-vs-white-row /
|
||
// fill-vs-dark-row, measured rather than estimated.
|
||
static let saves = Color(red: 0x45 / 255, green: 0x82 / 255, blue: 0x5E / 255) // 4.56 / 4.56 / 3.06
|
||
static let costs = Color(red: 0xA9 / 255, green: 0x63 / 255, blue: 0x5B / 255) // 4.54 / 4.54 / 3.07
|
||
static let breakEven = Color(red: 0x70 / 255, green: 0x70 / 255, blue: 0x70 / 255) // 4.95 / 4.95 / 2.81
|
||
static let closest = Color(red: 0x58 / 255, green: 0x78 / 255, blue: 0x9D / 255) // 4.52 / 4.52 / 3.09
|
||
|
||
static func color(for status: FuelSavingsStatus) -> Color {
|
||
switch status {
|
||
case .worthTheDrive: return saves
|
||
case .notWorthTheDrive: return costs
|
||
case .breakEven: return breakEven
|
||
case .baseline: return closest
|
||
}
|
||
}
|
||
}
|