One adaptive tone palette and one shape rule now drive every capsule on Stations, Favourites, the detail sheet and the Key. - CapsuleTone: the light/dark palette pairs in one place, resolved through a UIColor dynamic provider so a List row that re-renders without rebuilding its body still follows an appearance switch. Every pair clears 4.5:1 foreground-on-background in both appearances (4.96-6.72:1). - AdaptiveCapsule: capsule at one drawn line, rounded rect at two or more, radius 8 -> 10, measuring content before padding so insets cannot inflate the line count. - Listing verdict capsules move from white-on-muted-solid to the tinted palette, so Saves, Break even, Costs and Closest station read the same in the rows and in the detail sheet. - Best Value is purple, not green: green stays reserved for a financial saving, which is what the Saves capsule beside it means. - Price dots standardise to 12pt in the app (widget keeps its own 6pt dot; PriceRatingDot now takes a diameter so the difference is deliberate). - Station labels are sentence case with their recommended symbols (medal, tag, location.north.fill, trophy) and StationLabelKind replaces the Key's string-literal dispatch, so renaming a label can no longer drop a row. - Key says "Costs", matching the capsule's own headline (was "Costs more").
249 lines
10 KiB
Swift
249 lines
10 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 .closestStation
|
||
}
|
||
}
|
||
|
||
/// 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"
|
||
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
|
||
|
||
/// 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"
|
||
case .baseline: return "location.circle"
|
||
}
|
||
}
|
||
}
|