Unify capsule, station label and price rating styling
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").
This commit is contained in:
@@ -105,38 +105,51 @@ 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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
.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 {
|
||||
/// 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 .green
|
||||
case .notWorthTheDrive: return .red
|
||||
case .breakEven: return .secondary
|
||||
case .baseline: return .blue
|
||||
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"
|
||||
@@ -160,65 +173,63 @@ struct ListingFuelSavingsCapsule: View {
|
||||
/// 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.
|
||||
/// 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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
)
|
||||
// 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)
|
||||
}
|
||||
// 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.
|
||||
/// 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
|
||||
@@ -226,30 +237,6 @@ struct ListingFuelSavingsCapsule: View {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -259,43 +246,3 @@ struct ListingFuelSavingsCapsule: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user