import SwiftUI struct FuelSavingsAdjustmentView: View { @Binding var amount: FuelSavingsFillAmount @State private var showingComparisonHelp = false private var range: ClosedRange { 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 { 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 { 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 /// The height the text actually drew, so the shape can follow the drawn line /// count rather than the data's. See `drawnLineCount`. @State private var textHeight: CGFloat = 0 /// One caption line's height, scaled with Dynamic Type, to divide that by. @ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14 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) .background( GeometryReader { proxy in Color.clear .preference(key: CapsuleTextHeightKey.self, value: proxy.size.height) } ) } // 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) .onPreferenceChange(CapsuleTextHeightKey.self) { textHeight = $0 } } /// 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) } /// How many lines the text DREW, not how many the verdict declares. /// "Closest station" is one declared line that wraps to two in the row's /// narrow column, and the shape has to follow what is on screen or the pill's /// semicircular ends crowd the text they are wrapping. Falls back to the /// declared count on the first layout pass, before there is a height. private var drawnLineCount: Int { guard textHeight > 0, captionLineHeight > 0 else { return verdict.lines.count } return max(1, Int((textHeight / captionLineHeight).rounded())) } /// 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 DRAWN line count, so a wrapped "Closest /// station" gets the same rounded rect as a figure pushed onto its own line. private var shape: AnyShape { drawnLineCount > 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" } } } /// Carries the drawn height of a capsule's text block up to `drawnLineCount`. private struct CapsuleTextHeightKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } /// 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 } } }