diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 7d6aa89..2a759fe 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -1846,8 +1846,8 @@ struct StationRow: View { /// styles. The mockup's divider sits at ~53% of its row; this is that ratio, /// held per row rather than derived from the row's text — the point is that /// the hairline, the price axis and the star cannot move as the list scrolls. - /// The price column takes the remainder, and its savings capsule scales down - /// instead of widening the column and dragging the hairline with it. + /// The price column takes the remainder, and its listing verdict wraps + /// vertically instead of widening the column and dragging the hairline. /// /// One value for both price styles: the star used to sit at the end of the /// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L` @@ -1857,11 +1857,6 @@ struct StationRow: View { /// in pounds & pence, and names keep the same room in both styles. private static let leftSectionShare: CGFloat = 0.52 - /// The comparison label sits beside the capsule only where the row is wide - /// enough for both (regular width). On an iPhone the mockup's canvas is - /// wider than the whole screen, so the label drops under the capsule rather - /// than starving the station name. - @Environment(\.horizontalSizeClass) private var sizeClass private var ragColor: Color { guard let price = station.prices[fuel], let baselinePrice else { return .gray } @@ -2099,24 +2094,14 @@ struct StationRow: View { .buttonStyle(.borderless) } - /// The savings capsule plus the comparison it is measured against. The - /// capsule itself is the shared verdict view, so a row, the detail sheet and - /// the compact summary cannot drift apart. The comparison label sits UNDER - /// the capsule, trailing-aligned with it, at every width — beside it, a long - /// verdict pushed the caption off the row and the pair no longer read as one - /// statement. + /// Listing-only verdict. It deliberately omits money and the repeated + /// comparison caption; the full monetary result remains in More info. @ViewBuilder private func savingsCapsule(_ result: FuelSavingsResult) -> some View { - VStack(alignment: .trailing, spacing: 2) { + if result.status == .baseline { FuelSavingsCapsule(result: result) - Text(FuelSavingsFormat.comparisonLabel(status: result.status)) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - // Same narrow column as the capsule above it, so the same floor: - // scaled copy, not a trimmed "vs closest st…". - .minimumScaleFactor(0.7) - .multilineTextAlignment(.trailing) + } else { + ListingFuelSavingsCapsule(result: result) } } diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index a9b24c4..6c4abd6 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -157,6 +157,15 @@ struct FavouritesView: View { } } + if showsSavingsComparisonExplanation { + Section { + Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation")) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Section { ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in StationRow( @@ -180,9 +189,6 @@ struct FavouritesView: View { } footer: { VStack(alignment: .leading, spacing: 12) { Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation")) - if showsSavingsComparisonExplanation { - Text(String(localized: "Savings are compared with your nearest station, just like in the Stations tab.", comment: "Favourites fuel-savings explanation")) - } } .font(.footnote) .foregroundStyle(.secondary) diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index 4d9d7d5..c7ab7dd 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -93,8 +93,8 @@ struct FuelSavingsAdjustmentView: View { } } -/// The colour-coded verdict capsule shared by the station listing rows, the -/// detail sheet and the compact summary. +/// The colour-coded monetary verdict capsule used by the detail sheet and other +/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`. /// /// An HStack with an explicit spacing, NOT a Label: given the width to spare, /// a Label lays its icon and title out across the row and leaves a visible gap @@ -145,4 +145,38 @@ struct FuelSavingsCapsule: View { case .baseline: return "location.circle.fill" } } +} + +/// 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. +struct ListingFuelSavingsCapsule: View { + let result: FuelSavingsResult + + 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) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color.secondary.opacity(0.14))) + .accessibilityElement(children: .combine) + .accessibilityLabel(result.listingCapsuleHeadline()) + } + + private var symbol: String { + switch result.status { + case .worthTheDrive: return "checkmark.circle" + case .notWorthTheDrive: return "arrow.up.circle" + case .breakEven: return "equal.circle" + case .baseline: return "location.circle" + } + } } \ No newline at end of file diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index a081c79..9098380 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -123,6 +123,13 @@ struct StationsView: View { FuelSavingsAdjustmentView(amount: $fuelSavingsAmount) } + if fuelSavingsSettings.isConfigured && !stations.isEmpty { + Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation")) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if isLoading && stations.isEmpty { HStack(spacing: 10) { ProgressView() diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 45eb0cc..5abe8a0 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey."; "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back."; "Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it."; -"Savings are compared with your nearest station, just like in the Stations tab." = "Savings are compared with your nearest station, just like in the Stations tab."; +"Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs." = "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs."; "Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall."; "Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available."; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift index cd8be6a..c13b63c 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift @@ -18,6 +18,27 @@ final class FuelSavingsEstimateTests: XCTestCase { ) private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) + func testListingCapsuleUsesSixShortFinancialVerdictsWithoutAmounts() { + func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult { + FuelSavingsResult( + fillLitres: 30, + grossSavingGBP: status == .notWorthTheDrive ? 0 : 12.50, + extraDrivingCostGBP: status == .notWorthTheDrive ? 123.45 : 0, + extraDistanceMiles: 2, + netSavingGBP: status == .notWorthTheDrive ? -123.45 : 12.50, + status: status, + journeyConfidence: confidence + ) + } + + 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.)") + } + private func station(_ id: String, price: Double?, lat: Double = 51.5, diff --git a/Shared/FuelSavings.swift b/Shared/FuelSavings.swift index 2fb89e7..496cfd4 100644 --- a/Shared/FuelSavings.swift +++ b/Shared/FuelSavings.swift @@ -242,6 +242,19 @@ extension FuelSavingsResult { func capsuleHeadline() -> String { 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.)" : "" + switch status { + case .worthTheDrive: return "Saves\(approximate)" + case .notWorthTheDrive: return "Costs more\(approximate)" + case .breakEven: return "Break even\(approximate)" + case .baseline: return Self.baselineHeadline + } + } } /// One station's journey distance, plus how it was obtained.