diff --git a/FuelBoard/CapsuleStyles.swift b/FuelBoard/CapsuleStyles.swift new file mode 100644 index 0000000..1ed886d --- /dev/null +++ b/FuelBoard/CapsuleStyles.swift @@ -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: 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 + ) + } +} diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 377f5f5..bdeae2f 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -2084,29 +2084,26 @@ struct StationRow: View { } /// One recommendation badge, above the price it annotates. Badges stay - /// TINTED with coloured text — unlike the verdict capsules, which are filled - /// with white text: a badge annotates the price rather than reporting an - /// outcome, so it should stay quieter than the pill beneath it. Tinted text - /// also keeps its contrast in both appearances at every Dynamic Type size - /// without introducing a colour outside the palette. + /// TINTED with coloured text — unlike the verdict capsule beneath, which is + /// filled: a badge annotates the price rather than reporting an outcome, so it + /// stays quieter than the pill below it, and tinted text keeps its contrast in + /// both appearances at every Dynamic Type size. 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 /// they stay out of the shared model the tests cover. 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 { - case .bestValue: - return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold") - case .cheapest: - return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range") + case .bestValue: return .bestValue + case .cheapest: return .cheapest } } - - var tint: Color { self == .bestValue ? .green : .blue } } // MARK: - Location manager diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index b9f7d92..4904fc7 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -238,7 +238,6 @@ struct FavouritesView: View { } .sheet(isPresented: $showKey) { KeySheetView( - fuelTint: activeFuel.tintColor, showBestValueInfo: $showBestValueInfo, showImportantInformation: $showImportantInformation ) diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index 907ae6e..fe5329e 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -105,38 +105,51 @@ struct FuelSavingsCapsule: View { let result: FuelSavingsResult var compact: Bool = true + /// One line of `.headline` / `.caption`, scaled with Dynamic Type, so the + /// shared shape rule can tell one drawn line from two. + @ScaledMetric(relativeTo: .headline) private var headlineLineHeight: CGFloat = 22 + @ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14 + + private var tone: CapsuleTone { Self.tone(for: result.status) } + var body: some View { - HStack(spacing: compact ? 3 : 5) { - Image(systemName: Self.symbol(for: result.status)) - .accessibilityHidden(true) - Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline()) - .lineLimit(1) - // Measured on a 393pt screen: the capsule is handed ~101pt, of - // which the icon, its spacing and the 8pt padding either side - // take 32pt, leaving ~69pt for the text. "Closest station" needs - // ~90pt at full size, so a 0.8 floor — 72pt — could not reach it - // and the row showed "Closest stati…" instead. 0.7 leaves ~63pt, - // which clears it at ~10pt type: scaled, never truncated. - .minimumScaleFactor(0.7) + AdaptiveCapsule( + fill: tone.background, + lineHeight: compact ? captionLineHeight : headlineLineHeight, + horizontalPadding: compact ? 8 : 12, + verticalPadding: compact ? 4 : 7 + ) { + HStack(spacing: compact ? 3 : 5) { + Image(systemName: Self.symbol(for: result.status)) + .accessibilityHidden(true) + Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline()) + // The detail sheet's capsule sits beside a headline and has the + // width for its wording; the listing's is narrow and scales. + .lineLimit(compact ? 1 : nil) + .minimumScaleFactor(compact ? 0.7 : 1) + } + .font(compact ? .caption.weight(.semibold) : .headline) + .foregroundStyle(tone.foreground) } - .font(compact ? .caption.weight(.semibold) : .headline) - .foregroundStyle(Self.color(for: result.status)) - .padding(.horizontal, compact ? 8 : 12) - .padding(.vertical, compact ? 4 : 7) - .background(Capsule().fill(Self.color(for: result.status).opacity(0.15))) } - /// Verdict colour: green save, red worse off, grey break even, blue - /// baseline — the same green/amber/red family the price dot already uses. - static func color(for status: FuelSavingsStatus) -> Color { + /// Verdict tone: green save, red worse off, grey break even, neutral-grey + /// baseline — the same meanings the price dot's RAG family carries. + static func tone(for status: FuelSavingsStatus) -> CapsuleTone { switch status { - case .worthTheDrive: return .green - case .notWorthTheDrive: return .red - case .breakEven: return .secondary - case .baseline: return .blue + case .worthTheDrive: return .saves + case .notWorthTheDrive: return .costs + case .breakEven: return .breakEven + case .baseline: return .closestStation } } + /// Foreground-only form, for a `Text` that is tinted rather than filled (the + /// detail sheet's Total row). Keeps one source for the verdict colour. + static func color(for status: FuelSavingsStatus) -> Color { + tone(for: status).foreground + } + static func symbol(for status: FuelSavingsStatus) -> String { switch status { case .worthTheDrive: return "checkmark.circle.fill" @@ -160,65 +173,63 @@ struct ListingFuelSavingsCapsule: View { /// Station rows carry the figure; the Key sheet's legend shows the wording only. var showsAmount: Bool = true - /// The height the text actually drew, so the shape can follow the drawn line - /// count rather than the data's. See `drawnLineCount`. - @State private var textHeight: CGFloat = 0 - /// One caption line's height, scaled with Dynamic Type, to divide that by. + /// One caption line's height, scaled with Dynamic Type, for the shared shape + /// rule. The drawn height is divided by this, so the shape follows the lines + /// actually on screen rather than the number the verdict declares. @ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14 private var verdict: FuelSavingsResult.ListingVerdict { result.listingVerdict(showsAmount: showsAmount) } + private var tone: CapsuleTone { FuelSavingsCapsule.tone(for: result.status) } + var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 3) { - Image(systemName: symbol) - .accessibilityHidden(true) - // One Text per line, not a "\n" inside one string: each line keeps its - // own localisation key and the amount can be monospaced on its own. - // Left-aligned: the word and its figure share a left edge, so the - // block reads as a column rather than a ragged centre. - VStack(alignment: .leading, spacing: 0) { - Text(verdict.headline) - if let amount = verdict.amount { - Text(amount) - .monospacedDigit() - } - if let qualifier = verdict.qualifier { - Text(qualifier) + AdaptiveCapsule( + fill: tone.background, + lineHeight: captionLineHeight, + declaredLineCount: verdict.lines.count, + horizontalPadding: 8, + verticalPadding: 4 + ) { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Image(systemName: symbol) + .accessibilityHidden(true) + // One Text per line, not a "\n" inside one string: each line keeps its + // own localisation key and the amount can be monospaced on its own. + // Left-aligned: the word and its figure share a left edge, so the + // block reads as a column rather than a ragged centre. + VStack(alignment: .leading, spacing: 0) { + Text(verdict.headline) + if let amount = verdict.amount { + Text(amount) + .monospacedDigit() + } + if let qualifier = verdict.qualifier { + Text(qualifier) + } } + .multilineTextAlignment(.leading) + // Never compress or truncate: the capsule wraps and the row grows taller. + .fixedSize(horizontal: false, vertical: true) } - .multilineTextAlignment(.leading) - // Never compress or truncate: the capsule wraps and the row grows taller. - .fixedSize(horizontal: false, vertical: true) - .background( - GeometryReader { proxy in - Color.clear - .preference(key: CapsuleTextHeightKey.self, - value: proxy.size.height) - } - ) + // Tinted foreground on a tinted fill. These capsules carry a figure the + // eye is asked to read at a glance, so they are filled rather than + // tinted-text-only; every badge above them stays the quieter tinted form. + .font(.caption.weight(.semibold)) + .foregroundStyle(tone.foreground) } - // White on the muted fill. These capsules carry a figure the eye is asked - // to read at a glance, which is why they are the only filled-and-white - // surfaces in the app; every badge stays tinted with coloured text. - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) // Saves and Costs share one minimum width, so their trailing edges line // up down the list whatever the figure's length. Break even and Closest // station carry no figure and keep their natural width. .frame(minWidth: minimumWidth, alignment: .leading) - .background(shape.fill(fill)) .accessibilityElement(children: .combine) .accessibilityLabel(verdict.spokenText) - .onPreferenceChange(CapsuleTextHeightKey.self) { textHeight = $0 } } - /// Saves and Costs only — the two verdicts that carry a figure. See the - /// frame above: 84pt is the width the widest common amount needs - /// ("£123.45 more"), so shorter figures pad rather than shrink the pill. + /// Saves and Costs only — the two verdicts that carry a figure. 84pt is the + /// width the widest common amount needs ("£123.45 more"), so shorter figures + /// pad rather than shrink the pill. private var minimumWidth: CGFloat { switch result.status { case .worthTheDrive, .notWorthTheDrive: return 84 @@ -226,30 +237,6 @@ struct ListingFuelSavingsCapsule: View { } } - private var fill: Color { - ListingCapsuleFill.color(for: result.status) - } - - /// How many lines the text DREW, not how many the verdict declares. - /// "Closest station" is one declared line that wraps to two in the row's - /// narrow column, and the shape has to follow what is on screen or the pill's - /// semicircular ends crowd the text they are wrapping. Falls back to the - /// declared count on the first layout pass, before there is a height. - private var drawnLineCount: Int { - guard textHeight > 0, captionLineHeight > 0 else { return verdict.lines.count } - return max(1, Int((textHeight / captionLineHeight).rounded())) - } - - /// One line keeps the capsule. Two or more switch to a rounded rect: a pill's - /// semicircular ends crowd the text corners at that height, while an 8pt - /// radius does not. Keyed to the DRAWN line count, so a wrapped "Closest - /// station" gets the same rounded rect as a figure pushed onto its own line. - private var shape: AnyShape { - drawnLineCount > 1 - ? AnyShape(RoundedRectangle(cornerRadius: 8)) - : AnyShape(Capsule()) - } - private var symbol: String { switch result.status { case .worthTheDrive: return "checkmark.circle" @@ -259,43 +246,3 @@ struct ListingFuelSavingsCapsule: View { } } } - -/// Carries the drawn height of a capsule's text block up to `drawnLineCount`. -private struct CapsuleTextHeightKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) - } -} - -/// The station listing's verdict-capsule fills: muted semantic colours carrying -/// white text and icon. -/// -/// Muted AND fixed, deliberately not the translucent tints used elsewhere in the -/// app: white text on a 15% tint over a white row measures ~1.8:1 and is -/// unreadable. Each tone below sits at a relative luminance of ~0.18 — the band -/// where white text clears 4.5:1 AND the pill still separates ~3:1 from BOTH a -/// white row and the dark card fill — so one value each works in both -/// appearances without a dynamic colour. -/// -/// The saturated palette colours cannot carry white text at all: measured -/// white-on-fill is 2.2:1 for `systemGreen`, 3.6:1 for `systemRed` and 4.0:1 for -/// `systemBlue`, every one of them below the 4.5:1 a caption needs. Muting is -/// therefore a contrast requirement here, not only the mockup's taste. -enum ListingCapsuleFill { - // Trailing comments record, per colour: white-on-fill / fill-vs-white-row / - // fill-vs-dark-row, measured rather than estimated. - static let saves = Color(red: 0x45 / 255, green: 0x82 / 255, blue: 0x5E / 255) // 4.56 / 4.56 / 3.06 - static let costs = Color(red: 0xA9 / 255, green: 0x63 / 255, blue: 0x5B / 255) // 4.54 / 4.54 / 3.07 - static let breakEven = Color(red: 0x70 / 255, green: 0x70 / 255, blue: 0x70 / 255) // 4.95 / 4.95 / 2.81 - static let closest = Color(red: 0x58 / 255, green: 0x78 / 255, blue: 0x9D / 255) // 4.52 / 4.52 / 3.09 - - static func color(for status: FuelSavingsStatus) -> Color { - switch status { - case .worthTheDrive: return saves - case .notWorthTheDrive: return costs - case .breakEven: return breakEven - case .baseline: return closest - } - } -} diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index f7ca052..67b4a59 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -11,9 +11,6 @@ import WidgetKit /// takes the two nested-sheet flags as bindings, so each tab keeps owning its own /// QA launch-argument hooks. 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 showImportantInformation: Bool @@ -47,9 +44,9 @@ struct KeySheetView: View { } Section { - ForEach(KeySheetContent.stationLabels, id: \.label) { item in - KeySheetDescriptionRow(item: item) { - KeyStationLabelIndicator(label: item.label, fuelColor: fuelTint) + ForEach(StationLabelKind.allCases, id: \.self) { kind in + KeySheetDescriptionRow(item: item(for: kind)) { + KeyStationLabelIndicator(kind: kind) } } } header: { @@ -87,6 +84,16 @@ struct KeySheetView: View { .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. @@ -384,12 +391,11 @@ struct StationsView: View { ) } - /// The legend sheet, shared with the Favourites tab. Only the fuel differs: - /// its colour tints the station-label row, so the legend matches the rows the - /// reader is actually looking at. + /// 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 { KeySheetView( - fuelTint: selectedFuel.tintColor, showBestValueInfo: $showBestValueInfo, showImportantInformation: $showImportantInformation ) @@ -473,10 +479,12 @@ private struct KeySheetDescriptionRow: View { private struct KeySavingsCapsule: View { let label: String + /// Keyed on the shared label so the legend cannot demonstrate a word the + /// listing capsule never draws. private var status: FuelSavingsStatus { switch label { - case "Saves": return .worthTheDrive - case "Costs more": return .notWorthTheDrive + case String(localized: "Saves"): return .worthTheDrive + case String(localized: "Costs"): return .notWorthTheDrive default: return .breakEven } } @@ -502,13 +510,23 @@ private struct KeySavingsCapsule: View { } /// 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 { 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 { Circle() .fill(color) - .frame(width: 8, height: 8) + .frame(width: diameter, height: diameter) .accessibilityHidden(true) } } @@ -525,43 +543,51 @@ struct FavouriteStarGlyph: View { /// 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. +/// +/// 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 { - let label: String - let color: Color + let kind: StationLabelKind + + 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 { - Text(LocalizedStringKey(label)) - .font(.caption2.bold()) - .foregroundStyle(color) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(Capsule().fill(color.opacity(0.15))) - .fixedSize(horizontal: true, vertical: false) + HStack(spacing: 3) { + Image(systemName: kind.symbol) + .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(.vertical, 3) + .background(Capsule().fill(tone.background)) } } +/// 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 { - let label: String - let fuelColor: Color + let kind: StationLabelKind var body: some View { - switch label { - 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. + if kind == .closestStation { ListingFuelSavingsCapsule(result: baselineResult) - default: - Text("TOP") - .font(.caption2.bold()) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(fuelColor.opacity(0.18), in: Capsule()) - .fixedSize(horizontal: true, vertical: false) + } else { + StationLabelBadge(kind: kind) } } diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index e2e3b94..74a2097 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -9,9 +9,10 @@ "Alerts" = "Alerts"; "Settings" = "Settings"; "FuelBoard" = "FuelBoard"; -"TOP" = "TOP"; -"BEST VALUE" = "BEST VALUE"; -"CHEAPEST" = "CHEAPEST"; +"Top" = "Top"; +"Best value" = "Best value"; +"Cheapest" = "Cheapest"; +"Closest station" = "Closest station"; /* Stations tab */ "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."; "Break even" = "Break even"; "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."; /* Station listing verdict capsule: the words above the figure */ -"Costs" = "Costs"; "(approx.)" = "(approx.)"; "%@ more" = "%@ more"; "Station labels" = "Station labels"; -"CLOSEST" = "CLOSEST"; +"Closest station" = "Closest station"; "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 nearest station by journey distance." = "The nearest station by journey distance."; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift index 86dafe8..fabd701 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift @@ -271,17 +271,51 @@ final class KeySheetContentTests: XCTestCase { XCTAssertEqual(KeySheetContent.savings, [ .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: "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" }) } + /// 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() { XCTAssertEqual(KeySheetContent.stationLabels, [ - .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: "CLOSEST", description: "The nearest station by journey distance."), - .init(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.") + .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: "Closest station", description: "The nearest station by journey distance."), + .init(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.") ]) } diff --git a/FuelBoardWatch Watch App/ContentView.swift b/FuelBoardWatch Watch App/ContentView.swift index 99816af..952a83b 100644 --- a/FuelBoardWatch Watch App/ContentView.swift +++ b/FuelBoardWatch Watch App/ContentView.swift @@ -516,8 +516,10 @@ struct ContentView: View { .fontWeight(favourite.isCheapest ? .semibold : .regular) .lineLimit(1) if favourite.isCheapest { - Text("TOP") - .font(.system(size: 9, weight: .bold)) + // Sentence case, to match every other surface: the watch + // 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(.vertical, 2) .background(fuel.color.opacity(0.18), in: Capsule()) diff --git a/Shared/FuelSavings.swift b/Shared/FuelSavings.swift index 3411fd5..98d03ad 100644 --- a/Shared/FuelSavings.swift +++ b/Shared/FuelSavings.swift @@ -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. ///