Files
fuelboard/FuelBoard/StationsView.swift
T
FuelBoard Contributor 2c6e63f6b1 live activity: tint pump glyph by monitored fuel
Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A,
cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and
Live Activity all read one definition (was app-only in StationsView, so the
widget couldn't see it). Tint the Live Activity rich card pump icon and the
Dynamic Island compactLeading pump by context.state.fuel.tintColor.
2026-08-19 22:18:56 +01:00

388 lines
18 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
/// 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 = sortMode == .closest ? "Closest" : "Cheapest"
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 · tap a station for directions."
}
return "\(mode) \(fuel)\(ratio) stations updated · tap a station for directions."
}
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(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 {
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),
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)
}
}
}
// 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.
.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
}
#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: "Best value", blurb: "Within 1.5p of the cheapest",
threshold: "≤1.5p")
keyCard(
icon: "equal.circle.fill", color: .orange,
title: "Okay", blurb: "Within 4p of the cheapest",
threshold: "≤4p")
keyCard(
icon: "exclamationmark.circle.fill", color: .red,
title: "Pricey", blurb: "More than 4p over 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("Colours match each station's rating on the list")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.top, 4)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
}
}
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
}
}
/// 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)
}
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))
}