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 baselinePrice: Double? let topStationID: String? let location: Coordinate? let favouriteIDs: Set var onToggleFavourite: (FuelStation) -> 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 var body: some View { NavigationStack { List { Section { if let location { if sortMode == .closest { Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(stationLimit) miles.") .font(.footnote) .foregroundStyle(.secondary) } else { Text("Cheapest \(selectedFuel.displayName) within \(stationLimit) miles — tap a station for directions.") .font(.footnote) .foregroundStyle(.secondary) } } else { Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions.") .font(.footnote) .foregroundStyle(.secondary) } } Section("Fuel type") { Picker("Fuel type", selection: $selectedFuel) { ForEach(FuelType.allCases) { fuel in Text(fuel.shortName).tag(fuel) } } .pickerStyle(.segmented) .onChange(of: selectedFuel) { _, newValue in FuelStore.saveSelectedFuel(newValue) WidgetCenter.shared.reloadAllTimelines() } } Section("Distance") { Picker("Distance", selection: $stationLimit) { ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in Text("\(miles) miles").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) if sortMode == .closest { Text("Distance only applies to Cheapest") .font(.caption2) .foregroundStyle(.secondary) } else { Text("\(totalCount) stations within \(stationLimit) miles") .font(.caption2) .foregroundStyle(.secondary) } } 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 { Text("No \(selectedFuel.displayName) stations found.") .foregroundStyle(.secondary) } else { ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in StationRow( station: station, fuel: selectedFuel, location: location, baselinePrice: baselinePrice, isTopResult: station.id == topStationID, isFavourite: favouriteIDs.contains(station.id), onToggleFavourite: { onToggleFavourite(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) } } } Section("Key") { HStack(spacing: 8) { Circle().fill(.green).frame(width: 12, height: 12) Text("Best value — within 1.5p of the cheapest") .font(.caption) } HStack(spacing: 8) { Circle().fill(.orange).frame(width: 12, height: 12) Text("Okay — within 4p of the cheapest") .font(.caption) } HStack(spacing: 8) { Circle().fill(.red).frame(width: 12, height: 12) Text("Pricey — more than 4p over the cheapest") .font(.caption) } HStack(spacing: 8) { Text("TOP") .font(.caption2.bold()) .padding(.horizontal, 5) .padding(.vertical, 1) .background(Capsule().fill(.blue.opacity(0.15))) .foregroundStyle(.blue) Text("Top result for the current sort") .font(.caption) } HStack(spacing: 8) { Image(systemName: "star.fill") .font(.caption2) .foregroundStyle(.yellow) Text("Star a station to add it to Favourites") .font(.caption) } } } .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) } } } // MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only) extension FuelType { /// Short segment label ("Unleaded (E10)" / "Premium (E5)" / "Diesel") for /// the picker and description. var shortName: String { switch self { case .e10: return "Unleaded (E10)" case .e5: return "Premium (E5)" case .diesel: return "Diesel" } } /// Pump-handle colour convention (UK): green = unleaded, blue = premium, /// dark grey = diesel. Used for the fuel-type tab icons and the title icon. var tintColor: Color { switch self { case .e10: return .green case .e5: return .blue case .diesel: return Color(red: 0.35, green: 0.38, blue: 0.42) } } }