import SwiftUI import WidgetKit /// The "Key" legend: price ratings, savings capsules, station labels, the /// favourite glyph, then the two deeper explanations. One sheet for both list /// tabs — Stations opens it from the info button in the navigation bar, /// Favourites from the button beside Edit — so the legend cannot drift between /// them. /// /// Dismisses through the environment rather than the presenting view's state, and /// takes the two nested-sheet flags as bindings, so each tab keeps owning its own /// QA launch-argument hooks. struct KeySheetView: View { @Binding var showBestValueInfo: Bool @Binding var showImportantInformation: Bool @Environment(\.dismiss) private var dismiss /// Where the user last dragged the sheet. Bound rather than fixed at 0.6 so /// dragging it up to read the Station labels section sticks, instead of /// snapping back every time the sheet is reopened. /// /// The QA launch argument sets the INITIAL detent rather than assigning one /// after the sheet appears: assigning a new selection while the sheet is still /// presenting is ignored, which left the legend stuck at 0.6 and the Station /// labels section off screen. @State private var detent: PresentationDetent = ProcessInfo.processInfo.arguments.contains("-qaKeySheetLarge") ? .large : .fraction(0.6) var body: 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(StationLabelKind.allCases, id: \.self) { kind in KeySheetDescriptionRow(item: item(for: kind)) { StationLabelBadge(kind: kind) } } } 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") { dismiss() } } } // 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) } // QA hook: `-qaKeySheetLarge` opens the legend at full height — see the // `detent` state. The 0.6 detent puts the Station labels section below the // fold on every device, including the tallest iPhone, so the three label // badges cannot otherwise be seen on screen. .presentationDetents([.fraction(0.6), .large], selection: $detent) .presentationBackground(Color(UIColor.systemGroupedBackground)) } /// The Key row's copy for a label kind. Fails loudly in debug rather than /// silently rendering a blank row if a kind is ever added without its entry. private func item(for kind: StationLabelKind) -> KeySheetContent.Item { if let match = KeySheetContent.stationLabels.first(where: { $0.label == kind.label }) { return match } assertionFailure("No KeySheetContent.stationLabels entry for \(kind)") return KeySheetContent.Item(label: kind.label, description: "") } } /// 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 cheapestStationID: String? /// The station the savings are measured against — the closest station with an /// eligible price. Passed in from the owner of the scaffold baseline so this /// list's Closest badge and the reference station cannot disagree. let closestStationID: String? let location: Coordinate? let favouriteIDs: Set 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/badge/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), // The same flag the row gets from `cheapestStationID`. isCheapestInRange: station.id == cheapestStationID, // The same flag the row gets from `closestStationID`. isClosestInRange: station.id == closestStationID, isFavourite: favouriteIDs.contains(station.id), onToggleFavourite: { onToggleFavourite(station, selectedFuel) } ) } /// The legend sheet, shared with the Favourites tab. Nothing varies by fuel: /// every station label has a fixed semantic tone, so the legend reads the same /// whichever grade the reader is looking at. private var keySheet: some View { KeySheetView( showBestValueInfo: $showBestValueInfo, showImportantInformation: $showImportantInformation ) } private func stationRow(_ station: FuelStation) -> some View { StationRow( station: station, fuel: selectedFuel, location: location, distanceUnit: distanceUnit, priceDisplayStyle: priceDisplayStyle, baselinePrice: baselinePrice, isCheapestResult: station.id == cheapestStationID, isClosestResult: station.id == closestStationID, 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: 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 /// Keyed on the shared label so the legend cannot demonstrate a word the /// listing capsule never draws. private var status: FuelSavingsStatus { switch label { case String(localized: "Saves"): return .worthTheDrive case String(localized: "Costs"): 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. /// /// 12pt is the app's one diameter: it reads at a glance beside a 22pt price /// without competing with it, and it is large enough that the three colours are /// distinguishable for colour-vision-deficient users at arm's length. The widget /// keeps a smaller dot of its own — its rows are far tighter than a phone row — /// so this size is deliberately the APP's. struct PriceRatingDot: View { let color: Color var diameter: CGFloat = PriceRatingDot.standardDiameter /// The app's standard dot diameter, in points. static let standardDiameter: CGFloat = 12 var body: some View { Circle() .fill(color) .frame(width: diameter, height: diameter) .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. /// /// One tone per label kind, from the shared palette: purple for best value, blue /// for cheapest and top (a top favourite IS the cheapest favourite, so they share /// a class), neutral for proximity. The symbol is the label's own meaning, sized /// to sit on the caption's cap height rather than to dominate it. struct StationLabelBadge: View { let kind: StationLabelKind private var tone: CapsuleTone { switch kind { case .bestValue: return .bestValue case .cheapest: return .cheapest case .closest: return .closest } } var body: some View { HStack(spacing: 3) { Image(systemName: kind.symbol) .font(.caption2.weight(.semibold)) .accessibilityHidden(true) Text(LocalizedStringKey(kind.label)) .font(.caption.weight(.semibold)) .fixedSize(horizontal: true, vertical: false) } .foregroundStyle(tone.foreground) .padding(.horizontal, 7) .padding(.vertical, 3) .background(Capsule().fill(tone.background)) } } /// Every station label in the Key is the SAME `StationLabelBadge` the listing rows /// and the More info sheet draw. /// /// The closest station used to be the exception — the legend rendered the real /// verdict capsule for it, a wider pill with different fill and shape, so it read as /// a different class of thing from Best value and Cheapest sitting above it. Now /// that its label is the single word "Closest", it belongs in the same column, in /// the same capsule, at the same size. // 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)) }