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.
This commit is contained in:
+297
-47
@@ -15,10 +15,18 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
||||
/// Bumped when a road-distance fill finishes (or the accurate-distances toggle
|
||||
/// changes). Row distances are read from the shared store INSIDE the row
|
||||
/// views, so nothing else would invalidate the list and a freshly completed
|
||||
/// fill would not appear until some other state changed.
|
||||
@State private var roadDistanceTick = 0
|
||||
/// Set from `FB_DIST_FOCUS`/`-distFocus` so a road-distance fill can re-print
|
||||
/// the affected station's display value once the new distances land.
|
||||
@State private var distFocusQuery: String?
|
||||
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
|
||||
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||
@State private var sortMode: SortMode = ContentView.qaSortModeOverride(default: FuelStore.loadSortMode())
|
||||
@State private var stationLimit: Int = ContentView.qaStationRadiusOverride(default: FuelStore.loadStationLimit())
|
||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||||
@@ -40,6 +48,40 @@ struct ContentView: View {
|
||||
return nil
|
||||
}()
|
||||
|
||||
// MARK: - QA overrides (DEBUG)
|
||||
|
||||
/// QA hooks: `-stationRadius 10` / `-sortMode cheapest` override the stored
|
||||
/// results filter and sort for ONE launch, so filtering can be verified at
|
||||
/// each radius without tapping the picker (simctl has no tap command). Never
|
||||
/// persisted — the stored setting survives untouched.
|
||||
private static func qaStationRadiusOverride(default fallback: Int) -> Int {
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if let i = args.firstIndex(of: "-stationRadius"), i + 1 < args.count,
|
||||
let miles = Int(args[i + 1]), FuelStore.stationRadiusOptions.contains(miles) {
|
||||
return miles
|
||||
}
|
||||
#endif
|
||||
return fallback
|
||||
}
|
||||
|
||||
private static func qaSortModeOverride(default fallback: SortMode) -> SortMode {
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if let i = args.firstIndex(of: "-sortMode"), i + 1 < args.count,
|
||||
let mode = SortMode(rawValue: args[i + 1]) {
|
||||
return mode
|
||||
}
|
||||
// devicectl parses a leading-dash app argument as one of its OWN options,
|
||||
// so on a real device the env form is the one that arrives.
|
||||
if let env = ProcessInfo.processInfo.environment["FB_SORT_MODE"],
|
||||
let mode = SortMode(rawValue: env) {
|
||||
return mode
|
||||
}
|
||||
#endif
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// The radius the geofence actually uses: the manual alert radius, or —
|
||||
/// when "Follow search" is on — the Stations-tab distance capped at 8 mi.
|
||||
private var effectiveAlertsRadiusKM: Double {
|
||||
@@ -152,8 +194,12 @@ struct ContentView: View {
|
||||
guard let location else { return selling }
|
||||
if sortMode == .closest { return selling } // radius disabled in Closest
|
||||
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km
|
||||
// Test the distance the ROW shows (cached road route where there is one),
|
||||
// not the straight line: a station 9.8 mi straight-line but 14.6 mi by road
|
||||
// used to pass a 10-mile filter and then display 14.6 mi inside it.
|
||||
return selling.filter {
|
||||
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
||||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||
userLat: location.lat, userLng: location.lng)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +215,8 @@ struct ContentView: View {
|
||||
if sortMode == .closest, let location {
|
||||
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km
|
||||
let within = pool.filter {
|
||||
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
||||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||
userLat: location.lat, userLng: location.lng)
|
||||
}
|
||||
if let localMin = within.compactMap({ $0.prices[selectedFuel] }).min() {
|
||||
return localMin
|
||||
@@ -182,7 +229,14 @@ struct ContentView: View {
|
||||
/// First occurrence in display order wins on price ties.
|
||||
private var topStationID: String? {
|
||||
guard let baselinePrice else { return nil }
|
||||
return displayedStations.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
||||
return topStationID(in: displayedStations, baselinePrice: baselinePrice)
|
||||
}
|
||||
|
||||
/// The TOP-badge station within an ALREADY-SORTED list. Split out so a caller
|
||||
/// that has the sorted list (the Stations tab) does not sort the whole
|
||||
/// country a second time just to find one id.
|
||||
private func topStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
|
||||
list.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
||||
}
|
||||
|
||||
/// The app's own "cheapest in range" — the TOP-badge station — mirrored
|
||||
@@ -199,7 +253,7 @@ struct ContentView: View {
|
||||
station: station,
|
||||
fuel: selectedFuel,
|
||||
radiusKM: distanceUnit.toKM(Double(stationLimit)),
|
||||
distanceKM: station.distanceKM(to: location.lat, lng2: location.lng)
|
||||
distanceKM: FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -207,54 +261,116 @@ struct ContentView: View {
|
||||
sortedStations
|
||||
}
|
||||
|
||||
/// Timing wrapper. The work lives in `computeSortedStations()` so the DEBUG
|
||||
/// probe can wrap it while Release/Archive calls the plain function.
|
||||
private var sortedStations: [FuelStation] {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["FB_PERF"] != nil {
|
||||
let started = Date()
|
||||
let result = computeSortedStations()
|
||||
print("FB-PERF sortedStations mode=\(effectiveSortMode.rawValue) stations=\(result.count) "
|
||||
+ "ms=\(String(format: "%.1f", Date().timeIntervalSince(started) * 1_000))")
|
||||
return result
|
||||
}
|
||||
#endif
|
||||
return computeSortedStations()
|
||||
}
|
||||
|
||||
/// The display list, sorted.
|
||||
///
|
||||
/// Both keyed paths DECORATE first: each station's display distance is
|
||||
/// computed once per station rather than twice per comparison, so ranking the
|
||||
/// whole country in Closest mode costs ~8k distance reads instead of ~200k.
|
||||
/// That is the difference between a list that appears instantly and a picker
|
||||
/// that hangs for seconds — the display distance is not free (it consults the
|
||||
/// accurate-distances gate and the route cache).
|
||||
private func computeSortedStations() -> [FuelStation] {
|
||||
let pool = poolStations
|
||||
switch effectiveSortMode {
|
||||
case .closest:
|
||||
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
|
||||
return pool.sorted { lhs, rhs in
|
||||
// Closest first; price only breaks ties.
|
||||
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
if lDist != rDist { return lDist < rDist }
|
||||
return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]!
|
||||
let loc = location
|
||||
var decorated = pool.indices.map { index in
|
||||
(index: index,
|
||||
distance: FuelStore.displayDistanceKM(station: pool[index], userLat: loc.lat, userLng: loc.lng))
|
||||
}
|
||||
decorated.sort { lhs, rhs in
|
||||
// Closest first; price only breaks ties. Ordered on the SAME
|
||||
// distance the row shows, or a row reading "9.9 mi" can sit above
|
||||
// one reading "8.0 mi".
|
||||
if lhs.distance != rhs.distance { return lhs.distance < rhs.distance }
|
||||
return pool[lhs.index].prices[selectedFuel]! < pool[rhs.index].prices[selectedFuel]!
|
||||
}
|
||||
return decorated.map { pool[$0.index] }
|
||||
case .cheapest:
|
||||
return pool.sorted { lhs, rhs in
|
||||
// Cheapest first; distance only breaks ties.
|
||||
let lPrice = lhs.prices[selectedFuel]!
|
||||
let rPrice = rhs.prices[selectedFuel]!
|
||||
if lPrice != rPrice { return lPrice < rPrice }
|
||||
guard let location else { return false }
|
||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
// Cheapest first; distance only breaks ties. Decorated so a tie never
|
||||
// re-reads a distance inside the comparison (equal prices are common,
|
||||
// and ties are where the old comparator did all its cache work).
|
||||
var decorated = pool.indices.map { index in
|
||||
(index: index,
|
||||
price: pool[index].prices[selectedFuel]!,
|
||||
distance: location.map {
|
||||
FuelStore.displayDistanceKM(station: pool[index], userLat: $0.lat, userLng: $0.lng)
|
||||
} ?? .greatestFiniteMagnitude)
|
||||
}
|
||||
decorated.sort { lhs, rhs in
|
||||
if lhs.price != rhs.price { return lhs.price < rhs.price }
|
||||
return lhs.distance < rhs.distance
|
||||
}
|
||||
return decorated.map { pool[$0.index] }
|
||||
case .worthTheDrive:
|
||||
return sortedForWorthTheDrive(pool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Row/detail savings lookup with the comparison baseline resolved ONCE.
|
||||
/// The comparison baseline AND each station's journey, from ONE pass.
|
||||
///
|
||||
/// `nearestSavingsBaseline` scans the whole station list, and StationsView
|
||||
/// asks for savings once per visible row (plus again for the detail sheet),
|
||||
/// so handing the view a per-station method would rescan ~8k stations
|
||||
/// dozens of times per render.
|
||||
private func savingsLookup(baseline: FuelStation?) -> (FuelStation) -> FuelSavingsAvailability? {
|
||||
/// Every savings label on a row needs this pair, and it used to be built
|
||||
/// twice per tab: `nearestSavingsBaseline` scanned all ~8k stations
|
||||
/// (computing a journey for each) and `savingsLookup` immediately scanned them
|
||||
/// all AGAIN to fill the per-station map. Both now share one result.
|
||||
///
|
||||
/// The baseline is chosen with EXACTLY the loop and guards of
|
||||
/// `FuelSavingsEstimator.baselineStation`, so the row labelled "Closest
|
||||
/// station" cannot move — and it is still selected when the feature is
|
||||
/// unconfigured (it names that row), while the maps are only built when the
|
||||
/// labels will actually be shown.
|
||||
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
||||
guard let location else { return (nil, [:]) }
|
||||
let configured = fuelSavingsSettings.isConfigured
|
||||
var journeys: [String: FuelSavingsJourney] = [:]
|
||||
var bestStation: FuelStation?
|
||||
var bestMiles = Double.infinity
|
||||
for station in stations where station.prices[selectedFuel] != nil {
|
||||
guard let journey = savingsJourneyMiles(for: station, location: location) else { continue }
|
||||
if configured { journeys[station.id] = journey }
|
||||
let miles = journey.miles
|
||||
guard miles.isFinite, miles >= 0 else { continue }
|
||||
if miles < bestMiles {
|
||||
bestMiles = miles
|
||||
bestStation = station
|
||||
}
|
||||
}
|
||||
return (bestStation, configured ? journeys : [:])
|
||||
}
|
||||
|
||||
/// Row/detail savings lookup with the comparison baseline and the per-station
|
||||
/// journeys already resolved by `savingsScaffold()`.
|
||||
///
|
||||
/// StationsView asks for savings once per visible row, so handing the view a
|
||||
/// per-station method would rescan ~8k stations dozens of times per render.
|
||||
private func savingsLookup(baseline: FuelStation?,
|
||||
journeys: [String: FuelSavingsJourney])
|
||||
-> (FuelStation) -> FuelSavingsAvailability? {
|
||||
guard fuelSavingsSettings.isConfigured else { return { _ in nil } }
|
||||
guard let location else { return { _ in .unavailable("Location unavailable") } }
|
||||
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
|
||||
let baselineJourney: FuelSavingsJourney? = baseline.flatMap {
|
||||
journeys[$0.id] ?? savingsJourneyMiles(for: $0, location: location)
|
||||
}
|
||||
let baselineID = baseline?.id
|
||||
let settings = fuelSavingsSettings
|
||||
let amount = sessionFuelSavingsAmount
|
||||
let fuel = selectedFuel
|
||||
var journeys: [String: FuelSavingsJourney] = [:]
|
||||
for station in stations {
|
||||
if journeys[station.id] == nil,
|
||||
let journey = savingsJourneyMiles(for: station, location: location) {
|
||||
journeys[station.id] = journey
|
||||
}
|
||||
}
|
||||
return { station in
|
||||
FuelSavingsEstimator.availability(
|
||||
station: station,
|
||||
@@ -271,7 +387,7 @@ struct ContentView: View {
|
||||
/// The nearest station that sells the selected fuel — the Best Value
|
||||
/// comparison baseline. It is chosen with the SAME journey values used by
|
||||
/// the savings calculation, so a cached road route can change the baseline
|
||||
/// and keep the "more driving than closest" labels synchronized with the
|
||||
/// and keep the "extra return travel" labels synchronized with the
|
||||
/// cost calculation. When no road route is cached, the journey falls back to
|
||||
/// straight-line out-and-back, and the result is marked as an estimate.
|
||||
private var nearestSavingsBaseline: FuelStation? {
|
||||
@@ -470,8 +586,62 @@ struct ContentView: View {
|
||||
FuelStore.refreshedFavourites(favourites, from: stations)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Prints the DISPLAYED distance and its source for stations matching `query`
|
||||
/// (see `FB_DIST_FOCUS`). Called once a fix is available and again whenever a
|
||||
/// road-distance fill lands: that before/after is how the accurate-distances
|
||||
/// toggle is verified on a real device, where no screenshot tool exists.
|
||||
static func logDistanceFocus(query: String, stations: [FuelStation], location: Coordinate?) {
|
||||
guard let loc = location else {
|
||||
print("FB-DIST no location fix (stations=\(stations.count))")
|
||||
return
|
||||
}
|
||||
let matches = stations.filter { $0.name.localizedCaseInsensitiveContains(query) }
|
||||
print("FB-DIST query=\"\(query)\" matches=\(matches.count) pool=\(stations.count) "
|
||||
+ "mode=\(FuelStore.accurateDistancesEnabled) "
|
||||
+ "ready=\(FuelStore.accurateDistancesReady(userLat: loc.lat, userLng: loc.lng))")
|
||||
for s in matches {
|
||||
let d = FuelStore.roadDistanceDiagnostics(for: s, userLat: loc.lat, userLng: loc.lng)
|
||||
let straight = s.distanceKM(to: loc.lat, lng2: loc.lng)
|
||||
let shown = FuelStore.stationDistanceDisplay(station: s, userLat: loc.lat, userLng: loc.lng)
|
||||
print("FB-DIST \"\(s.name)\" display=\(String(format: "%.3f", d.displayDistanceKM))km "
|
||||
+ "straight=\(String(format: "%.3f", straight))km source=\(d.displaySource.rawValue) "
|
||||
+ "outward=\(d.outwardMeters.map { String(format: "%.0f", $0) } ?? "nil")m "
|
||||
+ "return=\(d.returnMeters.map { String(format: "%.0f", $0) } ?? "nil")m "
|
||||
+ "shown=\(String(format: "%.3f", shown.km))km approx=\(shown.isApproximate)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Times the two forms of the accurate-distances read: the raw suite read
|
||||
/// (one `UserDefaults(suiteName:)` construction plus a cfprefsd round-trip,
|
||||
/// which is what `stationDistanceDisplay` did on EVERY call) against the
|
||||
/// memoised property. Prints microseconds per read so the cost of the old hot
|
||||
/// path can be multiplied out from measurement instead of guessed at.
|
||||
static func runToggleReadBenchmark() {
|
||||
let reads = 20_000
|
||||
var sink = 0
|
||||
var started = Date()
|
||||
for _ in 0..<reads where UserDefaults(suiteName: FuelStore.appGroupSuite)?
|
||||
.bool(forKey: FuelStore.accurateDistancesKey) == true { sink += 1 }
|
||||
let raw = Date().timeIntervalSince(started) * 1_000_000 / Double(reads)
|
||||
started = Date()
|
||||
for _ in 0..<reads where FuelStore.accurateDistancesEnabled { sink += 1 }
|
||||
let memo = Date().timeIntervalSince(started) * 1_000_000 / Double(reads)
|
||||
print("FB-PERF toggleRead raw=\(String(format: "%.2f", raw))µs "
|
||||
+ "memo=\(String(format: "%.4f", memo))µs sink=\(sink)")
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
configuredContent
|
||||
// Reads `roadDistanceTick` so a finished road-distance fill (or the
|
||||
// accurate-distances toggle) actually re-renders the list: the row
|
||||
// distances are read from the shared store inside the row views, so
|
||||
// nothing else would invalidate them and a completed fill would go
|
||||
// unseen until some unrelated state changed.
|
||||
.onChange(of: roadDistanceTick) { _, _ in }
|
||||
}
|
||||
|
||||
private var configuredContent: AnyView {
|
||||
@@ -508,18 +678,39 @@ struct ContentView: View {
|
||||
StationMapView(request: request)
|
||||
}
|
||||
.sheet(item: $monitor.pendingDetailStation) { station in
|
||||
// One scaffold for the sheet's savings, baseline name and
|
||||
// diagnostics — this closure used to rebuild the baseline
|
||||
// twice and scan the whole list again for the journeys.
|
||||
let scaffold = savingsScaffold()
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
fuelSavings: savingsLookup(baseline: nearestSavingsBaseline)(station),
|
||||
fuelSavings: savingsLookup(baseline: scaffold.baseline,
|
||||
journeys: scaffold.journeys)(station),
|
||||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||||
savingsBaselineStationName: nearestSavingsBaseline?.name,
|
||||
journeyDiagnostics: journeyDiagnostics(for: station, baseline: nearestSavingsBaseline),
|
||||
savingsBaselineStationName: scaffold.baseline?.name,
|
||||
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistancesUpdated)) { _ in
|
||||
roadDistanceTick &+= 1
|
||||
#if DEBUG
|
||||
if let query = distFocusQuery {
|
||||
ContentView.logDistanceFocus(query: query, stations: stations, location: location)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardAccurateDistancesChanged)) { _ in
|
||||
// Re-render (rows and the radius filter read the same store),
|
||||
// refresh the Live Activity, and start a fill from here so the
|
||||
// switch has a visible effect within seconds.
|
||||
roadDistanceTick &+= 1
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||||
let info = note.userInfo ?? [:]
|
||||
if let id = info["stationID"] as? String,
|
||||
@@ -551,6 +742,50 @@ struct ContentView: View {
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
#if DEBUG
|
||||
showWidgetMock = args.contains("-widgets")
|
||||
// `-accurateDistances 0|1` (or `FB_ACCURATE_DISTANCES`, the form that
|
||||
// survives devicectl's argument parsing) forces the setting for a
|
||||
// launch so both display bases can be exercised without tapping
|
||||
// Settings. DEBUG-only, but it does write the real setting.
|
||||
var accurateOverride: String? = nil
|
||||
if let i = args.firstIndex(of: "-accurateDistances"), i + 1 < args.count {
|
||||
accurateOverride = args[i + 1]
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["FB_ACCURATE_DISTANCES"], !env.isEmpty {
|
||||
accurateOverride = env
|
||||
}
|
||||
if let value = accurateOverride {
|
||||
FuelStore.setAccurateDistancesEnabled(value == "1")
|
||||
}
|
||||
// `FB_PERF_BENCH=1` times the toggle read the display path makes: the
|
||||
// raw `UserDefaults(suiteName:)` form (what the sort used to do per
|
||||
// comparison) against the memoised property. DEBUG-only.
|
||||
if ProcessInfo.processInfo.environment["FB_PERF_BENCH"] != nil {
|
||||
ContentView.runToggleReadBenchmark()
|
||||
}
|
||||
// `-distFocus <substring>` prints the DISPLAYED distance and its
|
||||
// source for every matching station, read from THIS device's own
|
||||
// route cache (FB-DIST). Two devices can legitimately disagree
|
||||
// (one has a routed distance, the other falls back to the straight
|
||||
// line), and this is the only way to compare them side by side
|
||||
// without a device screenshot tool. Waits (bounded) for a fix.
|
||||
var distFocusQuery: String? = nil
|
||||
if let i = args.firstIndex(of: "-distFocus"), i + 1 < args.count { distFocusQuery = args[i + 1] }
|
||||
// devicectl swallows a leading-dash app argument (it parses `-distFocus`
|
||||
// as its own `-t`), so the env form — set as DEVICECTL_CHILD_FB_DIST_FOCUS
|
||||
// — is the one that works on a real device.
|
||||
if let env = ProcessInfo.processInfo.environment["FB_DIST_FOCUS"], !env.isEmpty {
|
||||
distFocusQuery = env
|
||||
}
|
||||
if let query = distFocusQuery {
|
||||
self.distFocusQuery = query
|
||||
Task { @MainActor in
|
||||
for _ in 0..<12 {
|
||||
if location != nil, !stations.isEmpty { break }
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
}
|
||||
ContentView.logDistanceFocus(query: query, stations: stations, location: location)
|
||||
}
|
||||
}
|
||||
// `-seedFavourite <substring>` pins the first matching station
|
||||
// for Unleaded via the normal save path (keychain + app group)
|
||||
// so screenshot captures can show a populated Favourites tab.
|
||||
@@ -1087,10 +1322,19 @@ struct ContentView: View {
|
||||
/// The Stations tab, extracted from `body` so the TabView expression stays
|
||||
/// within the compiler's type-check budget.
|
||||
private var stationsTab: some View {
|
||||
let baselineStation = nearestSavingsBaseline
|
||||
// ONE scaffold (baseline + journeys) and ONE sort serve the list, its
|
||||
// count, the TOP badge and every savings label. Asking for
|
||||
// `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`,
|
||||
// `topStationID` and `savingsLookup` separately walked the full ~8k list
|
||||
// six times per render — seconds of main-thread work in Closest mode,
|
||||
// where the pool is every station in the UK.
|
||||
let scaffold = savingsScaffold()
|
||||
let baselineStation = scaffold.baseline
|
||||
let displayed = displayedStations
|
||||
let topID = baselinePrice.flatMap { topStationID(in: displayed, baselinePrice: $0) }
|
||||
return StationsView(
|
||||
stations: displayedStations,
|
||||
totalCount: sortedStations.count,
|
||||
stations: displayed,
|
||||
totalCount: displayed.count,
|
||||
isLoading: isLoading,
|
||||
selectedFuel: $selectedFuel,
|
||||
sortMode: Binding(get: { effectiveSortMode }, set: { sortMode = $0 }),
|
||||
@@ -1098,13 +1342,13 @@ struct ContentView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: baselinePrice,
|
||||
topStationID: topStationID,
|
||||
topStationID: topID,
|
||||
location: location,
|
||||
favouriteIDs: favouriteIDs,
|
||||
stationTapAction: stationTapAction,
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||
savingsForStation: savingsLookup(baseline: baselineStation),
|
||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||
savingsBaselineStationName: baselineStation?.name,
|
||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||
onToggleFavourite: toggleFavourite,
|
||||
@@ -1115,7 +1359,8 @@ struct ContentView: View {
|
||||
/// The Favourites tab, extracted from `body` for the same type-check
|
||||
/// budget reason.
|
||||
private var favouritesTab: some View {
|
||||
let baselineStation = nearestSavingsBaseline
|
||||
let scaffold = savingsScaffold()
|
||||
let baselineStation = scaffold.baseline
|
||||
return FavouritesView(
|
||||
favourites: refreshedFavourites,
|
||||
selectedFuel: selectedFuel,
|
||||
@@ -1125,7 +1370,7 @@ struct ContentView: View {
|
||||
stationTapAction: stationTapAction,
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||
savingsForStation: savingsLookup(baseline: baselineStation),
|
||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||
savingsBaselineStationName: baselineStation?.name,
|
||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||
onToggleFavourite: toggleFavourite,
|
||||
@@ -1451,10 +1696,14 @@ struct StationRow: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let location {
|
||||
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||
let distance = FuelStore.stationDistanceDisplay(
|
||||
station: station, userLat: location.lat, userLng: location.lng
|
||||
)
|
||||
Text(distance.label(unit: distanceUnit))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.accessibilityLabel(distance.spokenLabel(unit: distanceUnit))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -1513,7 +1762,8 @@ struct StationRow: View {
|
||||
}
|
||||
if fuelSavings != nil {
|
||||
// Full-width verdict banner under the row. Inline in the middle
|
||||
// column it truncated ("Save £2.75 after tra…") because the
|
||||
// column it truncated ("Approx. £2.75 better off after tra…")
|
||||
// because the
|
||||
// price block squeezes that column — and the verdict is the
|
||||
// point of this sort mode, so it gets its own line.
|
||||
FuelSavingsSummaryView(
|
||||
|
||||
Reference in New Issue
Block a user