diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index 694335e..80d46e9 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -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 { diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index c5b522f..34d41bb 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -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) } } diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 028d834..4d0a5ef 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -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."; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift index f95b7a6..d1a0751 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift @@ -18,7 +18,7 @@ final class FuelSavingsEstimateTests: XCTestCase { ) private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) - func testListingCapsuleUsesSixShortFinancialVerdictsWithoutAmounts() { + func testListingCapsulePutsTheFigureOnItsOwnLine() { func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult { FuelSavingsResult( fillLitres: 30, @@ -31,12 +31,33 @@ final class FuelSavingsEstimateTests: XCTestCase { ) } - XCTAssertEqual(result(.worthTheDrive, .confirmed).listingCapsuleHeadline(), "Saves") - XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), "Saves (approx.)") - XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingCapsuleHeadline(), "Costs more") - XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingCapsuleHeadline(), "Costs more (approx.)") - XCTAssertEqual(result(.breakEven, .confirmed).listingCapsuleHeadline(), "Break even") - XCTAssertEqual(result(.breakEven, .estimated).listingCapsuleHeadline(), "Break even (approx.)") + // 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"]) + XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines, + ["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() { diff --git a/Shared/FuelSavings.swift b/Shared/FuelSavings.swift index d4b833f..0ff7f42 100644 --- a/Shared/FuelSavings.swift +++ b/Shared/FuelSavings.swift @@ -263,18 +263,73 @@ extension FuelSavingsResult { headline(compact: true) } - /// Short, amount-free verdict used only by station listing rows. The detail - /// sheet continues to use `capsuleHeadline()` so its full monetary verdict is - /// unchanged. Confidence follows the existing journey provenance exactly. - func listingCapsuleHeadline() -> String { - let approximate = journeyConfidence.isEstimated ? " (approx.)" : "" + /// The station-listing capsule's verdict, split into the lines the capsule + /// draws. + /// + /// The amount always takes its OWN line, so where the capsule breaks is + /// decided by the data, never by the width it was handed: the figure can + /// 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". + 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 "Saves\(approximate)" - case .notWorthTheDrive: return "Costs more\(approximate)" - case .breakEven: return "Break even\(approximate)" - case .baseline: return Self.baselineHeadline + 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 + } } /// One station's journey distance, plus how it was obtained.