Show the saving in the listing capsule on its own line

Station and Favourite rows share one capsule, so both now read
"Save / £3.10" and "Costs / £1.20 more" instead of the amount-free
"Saves" / "Costs more". "(approx.)" is appended on its own line when the
journey behind the figure was estimated. Break even carries no figure —
±£0.05 is noise — so it stays a single line unless the qualifier adds one.

- Split `listingCapsuleHeadline()` into a structured `ListingVerdict`
  (headline / amount / qualifier) drawn as one Text per line, so where the
  capsule breaks is decided by the data rather than by the width the price
  column happened to hand it: the figure can never straddle two lines.
- The capsule's shape follows its line count: one line stays a Capsule, two
  or more switch to a rounded rect at 8pt, whose corners do not crowd a
  taller block the way a pill's semicircular ends do.
- The Key sheet's legend renders the same component with `showsAmount:
  false`, so it demonstrates the wording without displaying the placeholder
  £1.00 figure it was built from.
- The new words ("Save", "Costs", "(approx.)", "%@ more") go into
  Localizable.strings so the copy editor owns them; amounts run through
  FuelSavingsFormat.money and the amount line is monospaced.
- VoiceOver keeps the single-line form, since a drawn break is a pause
  mid-phrase when spoken.

Tests cover the line structure for all four statuses, the estimated
qualifier and the amount-free legend. Pixel measurement on the simulator
confirms the shape rule: one line measures a radius of h/2 (a pill), two
lines measure 8pt, in both light and dark.
This commit is contained in:
FuelBoard Contributor
2026-10-18 18:25:59 +01:00
parent 6cbb083208
commit c46a7748e6
5 changed files with 143 additions and 26 deletions
+43 -9
View File
@@ -147,28 +147,62 @@ struct FuelSavingsCapsule: View {
}
}
/// Amount-free, neutral verdict used by station listing rows only. Keeping this
/// separate from `FuelSavingsCapsule` preserves the detail sheet's monetary,
/// colour-coded verdict and comparison presentation.
/// The station listing's verdict capsule: the words on the first line, the figure
/// on its own line beneath, and "(approx.)" on a third when the journey is an
/// estimate.
///
/// Stations and Favourites share `StationRow`, so both render this. The detail
/// sheet keeps `FuelSavingsCapsule`'s colour-coded monetary verdict on one line;
/// the Key sheet's legend renders this with `showsAmount: false` so it never
/// displays an invented figure.
struct ListingFuelSavingsCapsule: View {
let result: FuelSavingsResult
/// Station rows carry the figure; the Key sheet's legend shows the wording only.
var showsAmount: Bool = true
private var verdict: FuelSavingsResult.ListingVerdict {
result.listingVerdict(showsAmount: showsAmount)
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 3) {
Image(systemName: symbol)
.accessibilityHidden(true)
Text(result.listingCapsuleHeadline())
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
// 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)
}
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Color.secondary.opacity(0.14)))
.background(shape.fill(Color.secondary.opacity(0.14)))
.accessibilityElement(children: .combine)
.accessibilityLabel(result.listingCapsuleHeadline())
.accessibilityLabel(verdict.spokenText)
}
/// One line keeps the capsule. Two or more switch to a rounded rect: a pill's
/// semicircular ends crowd the text corners at that height, while an 8pt
/// radius does not. Keyed to the LINE COUNT, so Break even flips shape only
/// when "(approx.)" pushes it onto a second line.
private var shape: AnyShape {
verdict.lines.count > 1
? AnyShape(RoundedRectangle(cornerRadius: 8))
: AnyShape(Capsule())
}
private var symbol: String {
+3 -1
View File
@@ -465,7 +465,9 @@ private struct KeySavingsCapsule: View {
}
var body: some View {
ListingFuelSavingsCapsule(result: result)
// The legend demonstrates the wording, not a figure: `result` above is a
// placeholder verdict, and its £1.00 must never reach the screen.
ListingFuelSavingsCapsule(result: result, showsAmount: false)
}
}
+5
View File
@@ -39,6 +39,11 @@
"The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
"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";
"Station labels" = "Station labels";
"CLOSEST" = "CLOSEST";
"Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs.";