Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9e71f7ac |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
HStack(spacing: compact ? 3 : 5) {
|
AdaptiveCapsule(
|
||||||
Image(systemName: Self.symbol(for: result.status))
|
fill: tone.background,
|
||||||
.accessibilityHidden(true)
|
lineHeight: compact ? captionLineHeight : headlineLineHeight,
|
||||||
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
horizontalPadding: compact ? 8 : 12,
|
||||||
.lineLimit(1)
|
verticalPadding: compact ? 4 : 7
|
||||||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
) {
|
||||||
// which the icon, its spacing and the 8pt padding either side
|
HStack(spacing: compact ? 3 : 5) {
|
||||||
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
Image(systemName: Self.symbol(for: result.status))
|
||||||
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
.accessibilityHidden(true)
|
||||||
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
||||||
// which clears it at ~10pt type: scaled, never truncated.
|
// The detail sheet's capsule sits beside a headline and has the
|
||||||
.minimumScaleFactor(0.7)
|
// 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
|
/// 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,65 +173,63 @@ 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 {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
AdaptiveCapsule(
|
||||||
Image(systemName: symbol)
|
fill: tone.background,
|
||||||
.accessibilityHidden(true)
|
lineHeight: captionLineHeight,
|
||||||
// One Text per line, not a "\n" inside one string: each line keeps its
|
declaredLineCount: verdict.lines.count,
|
||||||
// own localisation key and the amount can be monospaced on its own.
|
horizontalPadding: 8,
|
||||||
// Left-aligned: the word and its figure share a left edge, so the
|
verticalPadding: 4
|
||||||
// block reads as a column rather than a ragged centre.
|
) {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||||
Text(verdict.headline)
|
Image(systemName: symbol)
|
||||||
if let amount = verdict.amount {
|
.accessibilityHidden(true)
|
||||||
Text(amount)
|
// One Text per line, not a "\n" inside one string: each line keeps its
|
||||||
.monospacedDigit()
|
// 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
|
||||||
if let qualifier = verdict.qualifier {
|
// block reads as a column rather than a ragged centre.
|
||||||
Text(qualifier)
|
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)
|
// Tinted foreground on a tinted fill. These capsules carry a figure the
|
||||||
// Never compress or truncate: the capsule wraps and the row grows taller.
|
// eye is asked to read at a glance, so they are filled rather than
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
// tinted-text-only; every badge above them stays the quieter tinted form.
|
||||||
.background(
|
.font(.caption.weight(.semibold))
|
||||||
GeometryReader { proxy in
|
.foregroundStyle(tone.foreground)
|
||||||
Color.clear
|
|
||||||
.preference(key: CapsuleTextHeightKey.self,
|
|
||||||
value: proxy.size.height)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// 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
|
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))
|
||||||
.padding(.horizontal, 7)
|
.accessibilityHidden(true)
|
||||||
.padding(.vertical, 3)
|
Text(LocalizedStringKey(kind.label))
|
||||||
.background(Capsule().fill(color.opacity(0.15)))
|
.font(.caption.weight(.semibold))
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.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 {
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
+71
-31
@@ -364,41 +364,81 @@ 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 {
|
///
|
||||||
struct Item: Equatable {
|
/// Every label is SENTENCE CASE ("Best value", not "BEST VALUE"): the labels
|
||||||
let label: String
|
/// are read as phrases, and a capsule set in all caps reads as shouting beside
|
||||||
let description: String
|
/// 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 = [
|
/// A station label's meaning, independent of its copy.
|
||||||
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."),
|
/// The Key sheet used to dispatch its indicator on the label's literal text
|
||||||
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
/// (`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 = [
|
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
|
||||||
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
var label: String {
|
||||||
Item(label: "Break even", description: "The saving or extra cost is negligible."),
|
switch self {
|
||||||
Item(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
|
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 = [
|
var symbol: String {
|
||||||
Item(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."),
|
switch self {
|
||||||
Item(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."),
|
case .bestValue: return "medal"
|
||||||
Item(label: "CLOSEST", description: "The nearest station by journey distance."),
|
case .cheapest: return "tag"
|
||||||
Item(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
|
case .closestStation: return "location.north.fill"
|
||||||
]
|
case .top: return "trophy"
|
||||||
|
}
|
||||||
static let favourite = Item(
|
}
|
||||||
label: "Favourites",
|
}
|
||||||
description: "Star a station to save it to your Favourites."
|
|
||||||
)
|
|
||||||
|
|
||||||
static let helpTitle = "How does Best Value work?"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which recommendation badge a station listing row shows, if any.
|
/// Which recommendation badge a station listing row shows, if any.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user