Files
fuelboard/FuelBoard/StationsView.swift
FuelBoard Contributor c46a7748e6 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.
2026-10-18 18:25:59 +01:00

628 lines
26 KiB
Swift

import SwiftUI
import WidgetKit
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
struct StationsView: View {
let stations: [FuelStation]
let totalCount: Int
let isLoading: Bool
@Binding var selectedFuel: FuelType
@Binding var sortMode: SortMode
@Binding var stationLimit: Int
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double?
let topStationID: String?
let location: Coordinate?
let favouriteIDs: Set<String>
let stationTapAction: StationTapAction
let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {}
/// Pagination: one page = 10 rows, reset whenever the underlying list
/// changes (new fetch, fuel/sort/radius switch). RAG/TOP/deltas still come
/// from the whole radius pool via `cheapestPrice` — paging never changes
/// which station is "best".
@State private var pageSize = 10
@State private var visibleCount = 10
/// Price-rating legend (the "Key") lives in a sheet, opened from the
/// info button in the header — keeps the list focused on stations.
@State private var showKey = false
/// The "How Best Value works" sheet. The explanation used to be a section in
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
/// the Key sheet and from the More info sheet.
@State private var showBestValueInfo = false
/// The shared "Important information" disclaimer sheet. It lives at the
/// bottom of the Key sheet here; the same sheet is reused wherever else the
/// disclaimer is needed.
@State private var showImportantInformation = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
/// directions context + "N/TOTAL stations updated". The numerator is the
/// current list pool; the denominator is the full UK station total from
/// the last chain fetch (8,022 mirror snapshot) — hidden until known.
private var footerCaption: String {
let miles = distanceUnit.displayMiles(stationLimit)
let unit = distanceUnit.label(for: Double(miles))
let fuel = selectedFuel.displayName
let mode: String
switch sortMode {
case .closest: mode = "Closest"
case .cheapest: mode = "Cheapest"
case .worthTheDrive: mode = "Best Value"
}
let tapHint = stationTapAction == .openMap
? "tap a station for directions."
: "tap a station for details."
let ratio: String
if let total = FuelStore.loadStationCount() {
ratio = "\(totalCount)/\(total)"
} else {
ratio = "\(totalCount)"
}
if location != nil {
if sortMode == .closest {
return "\(mode) \(fuel) stations — closest first, lowest price within \(miles) \(unit) · \(ratio) stations updated"
}
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
}
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
}
var body: some View {
NavigationStack {
List {
Section("Fuel type") {
FuelTypeSegmentedPicker(selection: $selectedFuel) { newValue in
FuelStore.saveSelectedFuel(newValue)
WidgetCenter.shared.reloadAllTimelines()
}
}
Section("Distance") {
Picker("Distance", selection: $stationLimit) {
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
let shown = distanceUnit.displayMiles(miles)
Text("\(shown) \(distanceUnit.label(for: Double(shown)))").tag(miles)
}
}
.pickerStyle(.segmented)
// In Closest mode the whole country is the pool — the
// radius is meaningless ("closest" must never return an
// empty state), so the picker is disabled but its value is
// kept for when the user switches back to Cheapest.
.disabled(sortMode == .closest)
.opacity(sortMode == .closest ? 0.5 : 1)
.onChange(of: stationLimit) { _, newValue in
FuelStore.saveStationLimit(newValue)
}
.padding(.vertical, 2)
}
Section("Stations") {
Picker("Sort by", selection: $sortMode) {
ForEach(availableSortModes) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.onChange(of: sortMode) { _, newValue in
FuelStore.saveSortMode(newValue)
}
.padding(.vertical, 2)
// Only offered while the feature is actually configured — a
// stale stored sort mode must not keep the control on screen
// for a switched-off feature.
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
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()
Text("Fetching prices…")
}
} else if stations.isEmpty {
let miles = distanceUnit.displayMiles(stationLimit)
let unit = distanceUnit.label(for: Double(miles))
VStack(alignment: .leading, spacing: 6) {
Text(String(format: NSLocalizedString("No %@ stations within %lld %@.", comment: "Empty state when the radius finds nothing nearby"), selectedFuel.displayName, miles, unit))
.foregroundStyle(.secondary)
Text("FuelBoard covers the whole UK — try Closest to browse every station.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Browse all UK stations") {
sortMode = .closest
}
.font(.caption)
}
.padding(.vertical, 4)
} else {
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
stationRow(station)
}
if visibleCount < stations.count {
Button {
visibleCount += pageSize
} label: {
HStack {
Spacer()
Text("Show \(min(pageSize, stations.count - visibleCount)) more (\(stations.count - visibleCount) remaining)")
Spacer()
}
}
} else {
Text("All \(stations.count) stations shown")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
}
// Bottom-of-tab explainer (moved from the top 2026-08-16),
// styled like the Favourites tab's cheapest callout: a caption
// paragraph in its own section cell.
Section {
Text(footerCaption)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.refreshable {
// Manual override for the twice-a-day cache policy.
await onRefresh()
}
.onChange(of: stations) { _, _ in
// New fetch or filter switch → back to the first page.
visibleCount = pageSize
}
// Native large-title behaviour (Mail-style): the system shows the
// large title at rest, scrolls it away and fades the compact
// inline title in. No custom toolbar needed — the native mechanism
// only renders text, which is exactly what we want here.
// No numeric field is left on this screen — the Worth the Drive
// fill-up control is a slider — so no keyboard should appear here.
// The Done bar and interactive dismissal stay as the escape hatch
// for any field that returns.
.keyboardDoneButton()
.scrollDismissesKeyboard(.interactively)
.navigationTitle("FuelBoard")
.navigationBarTitleDisplayMode(.large)
.onAppear {
#if DEBUG
// QA hook: launch with `-showKeySheet` to verify the legend
// sheet without driving a tap (simctl has no tap command).
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
showKey = true
}
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
// explanation on top of it, so the copy can be screenshotted
// without a tap. It has to chain: the explanation's `.sheet` lives
// inside the Key sheet's content, so it cannot present while the
// Key sheet is shut.
if BestValueInfoQA.isRequested {
showKey = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
showBestValueInfo = true
}
}
// QA hook: `-showImportantInfo` opens the Key sheet and then the
// shared disclaimer on top of it, so the copy can be
// screenshotted without a tap. Same chained requirement as
// `-showBestValueInfo` above.
if ImportantInformationQA.isRequested {
showKey = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
showImportantInformation = true
}
}
#endif
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
// "Setup Best Value" from the explanation sheet. That sheet closes
// itself and ContentView switches to Settings, but both of the
// sheets this view can have open are presented from HERE and each
// survived the tab switch, floating over Settings: the Key sheet
// and the More info sheet. Close whichever is up.
showKey = false
detailStation = nil
}
.task(id: stations.count) {
#if DEBUG
// QA hook: `-showDetail` opens the More info sheet for the first
// listed station, so the sheet can be screenshotted without a
// tap. Waits for the list to arrive first.
guard ProcessInfo.processInfo.arguments.contains("-showDetail"),
detailStation == nil,
let first = stations.first else { return }
try? await Task.sleep(nanoseconds: 2_000_000_000)
detailStation = first
#endif
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showKey = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel("Key")
}
}
.sheet(isPresented: $showKey) {
keySheet
}
.sheet(item: $detailStation) { station in
detailSheet(for: station)
}
}
}
private func detailSheet(for station: FuelStation) -> some View {
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: selectedFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
journeyDiagnostics: journeyDiagnosticsForStation(station),
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
)
}
private var keySheet: some View {
NavigationStack {
List {
Section {
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
}
} header: {
Text("Price ratings")
}
Section {
ForEach(KeySheetContent.savings, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeySavingsCapsule(label: item.label)
}
}
} header: {
Text("Savings")
}
Section {
ForEach(KeySheetContent.stationLabels, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeyStationLabelIndicator(label: item.label, fuelColor: selectedFuel.tintColor)
}
}
} header: {
Text("Station labels")
}
Section {
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
FavouriteStarGlyph(isFavourite: true)
}
} header: {
Text("Favourites")
}
Section {
HowBestValueRow { showBestValueInfo = true }
}
Section {
ImportantInformationRow { showImportantInformation = true }
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
// Nested sheet: the Key sheet stays behind the disclaimer.
.importantInformationSheet(isPresented: $showImportantInformation)
}
.presentationDetents([.fraction(0.6), .large])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
private func stationRow(_ station: FuelStation) -> some View {
StationRow(
station: station,
fuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
}
private var availableSortModes: [SortMode] {
var modes: [SortMode] = [.closest, .cheapest]
if fuelSavingsSettings.isConfigured {
modes.append(.worthTheDrive)
}
return modes
}
}
/// The station list's real RAG dot, paired with the rating name. The range lives
/// in the aligned description column rather than as a separate trailing value.
private struct KeyPriceRatingIndicator: View {
let item: KeySheetContent.Item
let color: Color
var body: some View {
HStack(spacing: 5) {
PriceRatingDot(color: color)
Text(LocalizedStringKey(item.label))
.font(.caption.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
}
}
}
/// Every Key section shares this one two-column row. The scaled first-column
/// width keeps every description on the same leading axis while preserving the
/// inline layout at accessibility sizes; descriptions own the flexible column
/// and grow vertically without truncating.
private struct KeySheetDescriptionRow<Indicator: View>: View {
private let indicatorColumnWidth: CGFloat = 128
let item: KeySheetContent.Item
@ViewBuilder let indicator: Indicator
var body: some View {
HStack(alignment: .top, spacing: 12) {
indicator
.frame(width: indicatorColumnWidth, alignment: .leading)
description
}
.padding(.vertical, 3)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.label). \(item.description)")
}
private var description: some View {
Text(LocalizedStringKey(item.description))
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Uses the same listing capsule component as real station rows, with neutral
/// fixture numbers: only the verdict is visible in this compact variant.
private struct KeySavingsCapsule: View {
let label: String
private var status: FuelSavingsStatus {
switch label {
case "Saves": return .worthTheDrive
case "Costs more": return .notWorthTheDrive
default: return .breakEven
}
}
private var result: FuelSavingsResult {
let netSaving = status == .worthTheDrive ? 1.0 : (status == .notWorthTheDrive ? -1.0 : 0)
return FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: netSaving,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: netSaving,
status: status,
journeyConfidence: .confirmed
)
}
var body: some View {
// 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)
}
}
/// Shared RAG dot used beside live station prices and in the Key.
struct PriceRatingDot: View {
let color: Color
var body: some View {
Circle()
.fill(color)
.frame(width: 8, height: 8)
.accessibilityHidden(true)
}
}
/// Shared favourite glyph used by station rows and the Key.
struct FavouriteStarGlyph: View {
let isFavourite: Bool
var body: some View {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
}
/// Shared station-label capsule. `StationRow` uses the same view for its live
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
struct StationLabelBadge: View {
let label: String
let color: Color
var body: some View {
Text(LocalizedStringKey(label))
.font(.caption2.bold())
.foregroundStyle(color)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(color.opacity(0.15)))
.fixedSize(horizontal: true, vertical: false)
}
}
private struct KeyStationLabelIndicator: View {
let label: String
let fuelColor: Color
var body: some View {
switch label {
case "BEST VALUE":
StationLabelBadge(label: label, color: .green)
case "CHEAPEST":
StationLabelBadge(label: label, color: .blue)
case "CLOSEST":
FuelSavingsCapsule(result: baselineResult)
default:
Text("TOP")
.font(.caption2.bold())
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(fuelColor.opacity(0.18), in: Capsule())
.fixedSize(horizontal: true, vertical: false)
}
}
private var baselineResult: FuelSavingsResult {
FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
status: .baseline,
journeyConfidence: .confirmed
)
}
}
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
extension FuelType {
/// Short segment label ("Unleaded" / "Premium" / "Diesel") for
/// the picker and description.
var shortName: String {
switch self {
case .e10: return "Unleaded"
case .e5: return "Premium"
case .diesel: return "Diesel"
}
}
}
/// Fuel-type selector styled like a segmented control, with a coloured pump
/// icon per fuel (green = unleaded, yellow = premium, cyan = diesel). Built
/// custom because the native `.segmented` picker tints every segment the same
/// accent colour — it can't show per-fuel pump colours. Shared by the Stations
/// and Favourites tabs.
struct FuelTypeSegmentedPicker: View {
@Binding var selection: FuelType
/// Which fuels to show. Defaults to all; the Favourites tab passes only
/// the fuels that actually have favourites.
var fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View {
HStack(spacing: 3) {
ForEach(fuels) { fuel in
let isSelected = fuel == selection
Button {
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel
}
onSelect(fuel)
} label: {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
}
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 7)
.background {
if isSelected {
Capsule()
.fill(Color(UIColor.systemBackground))
.overlay {
Capsule()
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
}
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
}
}
.scaleEffect(isSelected ? 1.02 : 0.98)
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground)))
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
}
}
#Preview("Fuel Picker") {
@Previewable @State var selectedFuel: FuelType = .e10
FuelTypeSegmentedPicker(selection: $selectedFuel)
.padding()
.background(Color(.systemGroupedBackground))
}