Files
fuelboard/FuelBoard/StationDetailView.swift
T
FuelBoard Contributor 2a2823c180 Accurate distances toggle, Approx. distance markers, and Closest-mode speedup
Distance confidence
- Every distance surface takes the figure AND its "Approx." marker from one
  FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget,
  Live Activity, watch, Siri), so a surface can never mark one figure approximate
  while showing a routed one. Radius filters and Closest ordering test that same
  displayed distance, so no row can read a distance outside the radius that
  filtered it (the reported "14.6 mi inside a 10-mile list").

Accurate distances (Settings -> Distances, default off)
- Off: the browse layer serves the straight line, marked Approx., for every row,
  so two installs agree to the metre and the app makes no route requests at all.
- On: routed distances for the whole search area, unmarked, served only from a
  COMPLETE set anchored within 600 m — a partial fill reverts every row at once
  rather than mixing road and straight-line bases.
- Best Value maths sits deliberately outside the toggle: it keeps using measured
  road routes and its own confidence marking, so money figures never move with
  the switch.
- Fills run only while the toggle is on and the app is frontmost; capacity raised
  to 600 stations per pass; completeness tolerates pins Apple Maps has no route
  for, which otherwise left an area permanently incomplete and the whole list
  stuck on estimates.

Performance (the reported slowdown switching to Closest / launching with it)
- FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per
  call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per
  Closest sort. Memoised, with the setter keeping it in step.
- Both keyed sorts decorate their keys — one distance per station, carried as an
  index instead of copying whole stations into the decorated array — and the
  comparison baseline plus per-station journeys now come from ONE pass instead of
  two full scans per tab. The Stations tab also reuses its single sorted list for
  the count and the TOP badge instead of sorting the country three times.
- Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per
  list, Cheapest 6 ms, Best Value 11 ms.
- DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and
  FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when
  a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where
  devicectl eats a leading-dash argument.

Tests: 198, 0 failures. Device and simulator Debug builds green.
2026-10-13 18:01:45 +01:00

155 lines
7.1 KiB
Swift

import SwiftUI
import MapKit
import UIKit
/// DEBUG-only explanation of the Best Value numbers for one station. It carries
/// no precise user coordinates and is never persisted.
struct FuelSavingsJourneyDiagnostics {
let rows: [(label: String, value: String)]
}
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
/// Shows a small map preview, the address, distance from the current location,
/// and a Directions button that opens Apple Maps — the same destination a direct
/// tap would have opened. The station name lives in the sheet header, not in a
/// row.
struct StationDetailView: View {
let station: FuelStation
let location: Coordinate?
let distanceUnit: DistanceUnit
var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Whether this station is starred for the fuel currently being viewed, and
/// the toggle that flips it. Passed from the list that opened the sheet, so
/// the star here and the star on the row are the same fuel-scoped
/// favourite and cannot drift apart.
var isFavourite: Bool = false
var onToggleFavourite: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var showingReportIssueConfirmation = false
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
private var stationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
}
var body: some View {
NavigationStack {
List {
Section {
Map(position: .constant(.region(MKCoordinateRegion(
center: stationCoordinate,
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
)))) {
Marker(station.name, coordinate: stationCoordinate)
.tint(.red)
}
.frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
.allowsHitTesting(false)
.listRowInsets(EdgeInsets())
}
Section {
// The station name is NOT repeated here: the sheet header
// (navigationTitle, inline) already shows it, and the address
// line below has any leading repeat of the name stripped, so
// a "Name" row only said it twice.
LabeledContent("Address", value: StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name))
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
LabeledContent("Distance", value: distance.label(unit: distanceUnit))
.monospacedDigit()
}
ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14)))
}
}
// Only shown while Fuel Savings backs it. `fuelSavings` is nil
// when the feature is off — `savingsLookup` returns nil when it
// is not configured — so the heading cannot be left dangling
// over an empty section.
if let fuelSavings {
Section {
FuelSavingsSummaryView(
availability: fuelSavings,
amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit
)
} header: {
Text("Best Value")
}
}
#if DEBUG
if let journeyDiagnostics {
Section("Best Value Diagnostics") {
ForEach(journeyDiagnostics.rows, id: \.label) { row in
LabeledContent(row.label, value: row.value)
.font(.caption)
.monospacedDigit()
}
}
}
#endif
Section {
Button {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
}
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
}
}
}
.navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline)
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Report") {
UIApplication.shared.open(reportIssueURL)
}
} message: {
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button {
onToggleFavourite?()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
}
.tint(isFavourite ? Color.yellow : nil)
.disabled(onToggleFavourite == nil)
.accessibilityLabel(
isFavourite
? String(localized: "Remove from favourites", comment: "Unstar a station from the More info sheet")
: String(localized: "Add to favourites", comment: "Star a station from the More info sheet")
)
}
}
}
}
}