288 lines
13 KiB
Swift
288 lines
13 KiB
Swift
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<String>
|
|
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
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
.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 {
|
|
// 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
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showKey = true
|
|
} label: {
|
|
Image(systemName: "info.circle")
|
|
}
|
|
.accessibilityLabel("Key")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showKey) {
|
|
NavigationStack {
|
|
List {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Key")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") { showKey = false }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)))
|
|
}
|
|
}
|