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:
FuelBoard Contributor
2026-10-19 11:39:54 +01:00
parent fcb11a8f52
commit 5d9e71f7ac
9 changed files with 427 additions and 227 deletions
+71 -31
View File
@@ -364,41 +364,81 @@ struct FuelSavingsRankedStation: Equatable {
let pricePencePerLitre: Double
}
/// 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
/// each visual indicator without coupling the wording to SwiftUI layout.
enum KeySheetContent {
struct Item: Equatable {
let label: String
let description: String
/// 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
/// 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 {
struct Item: Equatable {
let label: String
let description: String
}
static let priceRatings = [
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
Item(label: "Okay", description: "More than 1.5p/L, up to 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 = [
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: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
]
static let stationLabels = [
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: "Closest station", description: "The nearest station by journey distance."),
Item(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
]
static let favourite = Item(
label: "Favourites",
description: "Star a station to save it to your Favourites."
)
static let helpTitle = "How does Best Value work?"
}
static let priceRatings = [
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
]
/// 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
static let savings = [
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: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
]
/// 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")
}
}
static let stationLabels = [
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: "CLOSEST", description: "The nearest station by journey distance."),
Item(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
]
static let favourite = Item(
label: "Favourites",
description: "Star a station to save it to your Favourites."
)
static let helpTitle = "How does Best Value work?"
}
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.
///