491 lines
23 KiB
Swift
491 lines
23 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>
|
||
let stationTapAction: StationTapAction
|
||
let fuelSavingsSettings: FuelSavingsSettings
|
||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||
var savingsBaselineStationName: String?
|
||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||
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
|
||
|
||
/// The "How Best Value works" sheet. The explanation used to be a section in
|
||
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
|
||
/// the Key sheet and from the More info sheet.
|
||
@State private var showBestValueInfo = 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: String
|
||
switch sortMode {
|
||
case .closest: mode = "Closest"
|
||
case .cheapest: mode = "Cheapest"
|
||
case .worthTheDrive: mode = "Best Value"
|
||
}
|
||
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(availableSortModes) { mode in
|
||
Text(mode.displayName).tag(mode)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.onChange(of: sortMode) { _, newValue in
|
||
FuelStore.saveSortMode(newValue)
|
||
}
|
||
.padding(.vertical, 2)
|
||
|
||
// Only offered while the feature is actually configured — a
|
||
// stale stored sort mode must not keep the control on screen
|
||
// for a switched-off feature.
|
||
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
||
FuelSavingsAdjustmentView(
|
||
amount: $fuelSavingsAmount,
|
||
referencePricePencePerLitre: baselinePrice
|
||
)
|
||
}
|
||
|
||
if isLoading && stations.isEmpty {
|
||
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),
|
||
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
|
||
tapAction: stationTapAction,
|
||
fuelSavings: savingsForStation(station),
|
||
fuelSavingsAmount: fuelSavingsAmount,
|
||
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.
|
||
// No numeric field is left on this screen — the Worth the Drive
|
||
// fill-up control is a slider — so no keyboard should appear here.
|
||
// The Done bar and interactive dismissal stay as the escape hatch
|
||
// for any field that returns.
|
||
.keyboardDoneButton()
|
||
.scrollDismissesKeyboard(.interactively)
|
||
.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
|
||
}
|
||
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
|
||
// explanation on top of it, so the copy can be screenshotted
|
||
// without a tap. It has to chain: the explanation's `.sheet` lives
|
||
// inside the Key sheet's content, so it cannot present while the
|
||
// Key sheet is shut.
|
||
if BestValueInfoQA.isRequested {
|
||
showKey = true
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
showBestValueInfo = true
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
.task(id: stations.count) {
|
||
#if DEBUG
|
||
// QA hook: `-showDetail` opens the More info sheet for the first
|
||
// listed station, so the sheet can be screenshotted without a
|
||
// tap. Waits for the list to arrive first.
|
||
guard ProcessInfo.processInfo.arguments.contains("-showDetail"),
|
||
detailStation == nil,
|
||
let first = stations.first else { return }
|
||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||
detailStation = first
|
||
#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: "Good price", blurb: "Within 1.5p of the cheapest",
|
||
threshold: "0–1.5p")
|
||
keyCard(
|
||
icon: "equal.circle.fill", color: .orange,
|
||
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
|
||
threshold: ">1.5–4p")
|
||
keyCard(
|
||
icon: "exclamationmark.circle.fill", color: .red,
|
||
title: "Pricey", blurb: "More than 4p above 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("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 4)
|
||
}
|
||
.listRowBackground(Color.clear)
|
||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||
}
|
||
|
||
// The explanation is its own sheet now, opened from this
|
||
// row. Gated exactly like the More info row and the Best
|
||
// Value card itself: `isConfigured` is the same condition
|
||
// `savingsForStation` uses, so the row appears and
|
||
// disappears with the feature.
|
||
if fuelSavingsSettings.isConfigured {
|
||
Section {
|
||
HowBestValueRow { showBestValueInfo = true }
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Key")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Done") { showKey = false }
|
||
}
|
||
}
|
||
// Nested sheet: the Key sheet stays behind the explanation.
|
||
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||
}
|
||
.presentationDetents([.fraction(0.6)])
|
||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||
}
|
||
.sheet(item: $detailStation) { station in
|
||
StationDetailView(
|
||
station: station,
|
||
location: location,
|
||
distanceUnit: distanceUnit,
|
||
fuelSavings: savingsForStation(station),
|
||
fuelSavingsAmount: fuelSavingsAmount,
|
||
savingsBaselineStationName: savingsBaselineStationName,
|
||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||
isFavourite: favouriteIDs.contains(station.id),
|
||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var availableSortModes: [SortMode] {
|
||
var modes: [SortMode] = [.closest, .cheapest]
|
||
if fuelSavingsSettings.isConfigured {
|
||
modes.append(.worthTheDrive)
|
||
}
|
||
return modes
|
||
}
|
||
|
||
/// 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)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
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))
|
||
}
|