diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 2122fda..377f5f5 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -1941,14 +1941,18 @@ struct StationRow: View { // Name, full address, distance VStack(alignment: .leading, spacing: 2) { - // The name WRAPS instead of truncating: the row is tall - // enough now, and a name cut to "Z A Service Stat…" cannot - // be told apart from the station next door. Baseline - // alignment keeps the notice symbols on the name's FIRST - // line instead of floating beside a two-line name. + // The name wraps, capped at THREE lines. The row is tall + // enough for a two-line name, but an unbounded wrap let one + // very long forecourt name set the height of the whole row — + // the excessive row heights this refinement removes. Past + // three lines the name ellipsises; the full name is always + // in Station Details. Baseline alignment keeps the notice + // symbols on the name's FIRST line instead of floating + // beside a two-line name. HStack(alignment: .firstTextBaseline, spacing: 6) { Text(station.name) .font(.headline) + .lineLimit(3) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) ForEach(StationNotice.notices(for: station)) { notice in @@ -2019,27 +2023,33 @@ struct StationRow: View { if let badge = recommendationBadge { badgeCapsule(badge) } - // Price line: the status dot and the price, leading-aligned on - // the column's one axis. The star no longer ends this line — it - // sits under the station logo — so this whole column width now - // belongs to the price, which is what lets the wide pounds & - // pence form (£1.58⁷/L) render at full size rather than being - // scaled down or truncated. - if let price = station.prices[fuel] { - HStack(spacing: 5) { - PriceRatingDot(color: ragColor) - FuelStore.priceTextAttributed(price, style: priceDisplayStyle, - size: priceSize, weight: .bold) - .lineLimit(1) - .minimumScaleFactor(0.8) + // The price and its verdict capsule are ONE vertically grouped + // element: 5pt binds them as "this price, this outcome", while + // the badge keeps the column's full 12pt above so the three + // lines do not read as equals. + VStack(alignment: .leading, spacing: 5) { + // Price line: the status dot and the price, leading-aligned + // on the column's one axis. The star no longer ends this + // line — it sits under the station logo — so this whole + // column width now belongs to the price, which is what lets + // the wide pounds & pence form (£1.58⁷/L) render at full + // size rather than being scaled down or truncated. + if let price = station.prices[fuel] { + HStack(spacing: 5) { + PriceRatingDot(color: ragColor) + FuelStore.priceTextAttributed(price, style: priceDisplayStyle, + size: priceSize, weight: .bold) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + // The visible "+1.8p vs cheapest" line is gone with the + // redesign, so the comparison rides on the price's label. + .accessibilityElement(children: .ignore) + .accessibilityLabel(priceSpokenLabel(price)) + } + if let result = fuelSavings?.result { + savingsCapsule(result) } - // The visible "+1.8p vs cheapest" line is gone with the - // redesign, so the comparison rides on the price's label. - .accessibilityElement(children: .ignore) - .accessibilityLabel(priceSpokenLabel(price)) - } - if let result = fuelSavings?.result { - savingsCapsule(result) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -2065,21 +2075,20 @@ struct StationRow: View { .buttonStyle(.borderless) } - /// Listing-only verdict. It deliberately omits money and the repeated - /// comparison caption; the full monetary result remains in More info. - @ViewBuilder + /// The listing's verdict capsule — ONE component for every status, so the + /// baseline's "Closest station" pill carries the same soft blue fill, white + /// text and shape rules as Saves, Costs and Break even. The detail sheet + /// keeps `FuelSavingsCapsule` to itself, unchanged. private func savingsCapsule(_ result: FuelSavingsResult) -> some View { - if result.status == .baseline { - FuelSavingsCapsule(result: result) - } else { - ListingFuelSavingsCapsule(result: result) - } + ListingFuelSavingsCapsule(result: result) } - /// One recommendation badge, above the price it annotates. Tinted rather - /// than the mockup's solid fill, like every other badge and capsule in the - /// app: coloured text keeps its contrast in both appearances, at every - /// Dynamic Type size, without introducing a colour outside the palette. + /// 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. private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View { StationLabelBadge(label: badge.title, color: badge.tint) } diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index 80d46e9..b507ea4 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -186,15 +186,36 @@ struct ListingFuelSavingsCapsule: View { // Never compress or truncate: the capsule wraps and the row grows taller. .fixedSize(horizontal: false, vertical: true) } + // 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(.primary) + .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(shape.fill(Color.secondary.opacity(0.14))) + // 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) } + /// 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. + private var minimumWidth: CGFloat { + switch result.status { + case .worthTheDrive, .notWorthTheDrive: return 84 + case .breakEven, .baseline: return 0 + } + } + + 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 @@ -213,4 +234,36 @@ struct ListingFuelSavingsCapsule: View { case .baseline: return "location.circle" } } -} \ No newline at end of file +} + +/// 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 34d41bb..cc87de8 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -521,7 +521,10 @@ private struct KeyStationLabelIndicator: View { case "CHEAPEST": StationLabelBadge(label: label, color: .blue) case "CLOSEST": - FuelSavingsCapsule(result: baselineResult) + // 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) default: Text("TOP") .font(.caption2.bold()) diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 4d0a5ef..e2e3b94 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -40,7 +40,6 @@ "Costs more" = "Costs more"; "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 */ -"Save" = "Save"; "Costs" = "Costs"; "(approx.)" = "(approx.)"; "%@ more" = "%@ more"; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift index d1a0751..0fa07be 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift @@ -34,9 +34,9 @@ final class FuelSavingsEstimateTests: XCTestCase { // The figure takes its own line, so the capsule's shape is decided by the // data rather than by how much width the price column hands it. XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines, - ["Save", "£12.50"]) + ["Saves", "£12.50"]) XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines, - ["Save", "£12.50", "(approx.)"]) + ["Saves", "£12.50", "(approx.)"]) XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines, ["Costs", "£123.45 more"]) XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines, @@ -57,7 +57,7 @@ final class FuelSavingsEstimateTests: XCTestCase { // VoiceOver keeps a single line: a drawn break is a spoken pause. XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), - "Save £12.50 (approx.)") + "Saves £12.50 (approx.)") } func testDetailCapsulePlacesApproximationAfterTheFinancialResult() { diff --git a/Shared/FuelSavings.swift b/Shared/FuelSavings.swift index 0ff7f42..3411fd5 100644 --- a/Shared/FuelSavings.swift +++ b/Shared/FuelSavings.swift @@ -271,7 +271,7 @@ extension FuelSavingsResult { /// never straddle two lines, and a taller verdict grows the capsule downward /// instead of widening the row's fixed price column. struct ListingVerdict: Equatable { - /// "Save", "Costs", "Break even" or "Closest station". + /// "Saves", "Costs", "Break even" or "Closest station". let headline: String /// "£3.10" / "£1.20 more" — nil when there is no figure to show. let amount: String? @@ -293,7 +293,7 @@ extension FuelSavingsResult { switch status { case .worthTheDrive: return ListingVerdict( - headline: String(localized: "Save"), + headline: String(localized: "Saves"), amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil, qualifier: qualifier )