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:
@@ -147,28 +147,62 @@ struct FuelSavingsCapsule: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Amount-free, neutral verdict used by station listing rows only. Keeping this
|
/// The station listing's verdict capsule: the words on the first line, the figure
|
||||||
/// separate from `FuelSavingsCapsule` preserves the detail sheet's monetary,
|
/// on its own line beneath, and "(approx.)" on a third when the journey is an
|
||||||
/// colour-coded verdict and comparison presentation.
|
/// 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 {
|
struct ListingFuelSavingsCapsule: View {
|
||||||
let result: FuelSavingsResult
|
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 {
|
var body: some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
Text(result.listingCapsuleHeadline())
|
// One Text per line, not a "\n" inside one string: each line keeps its
|
||||||
.lineLimit(nil)
|
// own localisation key and the amount can be monospaced on its own.
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
// 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)
|
.multilineTextAlignment(.leading)
|
||||||
|
// Never compress or truncate: the capsule wraps and the row grows taller.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(Capsule().fill(Color.secondary.opacity(0.14)))
|
.background(shape.fill(Color.secondary.opacity(0.14)))
|
||||||
.accessibilityElement(children: .combine)
|
.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 {
|
private var symbol: String {
|
||||||
|
|||||||
@@ -465,7 +465,9 @@ private struct KeySavingsCapsule: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
"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 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.";
|
"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";
|
"Station labels" = "Station labels";
|
||||||
"CLOSEST" = "CLOSEST";
|
"CLOSEST" = "CLOSEST";
|
||||||
"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.";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
func testListingCapsuleUsesSixShortFinancialVerdictsWithoutAmounts() {
|
func testListingCapsulePutsTheFigureOnItsOwnLine() {
|
||||||
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
||||||
FuelSavingsResult(
|
FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
@@ -31,12 +31,33 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingCapsuleHeadline(), "Saves")
|
// The figure takes its own line, so the capsule's shape is decided by the
|
||||||
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), "Saves (approx.)")
|
// data rather than by how much width the price column hands it.
|
||||||
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingCapsuleHeadline(), "Costs more")
|
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingCapsuleHeadline(), "Costs more (approx.)")
|
["Save", "£12.50"])
|
||||||
XCTAssertEqual(result(.breakEven, .confirmed).listingCapsuleHeadline(), "Break even")
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
|
||||||
XCTAssertEqual(result(.breakEven, .estimated).listingCapsuleHeadline(), "Break even (approx.)")
|
["Save", "£12.50", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more", "(approx.)"])
|
||||||
|
|
||||||
|
// Break even carries no figure — ±£0.05 is noise — so it stays one line
|
||||||
|
// when confirmed and gains a second only for the estimate qualifier.
|
||||||
|
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
|
||||||
|
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
|
||||||
|
["Break even", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest station"])
|
||||||
|
|
||||||
|
// The Key sheet's legend demonstrates the wording, never a figure.
|
||||||
|
XCTAssertEqual(
|
||||||
|
result(.notWorthTheDrive, .confirmed).listingVerdict(showsAmount: false).lines,
|
||||||
|
["Costs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceOver keeps a single line: a drawn break is a spoken pause.
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
|
||||||
|
"Save £12.50 (approx.)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
||||||
|
|||||||
+65
-10
@@ -263,17 +263,72 @@ extension FuelSavingsResult {
|
|||||||
headline(compact: true)
|
headline(compact: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short, amount-free verdict used only by station listing rows. The detail
|
/// The station-listing capsule's verdict, split into the lines the capsule
|
||||||
/// sheet continues to use `capsuleHeadline()` so its full monetary verdict is
|
/// draws.
|
||||||
/// unchanged. Confidence follows the existing journey provenance exactly.
|
///
|
||||||
func listingCapsuleHeadline() -> String {
|
/// The amount always takes its OWN line, so where the capsule breaks is
|
||||||
let approximate = journeyConfidence.isEstimated ? " (approx.)" : ""
|
/// decided by the data, never by the width it was handed: the figure can
|
||||||
switch status {
|
/// never straddle two lines, and a taller verdict grows the capsule downward
|
||||||
case .worthTheDrive: return "Saves\(approximate)"
|
/// instead of widening the row's fixed price column.
|
||||||
case .notWorthTheDrive: return "Costs more\(approximate)"
|
struct ListingVerdict: Equatable {
|
||||||
case .breakEven: return "Break even\(approximate)"
|
/// "Save", "Costs", "Break even" or "Closest station".
|
||||||
case .baseline: return Self.baselineHeadline
|
let headline: String
|
||||||
|
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
|
||||||
|
let amount: String?
|
||||||
|
/// "(approx.)" when the journey behind the figure was estimated.
|
||||||
|
let qualifier: String?
|
||||||
|
|
||||||
|
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
|
||||||
|
|
||||||
|
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
|
||||||
|
/// in the middle of a phrase when spoken.
|
||||||
|
var spokenText: String { lines.joined(separator: " ") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verdict for the station listing rows — Stations and Favourites share one
|
||||||
|
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
|
||||||
|
/// which demonstrates the wording without inventing a figure.
|
||||||
|
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
|
||||||
|
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
|
||||||
|
switch status {
|
||||||
|
case .worthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Save"),
|
||||||
|
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Costs"),
|
||||||
|
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .breakEven:
|
||||||
|
// No figure: ±£0.05 is noise, so this stays a single line unless the
|
||||||
|
// estimate qualifier adds its own.
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Break even"),
|
||||||
|
amount: nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .baseline:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: Self.baselineHeadline,
|
||||||
|
amount: nil,
|
||||||
|
qualifier: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
|
||||||
|
private static func amountWithMore(_ value: Double) -> String {
|
||||||
|
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line verdict for voice and tests; the capsule itself renders
|
||||||
|
/// `listingVerdict()`'s lines separately.
|
||||||
|
func listingCapsuleHeadline() -> String {
|
||||||
|
listingVerdict().spokenText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user