Files
fuelboard/FuelBoard/LiveActivityManager.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

145 lines
5.6 KiB
Swift

// LiveActivityManager.swift — app-side driver for the FuelBoard Live Activity.
//
// The app owns the activity lifecycle (request / update / end) because the
// activity's own sandbox cannot fetch prices or read location. It mirrors the
// app's TOP badge exactly: cheapest station selling the SELECTED fuel within
// the CHOSEN distance radius. Call sites:
// - every location fix (foreground AND background significant-change wake-ups)
// - after every station refresh
// - when the Settings toggle flips
//
// Live Activities auto-end after 8 h — this manager re-requests a fresh one
// just before the cap so a long drive keeps its glanceable pill.
import ActivityKit
import Foundation
@MainActor
enum LiveActivityManager {
/// The running activity, if any.
private static var current: Activity<FuelBoardLiveActivityAttributes>?
/// When `current` was requested. This SDK's `Activity` no longer exposes
/// `startDate`, so the manager tracks it locally to honour the 8 h cap.
private static var currentStartDate: Date?
/// The system ends Live Activities after 8 hours. Restart at 7h59m so the
/// pill never silently disappears mid-drive.
private static let restartThreshold: TimeInterval = 7 * 3600 + 59 * 60
/// Recomputes the cheapest-in-radius station and updates the activity.
/// Passing `enabled: false` (or no usable data) ends any running activity.
/// `priceDisplayStyle` overrides the saved style (used when the setting
/// just changed and the save may not have landed yet); nil = read storage.
static func update(
stations: [FuelStation],
fuel: FuelType,
radiusKM: Double,
location: Coordinate?,
enabled: Bool,
priceDisplayStyle: PriceDisplayStyle? = nil
) {
guard enabled, let location else {
end()
return
}
guard let best = cheapestStation(
stations: stations, fuel: fuel, radiusKM: radiusKM,
lat: location.lat, lng: location.lng
), let price = best.prices[fuel] else {
end()
return
}
// ONE cache read for the figure AND its confidence: the activity shows
// "Approx." exactly when the app's own rows would.
let distance = FuelStore.stationDistanceDisplay(
station: best, userLat: location.lat, userLng: location.lng
)
let state = FuelBoardLiveActivityAttributes.ContentState(
fuel: fuel,
stationID: best.id,
stationName: best.name,
brand: best.brand,
pricePence: price,
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
distanceKM: distance.km,
distanceIsApproximate: distance.isApproximate,
lat: best.lat,
lng: best.lng,
updatedAt: Date()
)
let attributes = FuelBoardLiveActivityAttributes()
if let current {
// Near the 8 h cap? End and re-request so the pill keeps living.
let age = currentStartDate.map { Date().timeIntervalSince($0) } ?? 0
if age >= restartThreshold {
Task { await current.end(nil, dismissalPolicy: .immediate) }
self.current = nil
currentStartDate = nil
start(attributes: attributes, state: state)
} else {
Task { await current.update(using: state) }
}
} else {
start(attributes: attributes, state: state)
}
}
/// Ends any running activity (toggle off / app reset).
static func end() {
guard let current else { return }
Task { await current.end(nil, dismissalPolicy: .immediate) }
self.current = nil
currentStartDate = nil
}
// MARK: - Internals
private static func start(
attributes: FuelBoardLiveActivityAttributes,
state: FuelBoardLiveActivityAttributes.ContentState
) {
do {
current = try Activity.request(
attributes: attributes,
content: .init(state: state, staleDate: nil),
pushType: nil
)
currentStartDate = Date()
} catch {
// User disabled Live Activities in Settings, or system budget —
// silent: the feature just doesn't appear.
current = nil
currentStartDate = nil
}
}
/// Cheapest station selling `fuel` within `radiusKM` of the location —
/// identical semantics to the app list's TOP badge (price first, then
/// distance as the tie-breaker).
static func cheapestStation(
stations: [FuelStation],
fuel: FuelType,
radiusKM: Double,
lat: Double,
lng: Double
) -> FuelStation? {
// Radius and tie-break both use the distance the pill SHOWS, so the
// activity can never name a station the app's own list has filtered out.
stations
.filter { $0.prices[fuel] != nil }
.filter {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: lat, userLng: lng)
}
.min { lhs, rhs in
let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice }
return FuelStore.displayDistanceKM(station: lhs, userLat: lat, userLng: lng)
< FuelStore.displayDistanceKM(station: rhs, userLat: lat, userLng: lng)
}
}
}