The two bottom cards (TOP, star) previously had no padding — their background hugged the text. Add .padding(.horizontal, 14).padding(.vertical, 12), matching (slightly exceeding) the RAG cards' vertical padding so the info cards read as proper cards.
375 lines
17 KiB
Swift
375 lines
17 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 let location {
|
|
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 {
|
|
// 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 {
|
|
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([.medium])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 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)))
|
|
}
|
|
}
|