Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
244f9be640 |
+46
-37
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user