Files
fuelboard/FuelBoard/StationsView.swift
T

270 lines
12 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 baselinePrice: Double?
let topStationID: String?
let location: Coordinate?
let favouriteIDs: Set<String>
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
/// True once the list has scrolled — the large header title is shown at
/// rest; the compact gas-pump+title bar appears only after scrolling.
@State private var isScrolled = false
var body: some View {
NavigationStack {
List {
// Large title header (icon + title). Native navigation titles
// can't carry an icon, so this is a real row at the top of the
// list. It cross-fades with the compact bar: once the list
// scrolls, this large title fades out in place while the
// smaller icon+title bar fades in below the nav bar (Mail-style).
HStack(spacing: 10) {
Image(systemName: "fuelpump.fill")
.font(.system(size: 34))
.foregroundStyle(selectedFuel.tintColor)
Text("FuelBoard")
.font(.largeTitle.bold())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowBackground(Color.clear)
.opacity(isScrolled ? 0 : 1)
.animation(.easeInOut(duration: 0.2), value: isScrolled)
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
}
.modifier(ScrolledTracker(isScrolled: $isScrolled))
.toolbar {
ToolbarItem(placement: .principal) {
if isScrolled {
HStack(spacing: 6) {
Image(systemName: "fuelpump.fill")
.foregroundStyle(selectedFuel.tintColor)
Text("FuelBoard")
.font(.headline)
}
.transition(.opacity)
}
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
}
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
/// Tracks whether the list has scrolled, using the native scroll-geometry API
/// (iOS 18+) — the reliable way to know when the large header title has
/// collapsed. (The old GeometryReader-preference approach didn't fire in List,
/// which is why the compact bar never appeared.)
private struct ScrolledTracker: ViewModifier {
@Binding var isScrolled: Bool
func body(content: Content) -> some View {
if #available(iOS 18.0, *) {
content
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y > 40
} action: { _, scrolled in
withAnimation(.easeInOut(duration: 0.2)) {
isScrolled = scrolled
}
}
} else {
content
}
}
}
extension FuelType {
/// Short segment label ("Unleaded" / "Premium" / "Diesel") for the picker.
var shortName: String {
switch self {
case .e10: return "Unleaded"
case .e5: return "Premium"
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)
}
}
}