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 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 var body: some View { NavigationStack { List { Section { if let location { if sortMode == .closest { Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit)))) · \(totalCount) stations") .font(.footnote) .foregroundStyle(.secondary) } else { Text("Cheapest \(selectedFuel.displayName) within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit)))) — \(totalCount) stations · 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") { 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 { 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, distanceUnit: distanceUnit, priceDisplayStyle: priceDisplayStyle, baselinePrice: baselinePrice, isTopResult: station.id == topStationID, isFavourite: favouriteIDs.contains(station.id), onToggleFavourite: { onToggleFavourite(station, selectedFuel) } ) } 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" / "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 colour wheel (user-chosen palette): green = unleaded (#30D158), /// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the /// fuel-type tab icons and the title icon. var tintColor: Color { switch self { case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158 case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF } } } /// 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 } var body: some View { HStack(spacing: 3) { ForEach(fuels) { fuel in let isSelected = fuel == selection Button { selection = fuel onSelect(fuel) } label: { HStack(spacing: 5) { Image(systemName: "fuelpump.fill") .font(.caption2) .foregroundStyle(fuel.tintColor) Text(fuel.shortName) .font(.subheadline.weight(isSelected ? .semibold : .regular)) } .foregroundStyle(isSelected ? Color.primary : Color.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 7) .background { if isSelected { Capsule() .fill(Color(UIColor.systemBackground)) .shadow(color: .black.opacity(0.08), radius: 1, y: 0.5) } } } .buttonStyle(.plain) } } .padding(3) .background(Capsule().fill(Color(.secondarySystemBackground))) } }