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 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 /// 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 } } #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 { VStack(spacing: 10) { keyCard( icon: "checkmark.circle.fill", color: .green, title: "Good price", blurb: "Within 1.5p of the cheapest", threshold: "0–1.5p") keyCard( icon: "equal.circle.fill", color: .orange, title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest", threshold: ">1.5–4p") keyCard( icon: "exclamationmark.circle.fill", color: .red, title: "Pricey", blurb: "More than 4p above the cheapest", threshold: ">4p") Rectangle() .fill(Color(.separator)) .frame(height: 0.5) .padding(.vertical, 2) HStack(spacing: 12) { Text("TOP") .font(.caption2.bold()) .padding(.horizontal, 7) .padding(.vertical, 3) .background(Capsule().fill(.blue.opacity(0.16))) .foregroundStyle(.blue) Text("Top result for the current sort") .font(.subheadline.weight(.semibold)) Spacer(minLength: 0) } .padding(.horizontal, 14) .padding(.vertical, 12) .keyCardFill(color: .blue) HStack(spacing: 12) { Image(systemName: "star.fill") .font(.title3) .foregroundStyle(.yellow) .frame(width: 34) Text("Star a station to add it to Favourites") .font(.subheadline.weight(.semibold)) Spacer(minLength: 0) } .padding(.horizontal, 14) .padding(.vertical, 12) .keyCardFill(color: .yellow) Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.") .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .multilineTextAlignment(.center) .padding(.top, 4) } .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } // The explanation is its own sheet now, opened from this // row. Gated exactly like the More info row and the Best // Value card itself: `isConfigured` is the same condition // `savingsForStation` uses, so the row appears and // disappears with the feature. if fuelSavingsSettings.isConfigured { Section { HowBestValueRow { showBestValueInfo = 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() } } .presentationDetents([.fraction(0.6)]) .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 } /// One price-rating card for the Key sheet: accent icon, title + blurb, /// and the rating threshold in the accent colour (mirrors the tip cards). private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.title3) .foregroundStyle(color) .frame(width: 34) VStack(alignment: .leading, spacing: 2) { Text(LocalizedStringKey(title)).font(.headline) Text(LocalizedStringKey(blurb)) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 8) HStack(spacing: 6) { Circle().fill(color).frame(width: 10, height: 10) Text(threshold) .font(.headline.weight(.bold)) .foregroundStyle(color) .monospacedDigit() } } .padding(.horizontal, 14) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .keyCardFill(color: color) } } /// Card background shared by the Key sheet cards — subtle accent tint over /// the grouped background, matching the tip-section card style. private extension View { func keyCardFill(color: Color) -> some View { background( RoundedRectangle(cornerRadius: 12) .fill(Color(.secondarySystemGroupedBackground)) .overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08))) ) } } // 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)) }