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 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 /// 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 = sortMode == .closest ? "Closest" : "Cheapest" 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 — nearest first, best value 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 ("nearest" 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(SortMode.allCases) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) .onChange(of: sortMode) { _, newValue in FuelStore.saveSortMode(newValue) } .padding(.vertical, 2) if isLoading { 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: station, fuel: selectedFuel, location: location, distanceUnit: distanceUnit, priceDisplayStyle: priceDisplayStyle, baselinePrice: baselinePrice, isTopResult: station.id == topStationID, isFavourite: favouriteIDs.contains(station.id), tapAction: stationTapAction, onToggleFavourite: { onToggleFavourite(station, selectedFuel) }, onShowDetails: { detailStation = $0 } ) } 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. .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 } #endif } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showKey = true } label: { Image(systemName: "info.circle") } .accessibilityLabel("Key") } } .sheet(isPresented: $showKey) { NavigationStack { List { Section { VStack(spacing: 10) { keyCard( icon: "checkmark.circle.fill", color: .green, title: "Best value", blurb: "Within 1.5p of the cheapest", threshold: "≤1.5p") keyCard( icon: "equal.circle.fill", color: .orange, title: "Okay", blurb: "Within 4p of the cheapest", threshold: "≤4p") keyCard( icon: "exclamationmark.circle.fill", color: .red, title: "Pricey", blurb: "More than 4p over 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("Colours match each station's rating on the list") .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.top, 4) } .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } .navigationTitle("Key") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showKey = false } } } } .presentationDetents([.fraction(0.6)]) .presentationBackground(Color(UIColor.systemGroupedBackground)) } .sheet(item: $detailStation) { station in StationDetailView( station: station, location: location, distanceUnit: distanceUnit ) } } } /// 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) } 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)) }