Compare commits

...
Author SHA1 Message Date
FuelBoard Contributor 5d9e71f7ac 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").
2026-10-19 11:39:54 +01:00
9 changed files with 427 additions and 227 deletions
+155
View File
@@ -0,0 +1,155 @@
import SwiftUI
/// One semantic capsule tone: a foreground and a background for each appearance.
///
/// The palette is deliberately a TINTED pair rather than a saturated solid. Every
/// tone below clears 4.5:1 for its own foreground on its own background in BOTH
/// appearances (measured, 4.96:1 to 6.72:1), which is what lets one component
/// carry the same meaning on a white row and a dark card without a second
/// colour per status.
///
/// The pairs are resolved through `UIColor`'s dynamic provider rather than
/// SwiftUI's `@Environment(\.colorScheme)`: a capsule inside a `List` is often
/// re-rendered without its body being rebuilt, so a scheme read at body time can
/// lag one switch behind. This way the colour itself follows the appearance.
struct CapsuleTone: Equatable {
let lightForeground: Color
let lightBackground: Color
let darkForeground: Color
let darkBackground: Color
var foreground: Color { dynamic(light: lightForeground, dark: darkForeground) }
var background: Color { dynamic(light: lightBackground, dark: darkBackground) }
private func dynamic(light: Color, dark: Color) -> Color {
let lightUI = UIColor(light)
let darkUI = UIColor(dark)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? darkUI : lightUI
})
}
}
extension CapsuleTone {
/// Green: a financial saving.
static let saves = CapsuleTone(
lightForeground: Color(hex: 0x176B3D), lightBackground: Color(hex: 0xDDF3E5),
darkForeground: Color(hex: 0x66D69A), darkBackground: Color(hex: 0x204333)
)
/// Grey: a neutral outcome, neither saving nor costing.
static let breakEven = CapsuleTone(
lightForeground: Color(hex: 0x555B64), lightBackground: Color(hex: 0xE9EAED),
darkForeground: Color(hex: 0xD0D0D5), darkBackground: Color(hex: 0x404044)
)
/// Red: an additional cost.
static let costs = CapsuleTone(
lightForeground: Color(hex: 0xA63F3D), lightBackground: Color(hex: 0xF9E1DF),
darkForeground: Color(hex: 0xEF9690), darkBackground: Color(hex: 0x4B3032)
)
/// Purple: best overall value after travel.
static let bestValue = CapsuleTone(
lightForeground: Color(hex: 0x7043A1), lightBackground: Color(hex: 0xEEE4F8),
darkForeground: Color(hex: 0xD1B4FA), darkBackground: Color(hex: 0x44354F)
)
/// Blue: the lowest fuel price in range.
static let cheapest = CapsuleTone(
lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB),
darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53)
)
/// Neutral grey: proximity. Same family as `breakEven` — both mean "no
/// judgement" — but kept as its own pair so proximity and a break-even
/// verdict stay independently tunable.
static let closestStation = CapsuleTone(
lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0),
darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349)
)
/// Blue, like `cheapest`: the top favourite IS the cheapest favourite, so it
/// must not read as a different class of thing.
static let top = CapsuleTone(
lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB),
darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53)
)
}
/// The one shape rule every capsule in the app follows.
///
/// A capsule while the content is a single line; a rounded rectangle once it
/// wraps. A pill's semicircular ends crowd the text corners at two lines or
/// more, while a 10pt radius does not. The shape follows what the content
/// actually DREW, not how many lines it declared, because a label can wrap in a
/// narrow column without the data changing — "Closest station" is the case that
/// forced this: one declared line that wraps to two in the listing's price
/// column.
///
/// Measuring happens on the content BEFORE padding, so the drawn line count is
/// never inflated by the capsule's own insets.
struct AdaptiveCapsule<Content: View>: View {
/// The tone's background fill. The caller resolves it from its own semantics.
let fill: Color
/// One line's height for the content's font, already scaled with Dynamic
/// Type — pass a `@ScaledMetric` so the division below tracks the user's size.
let lineHeight: CGFloat
/// Lines the content declares, used for the first layout pass before a
/// measured height exists.
var declaredLineCount: Int = 1
var cornerRadius: CGFloat = 10
var horizontalPadding: CGFloat
var verticalPadding: CGFloat
@ViewBuilder let content: Content
@State private var drawnHeight: CGFloat = 0
private var lineCount: Int {
guard drawnHeight > 0, lineHeight > 0 else { return max(1, declaredLineCount) }
return max(1, Int((drawnHeight / lineHeight).rounded()))
}
private var shape: AnyShape {
lineCount > 1
? AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
: AnyShape(Capsule())
}
var body: some View {
content
.background(
GeometryReader { proxy in
Color.clear.preference(key: CapsuleContentHeightKey.self,
value: proxy.size.height)
}
)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.background(shape.fill(fill))
.onPreferenceChange(CapsuleContentHeightKey.self) { drawnHeight = $0 }
}
}
/// Carries the drawn height of a capsule's content up to `lineCount`.
private struct CapsuleContentHeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
extension Color {
/// A fixed sRGB colour from a 24-bit literal. The capsule palette is
/// appearance-specific and supplied per tone, so these are deliberately NOT
/// dynamic — `CapsuleTone` resolves the pair itself.
init(hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
}
+10 -13
View File
@@ -2084,29 +2084,26 @@ struct StationRow: View {
} }
/// One recommendation badge, above the price it annotates. Badges stay /// One recommendation badge, above the price it annotates. Badges stay
/// TINTED with coloured text — unlike the verdict capsules, which are filled /// TINTED with coloured text — unlike the verdict capsule beneath, which is
/// with white text: a badge annotates the price rather than reporting an /// filled: a badge annotates the price rather than reporting an outcome, so it
/// outcome, so it should stay quieter than the pill beneath it. Tinted text /// stays quieter than the pill below it, and tinted text keeps its contrast in
/// also keeps its contrast in both appearances at every Dynamic Type size /// both appearances at every Dynamic Type size.
/// without introducing a colour outside the palette.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View { private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
StationLabelBadge(label: badge.title, color: badge.tint) StationLabelBadge(kind: badge.labelKind)
} }
} }
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so /// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
/// they stay out of the shared model the tests cover. /// they stay out of the shared model the tests cover.
private extension StationRecommendationBadge { private extension StationRecommendationBadge {
var title: String { /// The shared label kind, so a row's badge and the Key's legend for the same
/// meaning can never drift apart.
var labelKind: StationLabelKind {
switch self { switch self {
case .bestValue: case .bestValue: return .bestValue
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold") case .cheapest: return .cheapest
case .cheapest:
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
} }
} }
var tint: Color { self == .bestValue ? .green : .blue }
} }
// MARK: - Location manager // MARK: - Location manager
-1
View File
@@ -238,7 +238,6 @@ struct FavouritesView: View {
} }
.sheet(isPresented: $showKey) { .sheet(isPresented: $showKey) {
KeySheetView( KeySheetView(
fuelTint: activeFuel.tintColor,
showBestValueInfo: $showBestValueInfo, showBestValueInfo: $showBestValueInfo,
showImportantInformation: $showImportantInformation showImportantInformation: $showImportantInformation
) )
+52 -105
View File
@@ -105,38 +105,51 @@ struct FuelSavingsCapsule: View {
let result: FuelSavingsResult let result: FuelSavingsResult
var compact: Bool = true 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 { 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) { HStack(spacing: compact ? 3 : 5) {
Image(systemName: Self.symbol(for: result.status)) Image(systemName: Self.symbol(for: result.status))
.accessibilityHidden(true) .accessibilityHidden(true)
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline()) Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
.lineLimit(1) // The detail sheet's capsule sits beside a headline and has the
// Measured on a 393pt screen: the capsule is handed ~101pt, of // width for its wording; the listing's is narrow and scales.
// which the icon, its spacing and the 8pt padding either side .lineLimit(compact ? 1 : nil)
// take 32pt, leaving ~69pt for the text. "Closest station" needs .minimumScaleFactor(compact ? 0.7 : 1)
// ~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) .font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(Self.color(for: result.status)) .foregroundStyle(tone.foreground)
.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 /// Verdict tone: green save, red worse off, grey break even, neutral-grey
/// baseline — the same green/amber/red family the price dot already uses. /// baseline — the same meanings the price dot's RAG family carries.
static func color(for status: FuelSavingsStatus) -> Color { static func tone(for status: FuelSavingsStatus) -> CapsuleTone {
switch status { switch status {
case .worthTheDrive: return .green case .worthTheDrive: return .saves
case .notWorthTheDrive: return .red case .notWorthTheDrive: return .costs
case .breakEven: return .secondary case .breakEven: return .breakEven
case .baseline: return .blue 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 { static func symbol(for status: FuelSavingsStatus) -> String {
switch status { switch status {
case .worthTheDrive: return "checkmark.circle.fill" case .worthTheDrive: return "checkmark.circle.fill"
@@ -160,17 +173,25 @@ struct ListingFuelSavingsCapsule: View {
/// Station rows carry the figure; the Key sheet's legend shows the wording only. /// Station rows carry the figure; the Key sheet's legend shows the wording only.
var showsAmount: Bool = true var showsAmount: Bool = true
/// The height the text actually drew, so the shape can follow the drawn line /// One caption line's height, scaled with Dynamic Type, for the shared shape
/// count rather than the data's. See `drawnLineCount`. /// rule. The drawn height is divided by this, so the shape follows the lines
@State private var textHeight: CGFloat = 0 /// actually on screen rather than the number the verdict declares.
/// One caption line's height, scaled with Dynamic Type, to divide that by.
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14 @ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
private var verdict: FuelSavingsResult.ListingVerdict { private var verdict: FuelSavingsResult.ListingVerdict {
result.listingVerdict(showsAmount: showsAmount) result.listingVerdict(showsAmount: showsAmount)
} }
private var tone: CapsuleTone { FuelSavingsCapsule.tone(for: result.status) }
var body: some View { var body: some View {
AdaptiveCapsule(
fill: tone.background,
lineHeight: captionLineHeight,
declaredLineCount: verdict.lines.count,
horizontalPadding: 8,
verticalPadding: 4
) {
HStack(alignment: .firstTextBaseline, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 3) {
Image(systemName: symbol) Image(systemName: symbol)
.accessibilityHidden(true) .accessibilityHidden(true)
@@ -191,34 +212,24 @@ struct ListingFuelSavingsCapsule: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
// Never compress or truncate: the capsule wraps and the row grows taller. // Never compress or truncate: the capsule wraps and the row grows taller.
.fixedSize(horizontal: false, vertical: true) .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
// White on the muted fill. These capsules carry a figure the eye is asked // tinted-text-only; every badge above them stays the quieter tinted form.
// 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)) .font(.caption.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(tone.foreground)
.padding(.horizontal, 8) }
.padding(.vertical, 4)
// Saves and Costs share one minimum width, so their trailing edges line // 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 // up down the list whatever the figure's length. Break even and Closest
// station carry no figure and keep their natural width. // station carry no figure and keep their natural width.
.frame(minWidth: minimumWidth, alignment: .leading) .frame(minWidth: minimumWidth, alignment: .leading)
.background(shape.fill(fill))
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel(verdict.spokenText) .accessibilityLabel(verdict.spokenText)
.onPreferenceChange(CapsuleTextHeightKey.self) { textHeight = $0 }
} }
/// Saves and Costs only — the two verdicts that carry a figure. See the /// Saves and Costs only — the two verdicts that carry a figure. 84pt is the
/// frame above: 84pt is the width the widest common amount needs /// width the widest common amount needs ("£123.45 more"), so shorter figures
/// ("£123.45 more"), so shorter figures pad rather than shrink the pill. /// pad rather than shrink the pill.
private var minimumWidth: CGFloat { private var minimumWidth: CGFloat {
switch result.status { switch result.status {
case .worthTheDrive, .notWorthTheDrive: return 84 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 { private var symbol: String {
switch result.status { switch result.status {
case .worthTheDrive: return "checkmark.circle" 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
}
}
}
+64 -38
View File
@@ -11,9 +11,6 @@ import WidgetKit
/// takes the two nested-sheet flags as bindings, so each tab keeps owning its own /// takes the two nested-sheet flags as bindings, so each tab keeps owning its own
/// QA launch-argument hooks. /// QA launch-argument hooks.
struct KeySheetView: View { struct KeySheetView: View {
/// The selected fuel's colour. `KeyStationLabelIndicator` draws its pump badge
/// in it, so the legend matches the rows the reader is looking at.
let fuelTint: Color
@Binding var showBestValueInfo: Bool @Binding var showBestValueInfo: Bool
@Binding var showImportantInformation: Bool @Binding var showImportantInformation: Bool
@@ -47,9 +44,9 @@ struct KeySheetView: View {
} }
Section { Section {
ForEach(KeySheetContent.stationLabels, id: \.label) { item in ForEach(StationLabelKind.allCases, id: \.self) { kind in
KeySheetDescriptionRow(item: item) { KeySheetDescriptionRow(item: item(for: kind)) {
KeyStationLabelIndicator(label: item.label, fuelColor: fuelTint) KeyStationLabelIndicator(kind: kind)
} }
} }
} header: { } header: {
@@ -87,6 +84,16 @@ struct KeySheetView: View {
.presentationDetents([.fraction(0.6), .large]) .presentationDetents([.fraction(0.6), .large])
.presentationBackground(Color(UIColor.systemGroupedBackground)) .presentationBackground(Color(UIColor.systemGroupedBackground))
} }
/// The Key row's copy for a label kind. Fails loudly in debug rather than
/// silently rendering a blank row if a kind is ever added without its entry.
private func item(for kind: StationLabelKind) -> KeySheetContent.Item {
if let match = KeySheetContent.stationLabels.first(where: { $0.label == kind.label }) {
return match
}
assertionFailure("No KeySheetContent.stationLabels entry for \(kind)")
return KeySheetContent.Item(label: kind.label, description: "")
}
} }
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination. /// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
@@ -384,12 +391,11 @@ struct StationsView: View {
) )
} }
/// The legend sheet, shared with the Favourites tab. Only the fuel differs: /// The legend sheet, shared with the Favourites tab. Nothing varies by fuel:
/// its colour tints the station-label row, so the legend matches the rows the /// every station label has a fixed semantic tone, so the legend reads the same
/// reader is actually looking at. /// whichever grade the reader is looking at.
private var keySheet: some View { private var keySheet: some View {
KeySheetView( KeySheetView(
fuelTint: selectedFuel.tintColor,
showBestValueInfo: $showBestValueInfo, showBestValueInfo: $showBestValueInfo,
showImportantInformation: $showImportantInformation showImportantInformation: $showImportantInformation
) )
@@ -473,10 +479,12 @@ private struct KeySheetDescriptionRow<Indicator: View>: View {
private struct KeySavingsCapsule: View { private struct KeySavingsCapsule: View {
let label: String let label: String
/// Keyed on the shared label so the legend cannot demonstrate a word the
/// listing capsule never draws.
private var status: FuelSavingsStatus { private var status: FuelSavingsStatus {
switch label { switch label {
case "Saves": return .worthTheDrive case String(localized: "Saves"): return .worthTheDrive
case "Costs more": return .notWorthTheDrive case String(localized: "Costs"): return .notWorthTheDrive
default: return .breakEven default: return .breakEven
} }
} }
@@ -502,13 +510,23 @@ private struct KeySavingsCapsule: View {
} }
/// Shared RAG dot used beside live station prices and in the Key. /// Shared RAG dot used beside live station prices and in the Key.
///
/// 12pt is the app's one diameter: it reads at a glance beside a 22pt price
/// without competing with it, and it is large enough that the three colours are
/// distinguishable for colour-vision-deficient users at arm's length. The widget
/// keeps a smaller dot of its own — its rows are far tighter than a phone row —
/// so this size is deliberately the APP's.
struct PriceRatingDot: View { struct PriceRatingDot: View {
let color: Color let color: Color
var diameter: CGFloat = PriceRatingDot.standardDiameter
/// The app's standard dot diameter, in points.
static let standardDiameter: CGFloat = 12
var body: some View { var body: some View {
Circle() Circle()
.fill(color) .fill(color)
.frame(width: 8, height: 8) .frame(width: diameter, height: diameter)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
} }
@@ -525,43 +543,51 @@ struct FavouriteStarGlyph: View {
/// Shared station-label capsule. `StationRow` uses the same view for its live /// Shared station-label capsule. `StationRow` uses the same view for its live
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style. /// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
///
/// One tone per label kind, from the shared palette: purple for best value, blue
/// for cheapest and top (a top favourite IS the cheapest favourite, so they share
/// a class), neutral for proximity. The symbol is the label's own meaning, sized
/// to sit on the caption's cap height rather than to dominate it.
struct StationLabelBadge: View { struct StationLabelBadge: View {
let label: String let kind: StationLabelKind
let color: Color
private var tone: CapsuleTone {
switch kind {
case .bestValue: return .bestValue
case .cheapest: return .cheapest
case .closestStation: return .closestStation
case .top: return .top
}
}
var body: some View { var body: some View {
Text(LocalizedStringKey(label)) HStack(spacing: 3) {
.font(.caption2.bold()) Image(systemName: kind.symbol)
.foregroundStyle(color) .font(.caption2.weight(.semibold))
.accessibilityHidden(true)
Text(LocalizedStringKey(kind.label))
.font(.caption.weight(.semibold))
.fixedSize(horizontal: true, vertical: false)
}
.foregroundStyle(tone.foreground)
.padding(.horizontal, 7) .padding(.horizontal, 7)
.padding(.vertical, 3) .padding(.vertical, 3)
.background(Capsule().fill(color.opacity(0.15))) .background(Capsule().fill(tone.background))
.fixedSize(horizontal: true, vertical: false)
} }
} }
/// The Key sheet's indicator for a station label. The closest station is the one
/// exception: it renders the listing's real verdict capsule, so the legend
/// demonstrates the same fill, text and shape the rows show. Everything else is
/// the shared badge.
private struct KeyStationLabelIndicator: View { private struct KeyStationLabelIndicator: View {
let label: String let kind: StationLabelKind
let fuelColor: Color
var body: some View { var body: some View {
switch label { if kind == .closestStation {
case "BEST VALUE":
StationLabelBadge(label: label, color: .green)
case "CHEAPEST":
StationLabelBadge(label: label, color: .blue)
case "CLOSEST":
// The listing's own capsule, so the Key demonstrates the same soft
// blue fill and white text the listing shows. `FuelSavingsCapsule`
// remains the detail sheet's alone.
ListingFuelSavingsCapsule(result: baselineResult) ListingFuelSavingsCapsule(result: baselineResult)
default: } else {
Text("TOP") StationLabelBadge(kind: kind)
.font(.caption2.bold())
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(fuelColor.opacity(0.18), in: Capsule())
.fixedSize(horizontal: true, vertical: false)
} }
} }
+6 -6
View File
@@ -9,9 +9,10 @@
"Alerts" = "Alerts"; "Alerts" = "Alerts";
"Settings" = "Settings"; "Settings" = "Settings";
"FuelBoard" = "FuelBoard"; "FuelBoard" = "FuelBoard";
"TOP" = "TOP"; "Top" = "Top";
"BEST VALUE" = "BEST VALUE"; "Best value" = "Best value";
"CHEAPEST" = "CHEAPEST"; "Cheapest" = "Cheapest";
"Closest station" = "Closest station";
/* Stations tab */ /* Stations tab */
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations"; "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
@@ -37,14 +38,13 @@
"You’ll save money compared with your closest station, including travel costs." = "You’ll save money compared with your closest station, including travel costs."; "You’ll save money compared with your closest station, including travel costs." = "You’ll save money compared with your closest station, including travel costs.";
"Break even" = "Break even"; "Break even" = "Break even";
"The saving or extra cost is negligible." = "The saving or extra cost is negligible."; "The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
"Costs more" = "Costs more"; "Costs" = "Costs";
"Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel."; "Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel.";
/* Station listing verdict capsule: the words above the figure */ /* Station listing verdict capsule: the words above the figure */
"Costs" = "Costs";
"(approx.)" = "(approx.)"; "(approx.)" = "(approx.)";
"%@ more" = "%@ more"; "%@ more" = "%@ more";
"Station labels" = "Station labels"; "Station labels" = "Station labels";
"CLOSEST" = "CLOSEST"; "Closest station" = "Closest station";
"Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs."; "Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs.";
"The lowest fuel price per litre within your selected range." = "The lowest fuel price per litre within your selected range."; "The lowest fuel price per litre within your selected range." = "The lowest fuel price per litre within your selected range.";
"The nearest station by journey distance." = "The nearest station by journey distance."; "The nearest station by journey distance." = "The nearest station by journey distance.";
@@ -271,17 +271,51 @@ final class KeySheetContentTests: XCTestCase {
XCTAssertEqual(KeySheetContent.savings, [ XCTAssertEqual(KeySheetContent.savings, [
.init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."), .init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
.init(label: "Break even", description: "The saving or extra cost is negligible."), .init(label: "Break even", description: "The saving or extra cost is negligible."),
.init(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.") // "Costs" — the listing capsule's own headline, not "Costs more".
.init(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
]) ])
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" }) XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
} }
/// Every Key label is sentence case: the capsules are read as phrases, and an
/// all-caps set reads as shouting beside the sentence-case copy around it.
func testEveryKeyLabelIsSentenceCase() {
let labels = (KeySheetContent.priceRatings
+ KeySheetContent.savings
+ KeySheetContent.stationLabels).map(\.label)
for label in labels {
XCTAssertNotEqual(label, label.uppercased(),
"\(label) must be sentence case, not all caps")
}
XCTAssertEqual(KeySheetContent.stationLabels.map(\.label),
["Best value", "Cheapest", "Closest station", "Top"])
}
/// The Key dispatches its indicators on `StationLabelKind`, so every kind must
/// have exactly one entry in the legend — and each entry's label must be the
/// kind's own, or the row would describe one label and draw another.
func testEveryStationLabelKindHasOneMatchingLegendEntry() {
XCTAssertEqual(StationLabelKind.allCases.count, KeySheetContent.stationLabels.count)
for kind in StationLabelKind.allCases {
let matches = KeySheetContent.stationLabels.filter { $0.label == kind.label }
XCTAssertEqual(matches.count, 1, "\(kind) must own exactly one legend entry")
}
}
/// Recommended symbols, pinned so a rename cannot silently blank a badge.
func testStationLabelKindsUseTheirRecommendedSymbols() {
XCTAssertEqual(StationLabelKind.bestValue.symbol, "medal")
XCTAssertEqual(StationLabelKind.cheapest.symbol, "tag")
XCTAssertEqual(StationLabelKind.closestStation.symbol, "location.north.fill")
XCTAssertEqual(StationLabelKind.top.symbol, "trophy")
}
func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() { func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() {
XCTAssertEqual(KeySheetContent.stationLabels, [ XCTAssertEqual(KeySheetContent.stationLabels, [
.init(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."), .init(label: "Best value", description: "Meets your minimum saving after travel costs."),
.init(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."), .init(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
.init(label: "CLOSEST", description: "The nearest station by journey distance."), .init(label: "Closest station", description: "The nearest station by journey distance."),
.init(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.") .init(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
]) ])
} }
+4 -2
View File
@@ -516,8 +516,10 @@ struct ContentView: View {
.fontWeight(favourite.isCheapest ? .semibold : .regular) .fontWeight(favourite.isCheapest ? .semibold : .regular)
.lineLimit(1) .lineLimit(1)
if favourite.isCheapest { if favourite.isCheapest {
Text("TOP") // Sentence case, to match every other surface: the watch
.font(.system(size: 9, weight: .bold)) // row is the same label as the phone's, not a different one.
Text("Top")
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 4) .padding(.horizontal, 4)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(fuel.color.opacity(0.18), in: Capsule()) .background(fuel.color.opacity(0.18), in: Capsule())
+45 -5
View File
@@ -367,6 +367,12 @@ struct FuelSavingsRankedStation: Equatable {
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the /// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
/// descriptions in one Foundation-only model lets tests pin the exact meaning of /// descriptions in one Foundation-only model lets tests pin the exact meaning of
/// each visual indicator without coupling the wording to SwiftUI layout. /// each visual indicator without coupling the wording to SwiftUI layout.
///
/// Every label is SENTENCE CASE ("Best value", not "BEST VALUE"): the labels
/// are read as phrases, and a capsule set in all caps reads as shouting beside
/// the sentence-case copy in the rows around it. The tests pin these exact
/// strings, and `StationLabelKind` maps each one to its indicator, so the view
/// never matches on the literal text.
enum KeySheetContent { enum KeySheetContent {
struct Item: Equatable { struct Item: Equatable {
let label: String let label: String
@@ -379,17 +385,19 @@ enum KeySheetContent {
Item(label: "Pricey", description: "More than 4p/L above the cheapest.") Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
] ]
/// The same three verdicts the listing capsule draws, so the legend and the
/// rows cannot disagree on a word. "Costs" is the capsule's own headline.
static let savings = [ static let savings = [
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."), Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
Item(label: "Break even", description: "The saving or extra cost is negligible."), Item(label: "Break even", description: "The saving or extra cost is negligible."),
Item(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.") Item(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
] ]
static let stationLabels = [ static let stationLabels = [
Item(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."), Item(label: "Best value", description: "Meets your minimum saving after travel costs."),
Item(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."), Item(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
Item(label: "CLOSEST", description: "The nearest station by journey distance."), Item(label: "Closest station", description: "The nearest station by journey distance."),
Item(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.") Item(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
] ]
static let favourite = Item( static let favourite = Item(
@@ -400,6 +408,38 @@ enum KeySheetContent {
static let helpTitle = "How does Best Value work?" static let helpTitle = "How does Best Value work?"
} }
/// A station label's meaning, independent of its copy.
///
/// The Key sheet used to dispatch its indicator on the label's literal text
/// (`case "BEST VALUE":`), so renaming a label silently dropped that row to the
/// fallback indicator. The enum makes the mapping explicit and lets the
/// compiler catch a missing case.
enum StationLabelKind: String, CaseIterable {
case bestValue
case cheapest
case closestStation
case top
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
var label: String {
switch self {
case .bestValue: return String(localized: "Best value")
case .cheapest: return String(localized: "Cheapest")
case .closestStation: return FuelSavingsResult.baselineHeadline
case .top: return String(localized: "Top")
}
}
var symbol: String {
switch self {
case .bestValue: return "medal"
case .cheapest: return "tag"
case .closestStation: return "location.north.fill"
case .top: return "trophy"
}
}
}
/// Which recommendation badge a station listing row shows, if any. /// Which recommendation badge a station listing row shows, if any.
/// ///
/// Presentation only — no new rules: the BEST VALUE qualification reuses the /// Presentation only — no new rules: the BEST VALUE qualification reuses the