One sheet, reachable from the bottom of the Key sheet and from the bottom of Settings, with each topic as its own card and heading: - Fuel Prices, Fuel Availability, Savings & Best Value, Journey Estimates, Before You Travel and Disclaimer, in that order. - Every heading carries an SF Symbol in a fixed-width, Dynamic-Type-aware slot, so heading text lines up across sections regardless of glyph width. Icons take the accent colour, headings are semibold and primary, and each description sits beneath its heading in secondary text. - All copy lives in en.lproj/Localizable.strings keyed by the English literal, ready for the web copy editor. - The sheet, its trigger row and the .importantInformationSheet(isPresented:) modifier live in one file, so any further entry point is a one-line change and the wording cannot drift between them. - DEBUG-only `-showImportantInfo` launch hook, chained behind the Key sheet, for screenshotting without a tap.
536 lines
25 KiB
Swift
536 lines
25 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 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
|
||
|
||
/// The shared "Important information" disclaimer sheet. It lives at the
|
||
/// bottom of the Key sheet here; the same sheet is reused wherever else the
|
||
/// disclaimer is needed.
|
||
@State private var showImportantInformation = 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 — closest first, lowest price 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 ("closest" 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)
|
||
}
|
||
|
||
if fuelSavingsSettings.isConfigured && !stations.isEmpty {
|
||
Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation"))
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
// QA hook: `-showImportantInfo` opens the Key sheet and then the
|
||
// shared disclaimer on top of it, so the copy can be
|
||
// screenshotted without a tap. Same chained requirement as
|
||
// `-showBestValueInfo` above.
|
||
if ImportantInformationQA.isRequested {
|
||
showKey = true
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
showImportantInformation = true
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||
// "Setup Best Value" from the explanation sheet. That sheet closes
|
||
// itself and ContentView switches to Settings, but both of the
|
||
// sheets this view can have open are presented from HERE and each
|
||
// survived the tab switch, floating over Settings: the Key sheet
|
||
// and the More info sheet. Close whichever is up.
|
||
showKey = false
|
||
detailStation = nil
|
||
}
|
||
.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) {
|
||
keySheet
|
||
}
|
||
.sheet(item: $detailStation) { station in
|
||
detailSheet(for: station)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func detailSheet(for station: FuelStation) -> some View {
|
||
StationDetailView(
|
||
station: station,
|
||
location: location,
|
||
distanceUnit: distanceUnit,
|
||
priceStyle: priceDisplayStyle,
|
||
selectedFuel: selectedFuel,
|
||
fuelSavings: savingsForStation(station),
|
||
fuelSavingsAmount: fuelSavingsAmount,
|
||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||
isFavourite: favouriteIDs.contains(station.id),
|
||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||
)
|
||
}
|
||
|
||
private var keySheet: some View {
|
||
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 }
|
||
}
|
||
}
|
||
|
||
Section {
|
||
ImportantInformationRow { showImportantInformation = 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() }
|
||
// Nested sheet: the Key sheet stays behind the disclaimer.
|
||
.importantInformationSheet(isPresented: $showImportantInformation)
|
||
}
|
||
.presentationDetents([.fraction(0.6)])
|
||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||
}
|
||
|
||
private func stationRow(_ station: FuelStation) -> some View {
|
||
StationRow(
|
||
station: station,
|
||
fuel: selectedFuel,
|
||
location: location,
|
||
distanceUnit: distanceUnit,
|
||
priceDisplayStyle: priceDisplayStyle,
|
||
baselinePrice: baselinePrice,
|
||
isTopResult: station.id == topStationID,
|
||
isFavourite: favouriteIDs.contains(station.id),
|
||
tapAction: stationTapAction,
|
||
fuelSavings: savingsForStation(station),
|
||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||
onShowDetails: { detailStation = $0 }
|
||
)
|
||
}
|
||
|
||
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))
|
||
}
|