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
FuelBoard Contributor fcb11a8f52 Bump the build number to 30
29 was used for two different binaries during the listing refinement, so
the next install would have been indistinguishable from the last one.
Raised in all eight configurations — app, widget, watch app and watch
extension, Debug and Release — so the whole bundle reports one number.
2026-10-18 20:18:00 +01:00
FuelBoard Contributor ab93f6286a Add the Key button to the Favourites tab
Favourites gains the same info button the Stations list has, last in
the trailing toolbar group so it sits to the right of Edit. It stays
visible when the list is empty, because the legend explains the prices
and labels before anything is starred — Trends and Edit still hide
with no favourites.

The legend itself is now one view, KeySheetView, lifted out of
StationsView's private computed property so both tabs present the
same sheet and cannot drift apart. It lives in StationsView.swift
because the key's row types are file-private there, which keeps the
Xcode project untouched. Stations passes its selected fuel's tint and
Favourites its active tab's, so the station-label pump badge matches
the rows on screen; everything else — detents, the two nested sheets,
and the -showKeySheet / -showBestValueInfo / -showImportantInfo QA
launch hooks — behaves exactly as before.
2026-10-18 20:16:30 +01:00
FuelBoard Contributor 6edab35400 Give the closest capsule the rounded rect whenever it wraps
The capsule's shape was keyed to the verdict's declared line count, so
"Closest station" — one declared line — always drew as a Capsule while
its text wrapped to two lines in the row's narrow column, leaving the
semicircular ends crowding the words they wrapped. Saves never showed
this because its amount line makes the declared count two.

The shape now follows the DRAWN height: a GeometryReader behind the
text block reports what it actually rendered, divided by the caption's
line height taken through ScaledMetric so the test holds at every
Dynamic Type size. One drawn line keeps the capsule; two or more get
the same 8pt rounded rect the other verdicts use. The first layout
pass, before there is a height, falls back to the declared count.
2026-10-18 20:16:30 +01:00
10 changed files with 549 additions and 262 deletions
+8 -8
View File
@@ -533,7 +533,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -599,7 +599,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -663,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -728,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -826,7 +826,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -851,7 +851,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -875,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -900,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
+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
+25 -1
View File
@@ -48,6 +48,14 @@ struct FavouritesView: View {
/// Trends sheet (price history chart) presentation state. /// Trends sheet (price history chart) presentation state.
@State private var showTrends = false @State private var showTrends = false
/// The Key (legend) sheet — the same sheet the Stations tab presents, opened
/// from the info button beside Edit.
@State private var showKey = false
/// The Key sheet's two nested sheets, owned here because its rows set them
/// (same arrangement as StationsView, so the QA hooks behave identically).
@State private var showBestValueInfo = false
@State private var showImportantInformation = false
/// Station picked for the More-info sheet (tap action = showDetails). /// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation? @State private var detailStation: FuelStation?
@@ -206,8 +214,8 @@ struct FavouritesView: View {
} }
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title")) .navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
.toolbar { .toolbar {
if !favourites.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) { ToolbarItemGroup(placement: .topBarTrailing) {
if !favourites.isEmpty {
Button { Button {
showTrends = true showTrends = true
} label: { } label: {
@@ -216,7 +224,23 @@ struct FavouritesView: View {
} }
EditButton() EditButton()
} }
// Same button and same slot as the Stations tab: last in the
// group, so it sits to the right of Edit. Shown even with no
// favourites — the legend explains the prices and labels, which
// matter before anything is starred.
Button {
showKey = true
} label: {
Image(systemName: "info.circle")
} }
.accessibilityLabel(String(localized: "Key", comment: "Key button accessibility label"))
}
}
.sheet(isPresented: $showKey) {
KeySheetView(
showBestValueInfo: $showBestValueInfo,
showImportantInformation: $showImportantInformation
)
} }
.sheet(isPresented: $showTrends) { .sheet(isPresented: $showTrends) {
TrendsView( TrendsView(
+54 -75
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,11 +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
/// 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 { 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)
@@ -186,25 +213,23 @@ struct ListingFuelSavingsCapsule: View {
// 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)
} }
// White on the muted fill. These capsules carry a figure the eye is asked // Tinted foreground on a tinted fill. These capsules carry a figure the
// to read at a glance, which is why they are the only filled-and-white // eye is asked to read at a glance, so they are filled rather than
// surfaces in the app; every badge stays tinted with coloured text. // tinted-text-only; every badge above them stays the quieter tinted form.
.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)
} }
/// 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
@@ -212,20 +237,6 @@ struct ListingFuelSavingsCapsule: View {
} }
} }
private var fill: Color {
ListingCapsuleFill.color(for: result.status)
}
/// 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 LINE COUNT, so Break even flips shape only
/// when "(approx.)" pushes it onto a second line.
private var shape: AnyShape {
verdict.lines.count > 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"
@@ -235,35 +246,3 @@ struct ListingFuelSavingsCapsule: View {
} }
} }
} }
/// 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
}
}
}
+150 -94
View File
@@ -1,6 +1,101 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
/// The "Key" legend: price ratings, savings capsules, station labels, the
/// favourite glyph, then the two deeper explanations. One sheet for both list
/// tabs — Stations opens it from the info button in the navigation bar,
/// Favourites from the button beside Edit — so the legend cannot drift between
/// them.
///
/// Dismisses through the environment rather than the presenting view's state, and
/// takes the two nested-sheet flags as bindings, so each tab keeps owning its own
/// QA launch-argument hooks.
struct KeySheetView: View {
@Binding var showBestValueInfo: Bool
@Binding var showImportantInformation: Bool
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
}
} header: {
Text("Price ratings")
}
Section {
ForEach(KeySheetContent.savings, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeySavingsCapsule(label: item.label)
}
}
} header: {
Text("Savings")
}
Section {
ForEach(StationLabelKind.allCases, id: \.self) { kind in
KeySheetDescriptionRow(item: item(for: kind)) {
KeyStationLabelIndicator(kind: kind)
}
}
} header: {
Text("Station labels")
}
Section {
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
FavouriteStarGlyph(isFavourite: true)
}
} header: {
Text("Favourites")
}
Section {
HowBestValueRow { showBestValueInfo = true }
}
Section {
ImportantInformationRow { showImportantInformation = true }
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
// Nested sheet: the Key sheet stays behind the disclaimer.
.importantInformationSheet(isPresented: $showImportantInformation)
}
.presentationDetents([.fraction(0.6), .large])
.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.
struct StationsView: View { struct StationsView: View {
let stations: [FuelStation] let stations: [FuelStation]
@@ -296,73 +391,14 @@ struct StationsView: View {
) )
} }
/// The legend sheet, shared with the Favourites tab. Nothing varies by fuel:
/// every station label has a fixed semantic tone, so the legend reads the same
/// whichever grade the reader is looking at.
private var keySheet: some View { private var keySheet: some View {
NavigationStack { KeySheetView(
List { showBestValueInfo: $showBestValueInfo,
Section { showImportantInformation: $showImportantInformation
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) { )
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
}
} header: {
Text("Price ratings")
}
Section {
ForEach(KeySheetContent.savings, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeySavingsCapsule(label: item.label)
}
}
} header: {
Text("Savings")
}
Section {
ForEach(KeySheetContent.stationLabels, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeyStationLabelIndicator(label: item.label, fuelColor: selectedFuel.tintColor)
}
}
} header: {
Text("Station labels")
}
Section {
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
FavouriteStarGlyph(isFavourite: true)
}
} header: {
Text("Favourites")
}
Section {
HowBestValueRow { showBestValueInfo = true }
}
Section {
ImportantInformationRow { showImportantInformation = true }
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
// Nested sheet: the Key sheet stays behind the disclaimer.
.importantInformationSheet(isPresented: $showImportantInformation)
}
.presentationDetents([.fraction(0.6), .large])
.presentationBackground(Color(UIColor.systemGroupedBackground))
} }
private func stationRow(_ station: FuelStation) -> some View { private func stationRow(_ station: FuelStation) -> some View {
@@ -443,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
} }
} }
@@ -472,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)
} }
} }
@@ -495,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())
+50 -10
View File
@@ -364,10 +364,16 @@ struct FuelSavingsRankedStation: Equatable {
let pricePencePerLitre: Double let pricePencePerLitre: Double
} }
/// 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.
enum KeySheetContent { ///
/// 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 { struct Item: Equatable {
let label: String let label: String
let description: String let description: 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(
@@ -398,7 +406,39 @@ 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.
/// ///