2267 lines
113 KiB
Swift
2267 lines
113 KiB
Swift
import SwiftUI
|
||
import CoreLocation
|
||
import WidgetKit
|
||
|
||
/// The single floating notification banner. Network/offline state and tip
|
||
/// outcomes all funnel through ONE style and render via the same chrome in
|
||
/// ContentView — never pushing layout, always overlaid.
|
||
enum AppBanner: Hashable {
|
||
case offlineDump(date: String)
|
||
case connectionProblem
|
||
case tip(TipStore.TipOutcome)
|
||
}
|
||
|
||
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
|
||
@State private var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = RoadDistanceService.currentDebugStatus()
|
||
/// 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 = 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 = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
|
||
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||
@State private var fuelSavingsSettings: FuelSavingsSettings = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings())
|
||
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings()).preferredAmount
|
||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
||
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
|
||
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
|
||
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
|
||
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
|
||
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
|
||
@State private var liveActivityFollowsSearch: Bool = FuelStore.loadLiveActivityFollowsSearch()
|
||
/// The device's own last TRUSTWORTHY fix — raw CoreLocation output (subject
|
||
/// to LocationManager's junk-fix guard). Used for the network fetch hint and
|
||
/// for saving the position; never for labelling distances.
|
||
@State private var deviceLocation: Coordinate? = {
|
||
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
||
return nil
|
||
}()
|
||
|
||
/// Horizontal accuracy (metres) of `deviceLocation` when it came from a live
|
||
/// fix; nil when the value was seeded from the saved location at launch.
|
||
@State private var deviceLocationAccuracy: Double?
|
||
|
||
/// The single basis every DISTANCE the user sees is measured from — list
|
||
/// rows, the radius filter, sorting, the savings/journey figures and the
|
||
/// Live Activity all read this one origin, so no two of them can disagree
|
||
/// about where the user is.
|
||
///
|
||
/// Resolved through `FuelStore.distanceDisplayOrigin` rather than used raw:
|
||
/// a coarse Wi-Fi/cell fix (or one that contradicts the route cache's
|
||
/// anchor) must not silently re-base the UI — that is the bug where a row
|
||
/// showed an "Approx." marker beside the routed figure. When the live fix
|
||
/// is untrustworthy, this stays on the fix the cached routes were built
|
||
/// around; when the user has genuinely moved, it follows them.
|
||
private var location: Coordinate? {
|
||
guard let deviceLocation else { return nil }
|
||
let origin = FuelStore.distanceDisplayOrigin(
|
||
candidateLat: deviceLocation.lat,
|
||
candidateLng: deviceLocation.lng,
|
||
candidateAccuracyMeters: deviceLocationAccuracy
|
||
)
|
||
return Coordinate(lat: origin.lat, lng: origin.lng)
|
||
}
|
||
|
||
#if DEBUG
|
||
/// The origin decision behind `location`, for the `FB_DIST_FOCUS` probe.
|
||
private var displayOriginDiagnostics: (candidate: Coordinate?, accuracy: Double?,
|
||
source: String, candidateOffsetM: Double?,
|
||
anchor: (lat: Double, lng: Double)?) {
|
||
guard let deviceLocation else { return (nil, nil, "noFix", nil, nil) }
|
||
let resolved = FuelStore.distanceDisplayOrigin(
|
||
candidateLat: deviceLocation.lat, candidateLng: deviceLocation.lng,
|
||
candidateAccuracyMeters: deviceLocationAccuracy
|
||
)
|
||
let cache = FuelStore.loadRoadDistances()
|
||
return (deviceLocation, deviceLocationAccuracy, resolved.source,
|
||
cache.map { FuelStore.distanceMetersBetween(lat1: $0.sourceLat, lng1: $0.sourceLng,
|
||
lat2: deviceLocation.lat, lng2: deviceLocation.lng) },
|
||
cache.map { ($0.sourceLat, $0.sourceLng) })
|
||
}
|
||
#endif
|
||
|
||
// 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
|
||
}
|
||
|
||
/// QA hook: `-priceStyle poundsPence` renders prices in the wide pounds &
|
||
/// pence form for ONE launch. Same reason as `-sortMode`: simctl has no tap
|
||
/// command, so the wide price has to be measured against the row's column
|
||
/// widths without walking Settings → Units for every check. Never persisted.
|
||
private static func qaPriceStyleOverride(default fallback: PriceDisplayStyle) -> PriceDisplayStyle {
|
||
#if DEBUG
|
||
let args = ProcessInfo.processInfo.arguments
|
||
if let i = args.firstIndex(of: "-priceStyle"), i + 1 < args.count,
|
||
let style = PriceDisplayStyle(rawValue: args[i + 1]) {
|
||
return style
|
||
}
|
||
#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 {
|
||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||
}
|
||
|
||
private var effectiveAlertsMode: AlertMode {
|
||
(alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode
|
||
}
|
||
|
||
private func ensureBestValueAlertModeIsAvailable() {
|
||
guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return }
|
||
alertsMode = .cheapest
|
||
FuelStore.saveAlertsMode(.cheapest)
|
||
}
|
||
|
||
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
|
||
monitor.update(
|
||
stations: stations,
|
||
favourites: refreshedFavourites,
|
||
fuel: overrideFuel ?? alertsFuel,
|
||
radiusKM: effectiveAlertsRadiusKM,
|
||
alertMode: effectiveAlertsMode,
|
||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||
fuelSavingsSettings: fuelSavingsSettings,
|
||
fuelSavingsAmount: sessionFuelSavingsAmount
|
||
)
|
||
}
|
||
|
||
/// QA hook: `-qaConfigureSavings` configures the Fuel Savings feature for the
|
||
/// session (the sheet's Best Value card, the Worth the Drive sort and the
|
||
/// Key sheet's Best Value row are ALL gated on `settings.isConfigured`), so
|
||
/// they can be screenshotted without walking Settings. It writes through the
|
||
/// store exactly as the Settings screen does and returns what it wrote, so
|
||
/// the app runs on the same stored values — not a stubbed, parallel one.
|
||
static func qaSavingsSettingsOverride(default value: FuelSavingsSettings) -> FuelSavingsSettings {
|
||
#if DEBUG
|
||
guard ProcessInfo.processInfo.arguments.contains("-qaConfigureSavings") else { return value }
|
||
let seeded = FuelSavingsSettings(
|
||
enabled: true,
|
||
mpg: value.mpg,
|
||
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||
)
|
||
FuelStore.saveFuelSavingsSettings(seeded)
|
||
return seeded
|
||
#else
|
||
return value
|
||
#endif
|
||
}
|
||
|
||
private func installLocationUpdateHook() {
|
||
locationManager.onLocationUpdate = {
|
||
self.updateAlertMonitor()
|
||
// The Live Activity follows the same wake-ups — this hook
|
||
// fires on every fix incl. background significant-change
|
||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||
updateLiveActivity()
|
||
refreshRoadDistancesIfNeeded()
|
||
}
|
||
}
|
||
|
||
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
|
||
/// stations around the current fix. The app owns routing — the widget and
|
||
/// Live Activity only read the cached result. Falls back to the last saved
|
||
/// location so it can run before the first fresh GPS fix arrives.
|
||
private func refreshRoadDistancesIfNeeded() {
|
||
let origin = location ?? FuelStore.loadLocation()
|
||
guard let origin else { return }
|
||
let priority = worthTheDriveRoutingPriorities(location: origin)
|
||
Task {
|
||
await RoadDistanceService.refreshIfNeeded(
|
||
stations: stations,
|
||
lat: origin.lat,
|
||
lng: origin.lng,
|
||
prioritising: priority
|
||
)
|
||
}
|
||
}
|
||
|
||
/// The stations worth spending Apple Maps route requests on: the best
|
||
/// Worth-the-Drive candidates by estimated net saving, plus the comparison
|
||
/// baseline (every other row is measured against it).
|
||
///
|
||
/// Ranking is by net saving, so these are typically FURTHER out than the
|
||
/// nearest stations — the ones nearest-only routing never reached, which is
|
||
/// why their rows stayed on straight-line estimates.
|
||
private func worthTheDriveRoutingPriorities(location: Coordinate) -> [FuelStation] {
|
||
guard fuelSavingsSettings.isConfigured else { return [] }
|
||
let baseline = nearestSavingsBaseline
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
poolStations,
|
||
fuel: selectedFuel,
|
||
baseline: baseline,
|
||
settings: fuelSavingsSettings,
|
||
amount: sessionFuelSavingsAmount,
|
||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||
)
|
||
var priorities = Array(ranked.prefix(RoadDistanceService.priorityCandidates))
|
||
if let baseline, !priorities.contains(where: { $0.id == baseline.id }) {
|
||
priorities.insert(baseline, at: 0)
|
||
}
|
||
return priorities
|
||
}
|
||
|
||
private func ensureBackgroundMotionTrackingIfNeeded() {
|
||
if alertsEnabled || liveActivityEnabled {
|
||
locationManager.startBackgroundTracking()
|
||
}
|
||
}
|
||
|
||
@State private var isLoading = false
|
||
@State private var statusMessage = ""
|
||
/// What data is on screen, driving which (if any) status banner shows
|
||
/// above the tabs:
|
||
/// - `.live`: fetched or cached data — nothing to say.
|
||
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot —
|
||
/// the banner labels it honestly with the snapshot's own date.
|
||
/// - `.connectionProblem`: fetch failed but a saved cache is showing —
|
||
/// the banner says to check connectivity (tap = retry).
|
||
enum DataSourceStatus: Equatable {
|
||
case live
|
||
case offlineDump(date: String)
|
||
case connectionProblem
|
||
}
|
||
@State private var dataStatus: DataSourceStatus = .live
|
||
@State private var showOnboarding = false
|
||
@State private var showWidgetMock = false
|
||
@State private var selectedTab = 0
|
||
@State private var locationManager = LocationManager()
|
||
@StateObject private var monitor = ProximityMonitor()
|
||
/// Owns tip purchases; the single instance is shared down to Settings.
|
||
/// Its transient outcome rides the SAME status-banner chrome as the
|
||
/// network/offline strip (ContentView renders it above the tabs).
|
||
@StateObject private var tipStore = TipStore()
|
||
|
||
// --- Explicit banner animation state (robust fade-out) ---
|
||
// SwiftUI's removal transition for the banner proved unreliable here
|
||
// (the tip faded to nothing only in the cross-nil case, and even then
|
||
// inconsistently). Drive opacity/offset explicitly instead so a
|
||
// dismissal ALWAYS fades + slides up on ANY banner change.
|
||
@State private var currentBanner: AppBanner?
|
||
@State private var bannerOpacity: Double = 0
|
||
@State private var bannerOffset: CGFloat = 0
|
||
@State private var bannerClearTask: Task<Void, Never>?
|
||
|
||
/// The pool the list draws from. In Cheapest and Worth-the-Drive modes the
|
||
/// chosen miles radius bounds it ("best price within X miles"); in Closest
|
||
/// mode the radius is redundant — the whole country sorted nearest-first,
|
||
/// because "nearest" must never answer with an empty state. STRICT: no
|
||
/// fallback to out-of-radius stations in radius-bound modes.
|
||
private var poolStations: [FuelStation] {
|
||
let selling = stations.filter { $0.prices[selectedFuel] != nil }
|
||
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 {
|
||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||
userLat: location.lat, userLng: location.lng)
|
||
}
|
||
}
|
||
|
||
/// The price reference every row's delta + RAG compares against — the
|
||
/// "best" of the current pool. In BOTH modes that is the CHEAPEST station
|
||
/// within the chosen miles radius (the stations you'd actually consider).
|
||
/// Cheapest mode's pool already is the radius; Closest mode lists the
|
||
/// whole country nearest-first (so it never empties), but best value is
|
||
/// still judged against what's practically reachable, so deltas stay local.
|
||
/// Fallback: if the radius has no stations, use the pool minimum.
|
||
private var baselinePrice: Double? {
|
||
let pool = poolStations
|
||
if sortMode == .closest, let location {
|
||
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km
|
||
let within = pool.filter {
|
||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||
userLat: location.lat, userLng: location.lng)
|
||
}
|
||
if let localMin = within.compactMap({ $0.prices[selectedFuel] }).min() {
|
||
return localMin
|
||
}
|
||
}
|
||
return pool.compactMap { $0.prices[selectedFuel] }.min()
|
||
}
|
||
|
||
/// The "best" station — the cheapest in the pool — gets the TOP badge.
|
||
/// First occurrence in display order wins on price ties.
|
||
private var topStationID: String? {
|
||
guard let baselinePrice else { return nil }
|
||
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
|
||
/// into the Settings → Debug section so its "Cheapest in range" row
|
||
/// matches the app list (and the widget when the widget's fuel + distance
|
||
/// match the app's). The debug section previously showed the ALERT
|
||
/// prediction (alertsFuel + alert radius), which is a different pool by
|
||
/// design — that mismatch is what this fixes.
|
||
private var appCheapestStatus: DebugAppCheapest? {
|
||
guard let topID = topStationID,
|
||
let station = stations.first(where: { $0.id == topID }),
|
||
let location else { return nil }
|
||
return DebugAppCheapest(
|
||
station: station,
|
||
fuel: selectedFuel,
|
||
radiusKM: distanceUnit.toKM(Double(stationLimit)),
|
||
distanceKM: FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)
|
||
)
|
||
}
|
||
|
||
private var displayedStations: [FuelStation] {
|
||
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]! } }
|
||
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:
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
/// The comparison baseline AND each station's journey, from ONE pass.
|
||
///
|
||
/// 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 the same journey guards as
|
||
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
|
||
/// off, the ordinary Closest station still names that row.
|
||
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
||
guard let location else { return (nil, [:]) }
|
||
guard fuelSavingsSettings.isConfigured else {
|
||
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
|
||
savingsJourneyMiles(for: $0, location: location)
|
||
}
|
||
return (baseline, [:])
|
||
}
|
||
var journeys: [String: FuelSavingsJourney] = [:]
|
||
for station in stations where station.prices[selectedFuel] != nil {
|
||
if let journey = savingsJourneyMiles(for: station, location: location) {
|
||
journeys[station.id] = journey
|
||
}
|
||
}
|
||
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
|
||
journeys[$0.id]
|
||
}
|
||
return (baseline, 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: FuelSavingsJourney? = baseline.flatMap {
|
||
journeys[$0.id] ?? savingsJourneyMiles(for: $0, location: location)
|
||
}
|
||
let baselineID = baseline?.id
|
||
let settings = fuelSavingsSettings
|
||
let amount = sessionFuelSavingsAmount
|
||
let fuel = selectedFuel
|
||
return { station in
|
||
FuelSavingsEstimator.availability(
|
||
station: station,
|
||
fuel: fuel,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: journeys[station.id],
|
||
baselineJourney: baselineID.flatMap { journeys[$0] } ?? baselineJourney
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 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 "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? {
|
||
guard let location else { return nil }
|
||
return FuelSavingsEstimator.baselineStation(
|
||
stations,
|
||
fuel: selectedFuel,
|
||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||
)
|
||
}
|
||
|
||
/// The sort the UI and the list actually use.
|
||
///
|
||
/// The Worth-the-Drive sort is only offered while Fuel Savings is
|
||
/// configured, but the chosen sort is persisted — so a stored
|
||
/// `.worthTheDrive` would otherwise keep the fill-up control on screen (and
|
||
/// leave the picker on a mode it no longer lists) after the feature is
|
||
/// switched off. Fall back to Cheapest for RENDER ONLY: the stored
|
||
/// preference is left untouched, so re-enabling the feature restores the
|
||
/// user's choice.
|
||
private var effectiveSortMode: SortMode {
|
||
(sortMode == .worthTheDrive && !fuelSavingsSettings.isConfigured) ? .cheapest : sortMode
|
||
}
|
||
|
||
/// Journey (out and back) miles for a station: the measured road route when
|
||
/// one has been fetched, otherwise a straight-line out-and-back estimate, so
|
||
/// that every station the app lists can carry a saving.
|
||
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
|
||
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
|
||
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
|
||
for: station, userLat: location.lat, userLng: location.lng
|
||
) ? .confirmed : .estimated
|
||
return FuelSavingsJourney(
|
||
miles: roadMeters / FuelSavingsCalculator.metresPerMile,
|
||
basis: .roadRoute,
|
||
confidence: confidence
|
||
)
|
||
}
|
||
return FuelSavingsJourney(
|
||
miles: savingsFallbackMiles(for: station, location: location),
|
||
basis: .straightLine,
|
||
confidence: .estimated
|
||
)
|
||
}
|
||
|
||
private func savingsFallbackMiles(for station: FuelStation, location: Coordinate) -> Double {
|
||
FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) / FuelSavingsCalculator.kilometresPerMile * 2
|
||
}
|
||
|
||
private func sortedForWorthTheDrive(_ pool: [FuelStation]) -> [FuelStation] {
|
||
guard fuelSavingsSettings.isConfigured, let location else {
|
||
#if DEBUG
|
||
print("FB-DRIVE skipped configured=\(fuelSavingsSettings.isConfigured) location=\(self.location == nil ? "none" : "ok") pool=\(pool.count)")
|
||
#endif
|
||
return pool.sorted { ($0.prices[selectedFuel] ?? .infinity) < ($1.prices[selectedFuel] ?? .infinity) }
|
||
}
|
||
// Every station in the pool is ranked, with a straight-line estimate
|
||
// until its road route has been measured. Ranking only a road-route
|
||
// shortlist hid cheaper stations a few miles further out and left later
|
||
// pages with no savings (see `FuelSavingsEstimator.ranked`).
|
||
let baseline = nearestSavingsBaseline
|
||
let ordered = FuelSavingsEstimator.ranked(
|
||
pool,
|
||
fuel: selectedFuel,
|
||
baseline: baseline,
|
||
settings: fuelSavingsSettings,
|
||
amount: sessionFuelSavingsAmount,
|
||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||
)
|
||
#if DEBUG
|
||
logWorthTheDriveSummary(ordered, baseline: baseline, location: location)
|
||
#endif
|
||
return ordered
|
||
}
|
||
|
||
/// Compiled in EVERY configuration: the detail sheet's `journeyDiagnostics`
|
||
/// argument is not conditional (an `#if` inside an argument list does not
|
||
/// build), so this symbol must exist for Release/Archive too. Debug builds
|
||
/// still require the hidden diagnostics trigger before doing any work;
|
||
/// Release returns nil and does no work.
|
||
private func journeyDiagnostics(for station: FuelStation, baseline: FuelStation?) -> FuelSavingsJourneyDiagnostics? {
|
||
#if DEBUG
|
||
guard FuelStore.loadDebugMode(), let location else { return nil }
|
||
let stationDistance = FuelStore.roadDistanceDiagnostics(for: station, userLat: location.lat, userLng: location.lng)
|
||
let baselineDiagnostics = baseline.map { FuelStore.roadDistanceDiagnostics(for: $0, userLat: location.lat, userLng: location.lng) }
|
||
let journey = savingsJourneyMiles(for: station, location: location)
|
||
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: station,
|
||
fuel: selectedFuel,
|
||
baseline: baseline,
|
||
settings: fuelSavingsSettings,
|
||
amount: sessionFuelSavingsAmount,
|
||
journey: journey,
|
||
baselineJourney: baselineJourney
|
||
)
|
||
let result = availability?.result
|
||
|
||
func miles(_ value: Double?) -> String {
|
||
value.map { String(format: "%.3f mi", $0) } ?? "—"
|
||
}
|
||
func meters(_ value: Double?) -> String {
|
||
value.map { String(format: "%.0f m", $0) } ?? "—"
|
||
}
|
||
func source(_ basis: FuelSavingsDistanceBasis) -> String {
|
||
basis == .roadRoute ? "road route" : "straight-line estimate"
|
||
}
|
||
func amount(_ value: FuelSavingsFillAmount) -> String {
|
||
switch value.unit {
|
||
case .pounds: return String(format: "£%.2f", value.value)
|
||
case .litres: return String(format: "%.2f L", value.value)
|
||
}
|
||
}
|
||
func rawMoney(_ value: Double?) -> String {
|
||
value.map { String(format: "£%.6f", $0) } ?? "—"
|
||
}
|
||
let candidatePriceValue = station.prices[selectedFuel]
|
||
let baselinePriceValue = baseline?.prices[selectedFuel]
|
||
let price = candidatePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
|
||
let baselinePrice = baselinePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
|
||
let priceDifference = candidatePriceValue.flatMap { candidate in
|
||
baselinePriceValue.map { String(format: "%+.1fp/L", $0 - candidate) }
|
||
} ?? "—"
|
||
let litresBasis = sessionFuelSavingsAmount.unit == .pounds ? "candidate price" : "fixed litres"
|
||
let rows: [(String, String)] = [
|
||
("Station", "\(station.name) (\(station.id))"),
|
||
("Settings default fill-up", amount(fuelSavingsSettings.preferredAmount)),
|
||
("Effective/session fill-up", amount(sessionFuelSavingsAmount)),
|
||
("Fill-up unit", sessionFuelSavingsAmount.unit.displayName),
|
||
("MPG", String(format: "%.1f UK MPG", fuelSavingsSettings.mpg)),
|
||
("Candidate price", price),
|
||
("Baseline price", baselinePrice),
|
||
("Price difference", priceDifference),
|
||
("Candidate litres", result.map { String(format: "%.6f L · %@", $0.fillLitres, litresBasis) } ?? "—"),
|
||
("Gross saving raw", rawMoney(result?.grossSavingGBP)),
|
||
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
|
||
("Outward road", meters(stationDistance.outwardMeters)),
|
||
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
|
||
("Total journey", meters(stationDistance.totalJourneyMeters)),
|
||
("Cache", stationDistance.hasValidCacheEntry ? "valid" : "missing/invalid"),
|
||
("Baseline", baseline.map { "\($0.name) (\($0.id))" } ?? "—"),
|
||
("Baseline distance", baselineDiagnostics.map { "\(distanceUnit.format($0.displayDistanceKM)) · \(source($0.displaySource))" } ?? "—"),
|
||
("Baseline total", meters(baselineDiagnostics?.totalJourneyMeters)),
|
||
("Station journey", miles(journey?.miles)),
|
||
("Baseline journey", miles(baselineJourney?.miles)),
|
||
("Additional journey", miles(result?.extraDistanceMiles)),
|
||
("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"),
|
||
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
|
||
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
|
||
("Net saving raw", rawMoney(result?.netSavingGBP)),
|
||
("Displayed net saving", result?.headline() ?? "—")
|
||
]
|
||
return FuelSavingsJourneyDiagnostics(rows: rows)
|
||
#else
|
||
return nil
|
||
#endif
|
||
}
|
||
|
||
#if DEBUG
|
||
/// Last FB-DRIVE line printed, so the ranking (which runs on every render)
|
||
/// only reports when the result actually changes.
|
||
private static var lastDriveLogLine: String?
|
||
|
||
/// One summary line per ranking pass (FB-DRIVE) so a device console shows
|
||
/// whether the Best Value list is actually carrying savings for the
|
||
/// whole pool — the regression was silent otherwise.
|
||
private func logWorthTheDriveSummary(_ ordered: [FuelStation],
|
||
baseline: FuelStation?,
|
||
location: Coordinate) {
|
||
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
|
||
var rows: [(station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)] = []
|
||
for station in ordered {
|
||
// Capture the journey ONCE and print that same value: routing runs
|
||
// in the background, so re-reading it for the log would mix a stale
|
||
// journey into a fresh figure and make the numbers unreadable.
|
||
let journey = savingsJourneyMiles(for: station, location: location)
|
||
guard let availability = FuelSavingsEstimator.availability(
|
||
station: station,
|
||
fuel: selectedFuel,
|
||
baseline: baseline,
|
||
settings: fuelSavingsSettings,
|
||
amount: sessionFuelSavingsAmount,
|
||
journey: journey,
|
||
baselineJourney: baselineJourney
|
||
), let result = availability.result else { continue }
|
||
rows.append((station, result, journey))
|
||
}
|
||
func describe(_ row: (station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)) -> String {
|
||
let price = row.station.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a"
|
||
return "\"\(row.station.name)\" p=\(price) j=\(String(format: "%.1f", row.journey?.miles ?? 0))mi(\(row.journey?.basis.rawValue ?? "none")) gross=\(String(format: "%.2f", row.result.grossSavingGBP)) extra=\(String(format: "%.2f", row.result.extraDrivingCostGBP)) extraMi=\(String(format: "%.1f", row.result.extraDistanceMiles)) net=\(String(format: "%.2f", row.result.netSavingGBP)) \"\(row.result.headline())\""
|
||
}
|
||
let baselineLine: String
|
||
if let baseline, let baselineJourney {
|
||
let price = baseline.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a"
|
||
baselineLine = "baseline=\"\(baseline.name)\" p=\(price) j=\(String(format: "%.1f", baselineJourney.miles))mi(\(baselineJourney.basis.rawValue))"
|
||
} else {
|
||
baselineLine = "baseline=none"
|
||
}
|
||
let leaders = rows.prefix(3).map(describe).joined(separator: " | ")
|
||
let line = "FB-DRIVE pool=\(ordered.count) withSavings=\(rows.count) \(baselineLine) | \(leaders)"
|
||
guard ContentView.lastDriveLogLine != line else { return }
|
||
ContentView.lastDriveLogLine = line
|
||
print(line)
|
||
// `FB_DRIVE_FOCUS=<text>` prints one named station's row too, so a
|
||
// specific complaint can be checked against real routed numbers.
|
||
if let focus = ProcessInfo.processInfo.environment["FB_DRIVE_FOCUS"], !focus.isEmpty,
|
||
let row = rows.first(where: { $0.station.name.localizedCaseInsensitiveContains(focus) }) {
|
||
print("FB-DRIVE focus \(describe(row))")
|
||
}
|
||
}
|
||
#endif
|
||
|
||
/// Station IDs favourited for the CURRENTLY selected fuel — the star on a
|
||
/// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites).
|
||
private var favouriteIDs: Set<String> {
|
||
Set(favourites.filter { $0.fuel == selectedFuel }.map(\.station.id))
|
||
}
|
||
|
||
private var refreshedFavourites: [FavouriteEntry] {
|
||
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?,
|
||
originNote: String = "") {
|
||
if !originNote.isEmpty {
|
||
print("FB-DIST-ORIGIN \(originNote)")
|
||
}
|
||
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)")
|
||
}
|
||
}
|
||
|
||
/// One line describing where the displayed distances are measured FROM:
|
||
/// the raw device fix and its accuracy, the route cache's anchor and their
|
||
/// offset, and which branch `distanceDisplayOrigin` took. Printed by
|
||
/// `FB_DIST_FOCUS` so a device can prove which basis the list used.
|
||
private var distanceFocusOriginNote: String {
|
||
let d = displayOriginDiagnostics
|
||
let candidate = d.candidate.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
|
||
let accuracy = d.accuracy.map { String(format: "%.0fm", $0) } ?? "nil"
|
||
let anchor = d.anchor.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
|
||
let offset = d.candidateOffsetM.map { String(format: "%.0fm", $0) } ?? "nil"
|
||
let served = location.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
|
||
return "candidate=\(candidate) acc=\(accuracy) anchor=\(anchor) "
|
||
+ "candidateOffset=\(offset) resolved=\(d.source) served=\(served)"
|
||
}
|
||
#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 {
|
||
interactionObservedContent
|
||
}
|
||
|
||
private var presentedContent: AnyView {
|
||
AnyView(
|
||
// The Best Value announcement for users who updated into the feature
|
||
// (see `BestValueAnnouncementHost`): a one-page cover, shown once per
|
||
// announcement version, never while first-run onboarding is due.
|
||
BestValueAnnouncementHost(
|
||
isSuppressed: showOnboarding,
|
||
onDismiss: {
|
||
// The page writes straight to the store — it is its own cover
|
||
// with its own copy of the settings — so pull the stored
|
||
// values back in, or the choice wouldn't take effect until
|
||
// the next launch.
|
||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||
}
|
||
) {
|
||
mainContent
|
||
}
|
||
.fullScreenCover(isPresented: $showOnboarding) {
|
||
OnboardingView {
|
||
showOnboarding = false
|
||
}
|
||
}
|
||
.fullScreenCover(isPresented: $showWidgetMock) {
|
||
WidgetMockScreen()
|
||
}
|
||
.sheet(item: $monitor.pendingStationMap) { request in
|
||
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,
|
||
priceStyle: priceDisplayStyle,
|
||
fuelSavings: savingsLookup(baseline: scaffold.baseline,
|
||
journeys: scaffold.journeys)(station),
|
||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||
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,
|
||
originNote: distanceFocusOriginNote)
|
||
}
|
||
#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: .fuelBoardRoadDistanceDebugUpdated)) { _ in
|
||
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||
// "Setup Best Value" in the How Best Value works sheet: close
|
||
// the More info sheet it was opened from (nil dismisses it —
|
||
// the sheet is item-driven) and switch to Settings. Tag 3 is
|
||
// the Settings tab; its Best Value section is the first item
|
||
// in the list, so the controls are on screen on arrival.
|
||
monitor.pendingDetailStation = nil
|
||
selectedTab = 3
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||
let info = note.userInfo ?? [:]
|
||
if let id = info["stationID"] as? String,
|
||
let station = stations.first(where: { $0.id == id }) {
|
||
monitor.pendingDetailStation = station
|
||
return
|
||
}
|
||
// Station left the cache — fall back to the map sheet so
|
||
// the tap still shows something.
|
||
if let lat = info["stationLat"] as? Double,
|
||
let lng = info["stationLng"] as? Double {
|
||
let name = info["stationName"] as? String ?? "Station"
|
||
monitor.pendingStationMap = StationMapRequest(
|
||
name: name, latitude: lat, longitude: lng
|
||
)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private var lifecycleObservedContent: AnyView {
|
||
AnyView(
|
||
presentedContent
|
||
.onAppear {
|
||
// Launch-arg hooks for the screenshot/UI-test harness (same
|
||
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
||
// opens the given tab; `-skipOnboarding` skips onboarding
|
||
// without touching the stored flag.
|
||
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,
|
||
originNote: distanceFocusOriginNote)
|
||
}
|
||
}
|
||
// `FB_STALE_INCOMPLETE=1` rewrites the cached route set with the
|
||
// complete flag cleared — the state a capped or interrupted pass
|
||
// leaves behind. Launching in it must not show "Approx." for ever:
|
||
// the app has to notice that nothing is left to route and repair the
|
||
// flag. DEBUG-only verification hook.
|
||
if ProcessInfo.processInfo.environment["FB_STALE_INCOMPLETE"] == "1",
|
||
let cache = FuelStore.loadRoadDistances() {
|
||
FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng,
|
||
entries: cache.entries, complete: false)
|
||
print("FB-COMPLETE forced stale incomplete cached=\(cache.entries.count) "
|
||
+ "(expect repair on the next fill check)")
|
||
Task { @MainActor in
|
||
// Let a fix and the station pool land, then run the fill
|
||
// check the app makes on every fix/foreground.
|
||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||
refreshRoadDistancesIfNeeded()
|
||
}
|
||
}
|
||
// `FB_FAKE_FIX="lat,lng,accuracyM"` pushes ONE synthetic fix through
|
||
// the real LocationManager path (guard included), then re-prints the
|
||
// origin probe. A phone that is sitting still cannot produce the
|
||
// coarse Wi-Fi fix that flips every row to "Approx.", so this is how
|
||
// that case is verified on-device instead of guessed at.
|
||
if let fake = ProcessInfo.processInfo.environment["FB_FAKE_FIX"], !fake.isEmpty {
|
||
let parts = fake.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||
if parts.count == 3, let lat = Double(parts[0]), let lng = Double(parts[1]),
|
||
let accuracy = Double(parts[2]) {
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||
locationManager.applyFix(lat: lat, lng: lng, accuracy: accuracy)
|
||
print("FB-FIX applied lat=\(lat) lng=\(lng) acc=\(accuracy)m "
|
||
+ "manager=\(locationManager.current.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none")")
|
||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||
if let query = distFocusQuery {
|
||
ContentView.logDistanceFocus(query: query, stations: stations, location: location,
|
||
originNote: distanceFocusOriginNote)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// `-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.
|
||
// Repeatable: each occurrence adds another favourite (QA temp).
|
||
// Optional ":fuel" suffix (e10|e5|diesel) picks the
|
||
// fuel, so the fuel-aware Trends open can be verified (QA temp).
|
||
let seedQueries = args.enumerated()
|
||
.filter { $0.element == "-seedFavourite" }
|
||
.compactMap { i, _ in
|
||
i + 1 < args.count ? args[i + 1] : nil
|
||
}
|
||
if !seedQueries.isEmpty {
|
||
let stations = FuelStore.loadStations()
|
||
var favs = seedQueries.compactMap { query in
|
||
let parts = query.split(separator: ":", maxSplits: 1)
|
||
let name = String(parts[0])
|
||
let fuel = parts.count > 1
|
||
? FuelType(rawValue: String(parts[1])) ?? .e10
|
||
: FuelType.e10
|
||
return stations.first { $0.name.localizedCaseInsensitiveContains(name) }
|
||
.map { FavouriteEntry(station: $0, fuel: fuel) }
|
||
}
|
||
if favs.isEmpty, let q = seedQueries.first,
|
||
let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) {
|
||
favs = [FavouriteEntry(station: s, fuel: .e10)]
|
||
}
|
||
if !favs.isEmpty {
|
||
FuelStore.saveFavourites(favs)
|
||
WatchSyncManager.shared.pushSnapshot()
|
||
}
|
||
}
|
||
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||
switch args[i + 1] {
|
||
case "favourites": selectedTab = 1
|
||
case "alerts": selectedTab = 2
|
||
case "settings": selectedTab = 3
|
||
default: selectedTab = 0
|
||
}
|
||
}
|
||
// QA temp: `-fuel <e10|e5|diesel>` sets the app-wide selected
|
||
// fuel so the fuel-aware Trends open can be verified (simulates
|
||
// the user having switched to the diesel tab).
|
||
if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count,
|
||
let f = FuelType(rawValue: args[i + 1]) {
|
||
selectedFuel = f
|
||
FuelStore.saveSelectedFuel(f)
|
||
}
|
||
// QA temp: `FB_DRIVE_DEBUG=1` (DEBUG builds only) forces the
|
||
// optional Fuel Savings feature ON and selects the Worth the Drive
|
||
// sort, so the ranking can be exercised from the device console
|
||
// (FB-DRIVE) without tapping through Settings.
|
||
//
|
||
// It must NOT substitute the stored MPG / fill amount: doing that
|
||
// planted the DEFAULT values (45 MPG) over the stored ones and made
|
||
// every logged figure wrong — the exact opposite of the hook's
|
||
// purpose. Only `enabled` is overridden; the store is still the
|
||
// source for MPG and fill.
|
||
//
|
||
// An ENVIRONMENT variable, not a launch argument: devicectl clusters
|
||
// dash-options per character, so a word like `-worthTheDrive` is
|
||
// parsed as its own `-t` (--timeout) and the launch is rejected.
|
||
if ProcessInfo.processInfo.environment["FB_DRIVE_DEBUG"] == "1" {
|
||
var forced = FuelStore.loadFuelSavingsSettings()
|
||
forced.enabled = true
|
||
fuelSavingsSettings = forced
|
||
sortMode = .worthTheDrive
|
||
}
|
||
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
|
||
// "Tap station = More info". The stored default is open-Apple-Maps,
|
||
// so on a simulator a tap bounces out to Maps with no way back and
|
||
// the Worth the Drive detail section can't be checked at all.
|
||
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
|
||
stationTapAction = .showDetails
|
||
}
|
||
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||
// failure legs for the screenshot harness. The auto-refresh below
|
||
// is skipped so the banner stays up (a live fetch would clear it).
|
||
if args.contains("-forceOfflineDump") {
|
||
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
||
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||
}
|
||
if args.contains("-forceConnectionProblem") {
|
||
stations = FuelStore.loadStations().isEmpty
|
||
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
|
||
: FuelStore.loadStations()
|
||
dataStatus = .connectionProblem
|
||
}
|
||
#else
|
||
showWidgetMock = false
|
||
#endif
|
||
// Diagnostic only: `effectiveSortMode` already handles a stored
|
||
// `.worthTheDrive` that the feature can no longer back.
|
||
#if DEBUG
|
||
print("FB-SAVINGS launch storedSort=\(sortMode.displayName) effective=\(effectiveSortMode.displayName) configured=\(fuelSavingsSettings.isConfigured) enabled=\(fuelSavingsSettings.enabled) mpg=\(fuelSavingsSettings.mpg) amount=\(fuelSavingsSettings.preferredAmount.value)")
|
||
#endif
|
||
// Onboarding runs first on a fresh install — it owns the initial
|
||
// permission prompts (location, notifications, and the data/local
|
||
// network probe on the Data page). Location tracking and the first
|
||
// network fetch start once it's done.
|
||
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
|
||
// StationsView's `-showKeySheet`): skip onboarding without
|
||
// touching the stored flag.
|
||
#if DEBUG
|
||
let shouldSkipOnboarding = args.contains("-skipOnboarding")
|
||
// The onboarding flag lives in app-group defaults, which survive an
|
||
// app delete — so a reinstall alone won't bring onboarding back.
|
||
// `-showOnboarding` forces it up for the screenshot harness (and
|
||
// pairs with OnboardingView's `-onboardingPage` /
|
||
// `-onboardingBestValue`).
|
||
let shouldForceOnboarding = args.contains("-showOnboarding")
|
||
#else
|
||
let shouldSkipOnboarding = false
|
||
let shouldForceOnboarding = false
|
||
#endif
|
||
if shouldForceOnboarding {
|
||
showOnboarding = true
|
||
} else if !FuelStore.shouldShowOnboarding()
|
||
|| shouldSkipOnboarding {
|
||
locationManager.startForegroundTracking()
|
||
// Geofences and the Live Activity must follow the user even in
|
||
// the background: wire the delegate hook (fires on every fix
|
||
// incl. background significant-change wake-ups) so SwiftUI's
|
||
// foreground-only onChange isn't the only update path.
|
||
installLocationUpdateHook()
|
||
ensureBackgroundMotionTrackingIfNeeded()
|
||
updateAlertMonitor()
|
||
monitor.setEnabled(alertsEnabled)
|
||
updateLiveActivity()
|
||
// Compute road distances early (throttled; falls back to the
|
||
// last saved location) so distance surfaces are road-matched
|
||
// as soon as stations are available.
|
||
refreshRoadDistancesIfNeeded()
|
||
// Refresh only when the cache is stale (twice-a-day policy).
|
||
// Skipped under the force-* hooks so the banner stays up.
|
||
#if DEBUG
|
||
let shouldSkipInitialRefresh = args.contains("-forceOfflineDump")
|
||
|| args.contains("-forceConnectionProblem")
|
||
|| args.contains("-forceHistoryFailure")
|
||
#else
|
||
let shouldSkipInitialRefresh = false
|
||
#endif
|
||
if !shouldSkipInitialRefresh {
|
||
Task { await refresh() }
|
||
}
|
||
} else {
|
||
showOnboarding = true
|
||
}
|
||
}
|
||
.onChange(of: showOnboarding) { _, showing in
|
||
// After onboarding finishes (or the test re-run is dismissed),
|
||
// begin foreground location tracking if permission allows, and
|
||
// run the FIRST data fetch. This must be a forced refresh: a
|
||
// reinstall may leave a recent `lastRefresh` in the app-group
|
||
// defaults (which survive app deletion), which would make the
|
||
// cache-gated refresh skip the fetch and leave the station list
|
||
// empty on a brand-new install.
|
||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||
// Onboarding can switch Best Value on and set the MPG and
|
||
// fill-up figures. It is a full-screen cover with its own copy
|
||
// of the settings, so it writes straight to the store instead of
|
||
// through this view's binding — pull the stored values back in
|
||
// or the choice wouldn't take effect until the next launch.
|
||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||
ensureBestValueAlertModeIsAvailable()
|
||
locationManager.startForegroundTracking()
|
||
installLocationUpdateHook()
|
||
ensureBackgroundMotionTrackingIfNeeded()
|
||
updateAlertMonitor()
|
||
updateLiveActivity()
|
||
Task { await refresh(force: true) }
|
||
}
|
||
}
|
||
.onChange(of: fuelSavingsSettings) { old, new in
|
||
// An explicit Settings default edit supersedes the temporary slider
|
||
// override. Changes to MPG/enabled alone leave the session fill intact.
|
||
if old.preferredAmount != new.preferredAmount {
|
||
sessionFuelSavingsAmount = new.preferredAmount
|
||
}
|
||
// Fuel Savings changed in Settings while the app was running: the
|
||
// effective sort follows automatically (no stored sort is touched).
|
||
#if DEBUG
|
||
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
||
#endif
|
||
ensureBestValueAlertModeIsAvailable()
|
||
updateAlertMonitor()
|
||
}
|
||
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
||
// This remains a session-only override: do not write it to FuelStore.
|
||
// Any active Best Value alert/Live Activity must nevertheless use
|
||
// the same effective amount as rows, details, favourites and sorting.
|
||
updateAlertMonitor()
|
||
updateLiveActivity()
|
||
}
|
||
.onChange(of: scenePhase) { _, newPhase in
|
||
if newPhase == .active {
|
||
// Never start location tracking while onboarding is on screen —
|
||
// onboarding owns the initial permission prompts. Once it's
|
||
// completed, normal foreground tracking resumes.
|
||
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
|
||
locationManager.startForegroundTracking()
|
||
}
|
||
updateAlertMonitor()
|
||
updateLiveActivity()
|
||
refreshRoadDistancesIfNeeded()
|
||
if FuelStore.hasPendingWatchRefreshRequest {
|
||
Task { await handlePendingWatchRefreshRequest() }
|
||
}
|
||
// No network fetch on foreground unless the watch explicitly requests one.
|
||
} else {
|
||
locationManager.stopForegroundTracking()
|
||
}
|
||
}
|
||
.onChange(of: locationManager.current) { _, newLocation in
|
||
if let newLocation {
|
||
deviceLocation = newLocation
|
||
deviceLocationAccuracy = locationManager.currentAccuracy
|
||
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
||
WatchSyncManager.shared.pushSnapshot()
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
// Geofences follow the user's position, but the station list is
|
||
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
||
updateAlertMonitor()
|
||
updateLiveActivity()
|
||
refreshRoadDistancesIfNeeded()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private var interactionObservedContent: AnyView {
|
||
AnyView(
|
||
lifecycleObservedContent
|
||
.onChange(of: selectedFuel) { _, _ in
|
||
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
WatchSyncManager.shared.pushSnapshot()
|
||
}
|
||
.onChange(of: stationLimit) { _, newValue in
|
||
// Distance filter is LOCAL math now — the cache holds the full-UK
|
||
// dump, so changing 5/10/15 (miles or km) never needs a network
|
||
// fetch. sortedStations/radiusScopedStations recompute on the
|
||
// next render.
|
||
FuelStore.saveStationLimit(newValue)
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
// Follow-search surfaces mirror this distance — re-target.
|
||
if alertsFollowsSearch {
|
||
updateAlertMonitor()
|
||
}
|
||
updateLiveActivity()
|
||
}
|
||
.onChange(of: alertsEnabled) { _, newValue in
|
||
FuelStore.saveAlertsEnabled(newValue)
|
||
monitor.setEnabled(newValue)
|
||
updateAlertMonitor()
|
||
ensureBackgroundMotionTrackingIfNeeded()
|
||
}
|
||
.onChange(of: alertsRadius) { _, newValue in
|
||
FuelStore.saveAlertsRadius(newValue)
|
||
updateAlertMonitor()
|
||
}
|
||
.onChange(of: alertsFuel) { _, newValue in
|
||
// Alerts fuel is independent of the Stations-tab selection —
|
||
// changing it re-targets geofences to stations selling that fuel.
|
||
FuelStore.saveAlertsFuel(newValue)
|
||
updateAlertMonitor(fuel: newValue)
|
||
}
|
||
.onChange(of: alertsMode) { _, newValue in
|
||
if newValue == .bestValue, !fuelSavingsSettings.isConfigured {
|
||
alertsMode = .cheapest
|
||
FuelStore.saveAlertsMode(.cheapest)
|
||
updateAlertMonitor()
|
||
return
|
||
}
|
||
FuelStore.saveAlertsMode(newValue)
|
||
updateAlertMonitor()
|
||
}
|
||
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
|
||
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
||
updateAlertMonitor()
|
||
}
|
||
.onChange(of: alertsFollowsSearch) { _, newValue in
|
||
// Follow search = the geofence mirrors the Stations-tab distance
|
||
// (capped at 8 mi); picking any fixed radius clears it.
|
||
FuelStore.saveAlertsFollowsSearch(newValue)
|
||
updateAlertMonitor()
|
||
}
|
||
.onChange(of: favouriteDropAlertsEnabled) { _, newValue in
|
||
FuelStore.saveFavouriteDropAlertsEnabled(newValue)
|
||
}
|
||
.onChange(of: favouriteDropAlertsFuel) { _, newValue in
|
||
FuelStore.saveFavouriteDropAlertsFuel(newValue)
|
||
}
|
||
.onChange(of: liveActivityEnabled) { _, newValue in
|
||
// Toggling the Live Activity on starts it with the current best
|
||
// station; toggling off ends any running activity.
|
||
FuelStore.saveLiveActivityEnabled(newValue)
|
||
ensureBackgroundMotionTrackingIfNeeded()
|
||
updateLiveActivity()
|
||
}
|
||
.onChange(of: liveActivityFuel) { _, newValue in
|
||
// The pill tracks its OWN fuel — independent of the Stations tab.
|
||
FuelStore.saveLiveActivityFuel(newValue)
|
||
updateLiveActivity()
|
||
}
|
||
.onChange(of: liveActivityRadiusMiles) { _, newValue in
|
||
// Same for its own radius (miles, converted at use).
|
||
FuelStore.saveLiveActivityRadiusMiles(newValue)
|
||
updateLiveActivity()
|
||
}
|
||
.onChange(of: liveActivityFollowsSearch) { _, newValue in
|
||
// The pill mirrors the Stations-tab distance instead of its own.
|
||
FuelStore.saveLiveActivityFollowsSearch(newValue)
|
||
updateLiveActivity()
|
||
}
|
||
.onChange(of: distanceUnit) { _, _ in
|
||
// Distance unit changes the radius — mirror the new radius in the
|
||
// Live Activity immediately.
|
||
updateLiveActivity()
|
||
// Follow-search alerts also move with the unit.
|
||
if alertsFollowsSearch {
|
||
updateAlertMonitor()
|
||
}
|
||
}
|
||
.onChange(of: priceDisplayStyle) { _, newValue in
|
||
// The pill carries its own display style in content state — push
|
||
// an update so the running activity re-renders in the new style
|
||
// immediately. Pass the style explicitly: SettingsView saves it
|
||
// in its own onChange, whose ordering vs this one isn't guaranteed,
|
||
// so reading storage here could push the OLD style.
|
||
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
||
}
|
||
)
|
||
}
|
||
|
||
/// The one banner to show right now. Network/offline and tip outcomes all
|
||
/// funnel here and render through the SAME floating chrome. A transient
|
||
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
|
||
/// network state for its ~3.5 s.
|
||
private var activeBanner: AppBanner? {
|
||
if let outcome = tipStore.outcome { return .tip(outcome) }
|
||
switch dataStatus {
|
||
case .live: return nil
|
||
case .offlineDump(let date): return .offlineDump(date: date)
|
||
case .connectionProblem: return .connectionProblem
|
||
}
|
||
}
|
||
|
||
private var mainContent: some View {
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .top) {
|
||
rootTabView
|
||
if let banner = currentBanner {
|
||
// Floating near the top of the screen, over the nav area —
|
||
// overlays content (never pushes it) and sits above the
|
||
// main content so it doesn't cover or block the list/pill
|
||
// beneath it. Opacity/offset are driven explicitly by
|
||
// reflectBanner so the fade-out reliably animates.
|
||
floatingBanner(banner)
|
||
.padding(.top, geo.safeAreaInsets.top + 10)
|
||
.opacity(bannerOpacity)
|
||
.offset(y: bannerOffset)
|
||
}
|
||
}
|
||
}
|
||
.task(id: activeBanner) {
|
||
// Fires on launch with the current banner AND whenever it changes —
|
||
// unlike onChange(computed) which can miss the first non-nil value
|
||
// when dataStatus is set slightly after the view appears.
|
||
reflectBanner(activeBanner)
|
||
}
|
||
}
|
||
|
||
/// Explicitly animates the banner in/out — a robust replacement for the
|
||
/// SwiftUI removal transition (which was silently not firing). Handles:
|
||
/// - first appearance → fade + settle down from slightly above
|
||
/// - any content change (tip→network, new tip) → fade the old out, then
|
||
/// fade the new in — so a tip ALWAYS visibly fades away
|
||
/// - dismissal → fade out + slide up, then clear after the fade
|
||
private func reflectBanner(_ newBanner: AppBanner?) {
|
||
guard newBanner != currentBanner else { return }
|
||
bannerClearTask?.cancel()
|
||
if let newBanner {
|
||
if currentBanner == nil {
|
||
present(newBanner)
|
||
} else {
|
||
// Crossfade: slide+fade the current out, then present the new.
|
||
withAnimation(.easeOut(duration: 0.2)) {
|
||
bannerOpacity = 0
|
||
bannerOffset = -28
|
||
}
|
||
bannerClearTask = Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
present(newBanner)
|
||
}
|
||
}
|
||
} else {
|
||
withAnimation(.easeOut(duration: 0.35)) {
|
||
bannerOpacity = 0
|
||
bannerOffset = -28
|
||
}
|
||
bannerClearTask = Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
currentBanner = nil
|
||
bannerOpacity = 0
|
||
bannerOffset = 0
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Fade a banner in from slightly above and settle it into place.
|
||
private func present(_ banner: AppBanner) {
|
||
currentBanner = banner
|
||
bannerOpacity = 0
|
||
bannerOffset = -16
|
||
withAnimation(.easeOut(duration: 0.3)) {
|
||
bannerOpacity = 1
|
||
bannerOffset = 0
|
||
}
|
||
}
|
||
|
||
private var rootTabView: some View {
|
||
TabView(selection: $selectedTab) {
|
||
stationsTab
|
||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||
.tag(0)
|
||
|
||
favouritesTab
|
||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
||
.tag(1)
|
||
|
||
alertsTab
|
||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||
.tag(2)
|
||
|
||
settingsTab
|
||
.tag(3)
|
||
}
|
||
}
|
||
|
||
/// The banner title for the bundled-snapshot case: date when the stamp
|
||
/// parsed, plain "Offline data" otherwise.
|
||
private func offlineTitle(date: String) -> String {
|
||
date.isEmpty
|
||
? NSLocalizedString("Offline data", comment: "")
|
||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||
}
|
||
|
||
/// ONE floating banner style for all notifications. Network/offline and
|
||
/// tip outcomes share the same chrome; only the tap action differs —
|
||
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
|
||
@ViewBuilder
|
||
private func floatingBanner(_ banner: AppBanner) -> some View {
|
||
switch banner {
|
||
case .offlineDump(let date):
|
||
let title = offlineTitle(date: date)
|
||
statusBannerCard(
|
||
icon: "wifi.slash", tint: .orange, title: title,
|
||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||
trailingIcon: "arrow.clockwise",
|
||
accessibilityLabel: date.isEmpty
|
||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||
) {
|
||
Task { await refresh(force: true) }
|
||
}
|
||
case .connectionProblem:
|
||
statusBannerCard(
|
||
icon: "wifi.exclamationmark", tint: .red,
|
||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||
trailingIcon: "arrow.clockwise",
|
||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||
) {
|
||
Task { await refresh(force: true) }
|
||
}
|
||
case .tip(let outcome):
|
||
statusBannerCard(
|
||
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
|
||
trailingIcon: "xmark", accessibilityLabel: outcome.message
|
||
) {
|
||
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The single floating-banner chrome shared by every notification
|
||
/// (network/offline + tip). Overlaid — never in flow — so it never moves
|
||
/// the content beneath it.
|
||
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
||
trailingIcon: String, accessibilityLabel: String,
|
||
action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
.frame(width: 30)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(title)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
if let subtitle {
|
||
Text(subtitle)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
Image(systemName: trailingIcon)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(Color(.secondarySystemGroupedBackground))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||
)
|
||
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
||
)
|
||
.padding(.horizontal, 12)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(accessibilityLabel)
|
||
}
|
||
|
||
/// Pushes the current best-in-radius station into the Live Activity.
|
||
/// No-op (or ends the activity) when the toggle is off or there's no
|
||
/// location/data yet. Uses the Live Activity's OWN fuel + radius (set in
|
||
/// Settings) — independent of the Stations-tab fuel/distance.
|
||
private func updateLiveActivity(priceDisplayStyleOverride: PriceDisplayStyle? = nil) {
|
||
// "Follow search" mirrors the Stations-tab distance exactly (no cap —
|
||
// the pill only displays, it doesn't geofence).
|
||
let radiusKM = liveActivityFollowsSearch
|
||
? distanceUnit.toKM(Double(stationLimit))
|
||
: distanceUnit.toKM(Double(liveActivityRadiusMiles))
|
||
LiveActivityManager.update(
|
||
stations: stations,
|
||
fuel: liveActivityFuel,
|
||
radiusKM: radiusKM,
|
||
location: location,
|
||
enabled: liveActivityEnabled,
|
||
priceDisplayStyle: priceDisplayStyleOverride
|
||
)
|
||
}
|
||
|
||
/// The Stations tab, extracted from `body` so the TabView expression stays
|
||
/// within the compiler's type-check budget.
|
||
private var stationsTab: some View {
|
||
// 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: displayed,
|
||
totalCount: displayed.count,
|
||
isLoading: isLoading,
|
||
selectedFuel: $selectedFuel,
|
||
sortMode: Binding(get: { effectiveSortMode }, set: { sortMode = $0 }),
|
||
stationLimit: $stationLimit,
|
||
distanceUnit: distanceUnit,
|
||
priceDisplayStyle: priceDisplayStyle,
|
||
baselinePrice: baselinePrice,
|
||
topStationID: topID,
|
||
location: location,
|
||
favouriteIDs: favouriteIDs,
|
||
stationTapAction: stationTapAction,
|
||
fuelSavingsSettings: fuelSavingsSettings,
|
||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||
onToggleFavourite: toggleFavourite,
|
||
onRefresh: { await refresh(force: true) }
|
||
)
|
||
}
|
||
|
||
/// The Favourites tab, extracted from `body` for the same type-check
|
||
/// budget reason.
|
||
private var favouritesTab: some View {
|
||
let scaffold = savingsScaffold()
|
||
let baselineStation = scaffold.baseline
|
||
return FavouritesView(
|
||
favourites: refreshedFavourites,
|
||
selectedFuel: selectedFuel,
|
||
location: location,
|
||
distanceUnit: distanceUnit,
|
||
priceDisplayStyle: priceDisplayStyle,
|
||
stationTapAction: stationTapAction,
|
||
fuelSavingsSettings: fuelSavingsSettings,
|
||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||
onToggleFavourite: toggleFavourite,
|
||
onReorder: reorderFavourites,
|
||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
|
||
)
|
||
}
|
||
|
||
/// The Alerts tab, extracted from `body` so the TabView expression stays
|
||
/// within the compiler's type-check budget.
|
||
private var alertsTab: some View {
|
||
AlertsView(
|
||
enabled: $alertsEnabled,
|
||
radius: $alertsRadius,
|
||
fuel: $alertsFuel,
|
||
alertMode: $alertsMode,
|
||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||
bestValueConfigured: fuelSavingsSettings.isConfigured,
|
||
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||
followsSearch: $alertsFollowsSearch,
|
||
liveActivityEnabled: $liveActivityEnabled,
|
||
liveActivityFuel: $liveActivityFuel,
|
||
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
||
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
||
stationLimit: stationLimit,
|
||
distanceUnit: distanceUnit,
|
||
monitoredCount: monitor.monitoredStationIDs.count,
|
||
lastAlert: monitor.lastAlert,
|
||
onCheckFavouriteDropNow: {
|
||
Task { await refresh(force: true) }
|
||
}
|
||
)
|
||
}
|
||
|
||
/// The Settings tab, extracted from `body` so the TabView expression stays
|
||
/// within the compiler's type-check budget.
|
||
private var settingsTab: some View {
|
||
SettingsView(
|
||
tipStore: tipStore,
|
||
distanceUnit: $distanceUnit,
|
||
priceDisplayStyle: $priceDisplayStyle,
|
||
stationTapAction: $stationTapAction,
|
||
fuelSavingsSettings: $fuelSavingsSettings,
|
||
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
|
||
dataRefreshMode: $dataRefreshMode,
|
||
alertsFuel: alertsFuel,
|
||
alertsRadiusKM: alertsRadius,
|
||
testAlertResult: monitor.lastTestResult,
|
||
onTestAlert: { monitor.sendTestNotification() },
|
||
onPlainTestAlert: { monitor.sendPlainTestNotification() },
|
||
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
|
||
onShowOnboarding: { showOnboarding = true },
|
||
debugStatus: monitor.debugStatus,
|
||
appCheapest: appCheapestStatus,
|
||
regionError: monitor.lastRegionError,
|
||
monitoredCount: monitor.monitoredStationIDs.count,
|
||
alertLog: monitor.alertLog,
|
||
regionEventCount: monitor.regionEventCount,
|
||
debugFenceIdentifier: monitor.debugFenceIdentifier,
|
||
roadDistanceDebugStatus: roadDistanceDebugStatus,
|
||
onDebugFence: { monitor.registerDebugFenceAroundMe() },
|
||
onClearDebugFence: { monitor.clearDebugFence() }
|
||
)
|
||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||
}
|
||
|
||
private func toggleFavourite(_ station: FuelStation, fuel: FuelType) {
|
||
let key = FavouriteEntry(station: station, fuel: fuel)
|
||
if favourites.contains(where: { $0.id == key.id }) {
|
||
favourites.removeAll { $0.id == key.id }
|
||
} else {
|
||
favourites.append(key)
|
||
}
|
||
FuelStore.saveFavourites(favourites)
|
||
WatchSyncManager.shared.pushSnapshot()
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
updateAlertMonitor()
|
||
}
|
||
|
||
/// Drag-and-drop reorder from the Favourites tab — persists the new order
|
||
/// and refreshes every surface that consumes it (widgets + geofence
|
||
/// priority, which follows the stored order).
|
||
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
|
||
favourites = newOrder
|
||
FuelStore.saveFavourites(favourites)
|
||
WatchSyncManager.shared.pushSnapshot()
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
updateAlertMonitor()
|
||
}
|
||
|
||
private func handlePendingWatchRefreshRequest() async {
|
||
guard FuelStore.hasPendingWatchRefreshRequest else { return }
|
||
await refresh(force: true)
|
||
}
|
||
|
||
/// Shows whatever prices we already have BEFORE the network call settles, so
|
||
/// a refresh never hides data behind a full-screen spinner. It deliberately
|
||
/// does NOT touch `dataStatus`: a fetch that is still in flight must not
|
||
/// flash an offline/connection banner, and a successful fetch clears the
|
||
/// state anyway. The catch path below labels the state honestly ONLY if the
|
||
/// fetch really fails.
|
||
private func showFallbackStationsWhileRefreshing() {
|
||
guard stations.isEmpty else { return }
|
||
if let cached = MirrorFuelProvider.cachedStations(
|
||
near: location?.lat,
|
||
lng: location?.lng,
|
||
fuel: selectedFuel,
|
||
radiusKM: nil
|
||
), !cached.isEmpty {
|
||
stations = cached
|
||
return
|
||
}
|
||
let saved = FuelStore.loadStations()
|
||
if !saved.isEmpty {
|
||
stations = saved
|
||
return
|
||
}
|
||
// Bundled snapshot — real but possibly dated. Shown so the list is
|
||
// never blank while the fetch is in flight; if the fetch fails, the
|
||
// catch path re-labels it with the honest bundled-dump date.
|
||
if let dump = BundledDumpProvider.stations, !dump.isEmpty {
|
||
stations = dump
|
||
}
|
||
}
|
||
|
||
/// Fetches fresh prices, but only when the cache is stale — unless
|
||
/// `force` is true (pull-to-refresh is the manual override).
|
||
private func refresh(force: Bool = false) async {
|
||
// Freshness ALONE must never skip the fetch: `lastRefresh` is stored
|
||
// keychain-first (survives reinstall) while the station file lives in
|
||
// the App Group container (wiped on reinstall). A "fresh" cache with
|
||
// no cached stations used to short-circuit here and leave the list
|
||
// empty for ever after every reinstall. Only skip when there is
|
||
// genuinely something to show.
|
||
let cachedCount = FuelStore.loadStations().count
|
||
if !force, FuelStore.isCacheFresh, cachedCount > 0 {
|
||
#if DEBUG
|
||
print("FB-REFRESH skip: cache fresh, \(cachedCount) cached stations")
|
||
#endif
|
||
return // data already fresh — skip network entirely
|
||
}
|
||
#if DEBUG
|
||
print("FB-REFRESH start force=\(force) cached=\(cachedCount)")
|
||
#endif
|
||
showFallbackStationsWhileRefreshing()
|
||
isLoading = true
|
||
defer { isLoading = false }
|
||
do {
|
||
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
|
||
let fetched = try await FuelPriceProvider.active.fetchStations(
|
||
near: location?.lat, lng: location?.lng,
|
||
fuel: selectedFuel,
|
||
radiusKM: nil // full-UK dump; device filters by miles radius
|
||
)
|
||
stations = fetched
|
||
#if DEBUG
|
||
print("FB-REFRESH ok: \(fetched.count) stations from \(LiveChainProvider.lastSource ?? "?")")
|
||
#endif
|
||
FuelStore.saveStations(fetched)
|
||
FuelStore.saveLastRefresh()
|
||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||
FuelStore.markWatchRefreshHandled()
|
||
}
|
||
WatchSyncManager.shared.pushSnapshot()
|
||
// Persist envelope metadata (source, station count, GOV.UK
|
||
// dataset update time) for the Settings → About section — the
|
||
// live chain records whichever leg served the fetch.
|
||
if let meta = LiveChainProvider.latestMeta {
|
||
FuelStore.saveRelayMeta(meta)
|
||
}
|
||
// Keep the keychain favourites fresh with the new prices — the
|
||
// widget's Favourites mode reads them from keychain (the only
|
||
// channel shared on SideStore free), so stale star-time snapshots
|
||
// would otherwise show old prices.
|
||
FuelStore.saveFavourites(refreshedFavourites)
|
||
monitor.evaluateFavouritePriceDropAlert(
|
||
favourites: refreshedFavourites,
|
||
previousSnapshots: previousFavouriteSnapshots,
|
||
monitoredFuel: favouriteDropAlertsFuel,
|
||
enabled: favouriteDropAlertsEnabled
|
||
)
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||
// Live data restored — any status banner no longer applies.
|
||
dataStatus = .live
|
||
} catch {
|
||
#if DEBUG
|
||
print("FB-REFRESH fail: \(error)")
|
||
#endif
|
||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||
FuelStore.markWatchRefreshHandled()
|
||
}
|
||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||
if FuelStore.loadStations().isEmpty {
|
||
// No cached prices — last resort is the bundled REAL dump
|
||
// (stale but genuine), then the demo sample set. The banner
|
||
// labels the bundled snapshot honestly with its own date.
|
||
if let dump = BundledDumpProvider.stations {
|
||
stations = dump
|
||
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||
} else {
|
||
stations = SampleFuelProvider.sampleStations
|
||
dataStatus = .live
|
||
}
|
||
} else {
|
||
// Saved prices are still on screen — but the fetch failed, so
|
||
// say so: a stale cache must not look like a live app.
|
||
stations = FuelStore.loadStations()
|
||
dataStatus = .connectionProblem
|
||
}
|
||
}
|
||
// Keep monitor geofences in sync with the freshest data.
|
||
updateAlertMonitor()
|
||
// Fresh prices → refresh the Live Activity pill too.
|
||
updateLiveActivity()
|
||
}
|
||
}
|
||
|
||
// MARK: - Station row (shared by Stations + Favourites tabs)
|
||
|
||
struct StationRow: View {
|
||
let station: FuelStation
|
||
let fuel: FuelType
|
||
let location: Coordinate?
|
||
let distanceUnit: DistanceUnit
|
||
let priceDisplayStyle: PriceDisplayStyle
|
||
let baselinePrice: Double?
|
||
/// This row is the cheapest price in range — the caller's existing
|
||
/// `topStationID` test. Feeds the CHEAPEST badge.
|
||
let isTopResult: Bool
|
||
let isFavourite: Bool
|
||
var tapAction: StationTapAction = .openMap
|
||
var fuelSavings: FuelSavingsAvailability?
|
||
/// The user's Best Value alert threshold — the app's existing gate for
|
||
/// calling a saving worth the drive, so a row labels itself BEST VALUE by
|
||
/// exactly the rule the alerts already use. Read per row (the alert path
|
||
/// reads it per evaluation too); pass explicitly to inject a value.
|
||
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||
var onToggleFavourite: () -> Void = {}
|
||
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||
|
||
/// The price's point size, scaled with Dynamic Type: the price is built from
|
||
/// an explicit size (its "p"/"/L" suffixes are sized relative to it inside
|
||
/// `priceTextAttributed`), so it needs a metric rather than a font style.
|
||
/// 22pt keeps the price the row's loudest element after the station name.
|
||
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
|
||
|
||
|
||
/// The comparison label sits beside the capsule only where the row is wide
|
||
/// enough for both (regular width). On an iPhone the mockup's canvas is
|
||
/// wider than the whole screen, so the label drops under the capsule rather
|
||
/// than starving the station name.
|
||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||
|
||
private var ragColor: Color {
|
||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
|
||
case .green: return .green
|
||
case .amber: return .orange
|
||
case .red: return .red
|
||
}
|
||
}
|
||
|
||
private var deltaText: String? {
|
||
guard let price = station.prices[fuel], let baselinePrice else { return nil }
|
||
let delta = price - baselinePrice
|
||
if abs(delta) <= 0.05 { return NSLocalizedString("best", comment: "") }
|
||
// Signed: +X.Xp pricier than baseline, -X.Xp cheaper (Closest mode);
|
||
// in Cheapest mode the baseline is the cheapest so deltas are ≥ 0.
|
||
return String(format: "%+.1fp", delta)
|
||
}
|
||
|
||
/// True when this row IS the cheapest in range — its delta reads "best",
|
||
/// so labelling it "vs cheapest" would be nonsense.
|
||
private var isCheapestPrice: Bool {
|
||
guard let price = station.prices[fuel], let baselinePrice else { return false }
|
||
return abs(price - baselinePrice) <= 0.05
|
||
}
|
||
|
||
/// The row's single recommendation badge, if any. BEST VALUE (worth the
|
||
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one,
|
||
/// being context rather than a recommendation.
|
||
private var recommendationBadge: StationRecommendationBadge? {
|
||
StationRecommendationBadge.badge(
|
||
netSavingGBP: fuelSavings?.result?.netSavingGBP,
|
||
isCheapest: isTopResult,
|
||
bestValueThresholdGBP: bestValueThresholdGBP
|
||
)
|
||
}
|
||
|
||
/// VoiceOver: the price, then the delta the redesign no longer prints — the
|
||
/// listing shows a savings capsule in its place, so the comparison against
|
||
/// the cheapest price in range would otherwise be visible only as a colour.
|
||
private func priceSpokenLabel(_ price: Double) -> String {
|
||
guard let deltaText else { return FuelStore.priceTextSpoken(price) }
|
||
let comparison = String(localized: "vs cheapest", comment: "Price delta comparison caption")
|
||
return "\(FuelStore.priceTextSpoken(price)), \(isCheapestPrice ? deltaText : "\(deltaText) \(comparison)")"
|
||
}
|
||
|
||
private func handleTap() {
|
||
switch tapAction {
|
||
case .openMap:
|
||
if let url = station.mapsDirectionsURL {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
case .showDetails:
|
||
showDetails()
|
||
}
|
||
}
|
||
|
||
private func showDetails() {
|
||
onShowDetails(station)
|
||
}
|
||
|
||
var body: some View {
|
||
// Two sections, one hairline: station identity on the left, price and
|
||
// savings on the right. No wrapper VStack and no full-width verdict
|
||
// banner under the row — the savings capsule carries the verdict inside
|
||
// the right-hand column, and the extra travel behind it is explained in
|
||
// the detail sheet instead of being repeated on every row.
|
||
HStack(alignment: .center, spacing: 12) {
|
||
// LEFT SECTION — station identity: brand logo, then name, address and
|
||
// distance. This section yields horizontal space to the financial
|
||
// column first: a truncated station identity is recoverable from the
|
||
// detail sheet, while a truncated saving is materially misleading.
|
||
HStack(alignment: .top, spacing: 12) {
|
||
// The logo, with the favourite star directly beneath it. Moving
|
||
// the star out of the price line is what gives the price column
|
||
// its width back: sat at the end of that line it held ~28pt of
|
||
// the column, which is exactly the space the wide pounds &
|
||
// pence price needs for its own digits on a 393pt screen.
|
||
// 10pt between logo and star: at 4pt the star's glyph sat almost
|
||
// against the logo's bottom edge and read as part of it rather
|
||
// than as the control it is.
|
||
VStack(spacing: 10) {
|
||
// Round brand logo (or generic fuel pump fallback)
|
||
Group {
|
||
if let asset = station.brandImageName {
|
||
Image(asset)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.padding(5)
|
||
} else {
|
||
Image(systemName: "fuelpump.fill")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundStyle(Color.black.opacity(0.82))
|
||
}
|
||
}
|
||
.frame(width: 46, height: 46)
|
||
.background(Circle().fill(Color.white))
|
||
.clipShape(Circle())
|
||
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
||
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
||
|
||
favouriteButton
|
||
}
|
||
|
||
// Name, full address, distance
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
// Keep the established compact row. Long names yield space
|
||
// to the complete price/verdict column rather than wrapping
|
||
// and forcing a financial amount to truncate.
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Text(station.name)
|
||
.font(.headline)
|
||
.multilineTextAlignment(.leading)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
ForEach(StationNotice.notices(for: station)) { notice in
|
||
Image(systemName: notice.symbolName)
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityLabel(notice.title)
|
||
}
|
||
}
|
||
// Wraps for the same reason as the name — addresses here run
|
||
// to 70 characters at the 95th percentile.
|
||
Text(StationAddressLine.display(address: station.address,
|
||
postcode: station.postcode,
|
||
name: station.name))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.leading)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
if let location {
|
||
let distance = FuelStore.stationDistanceDisplay(
|
||
station: station, userLat: location.lat, userLng: location.lng
|
||
)
|
||
// ONE-WAY distance: the listing shows how far the station
|
||
// is. The return leg stays in the savings explanation, where
|
||
// the extra travel it drives is actually costed.
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "location.fill")
|
||
.font(.caption2)
|
||
.accessibilityHidden(true)
|
||
Text(distance.label(unit: distanceUnit))
|
||
.font(.caption2)
|
||
.monospacedDigit()
|
||
}
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel(distance.spokenLabel(unit: distanceUnit))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.layoutPriority(0)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.layoutPriority(0)
|
||
|
||
// 1pt hairline between the two sections. Inset top and bottom so it
|
||
// spans the row's content without touching the row edges, and
|
||
// decorative: the columns are told apart by their content, and two
|
||
// hairs per row would just be noise to VoiceOver.
|
||
//
|
||
// `secondary`, not `separator`: over a dark row fill the system
|
||
// separator measures 7/255 brighter than the fill — measurably
|
||
// invisible — while the mockup's rule sits ~112 above it. Secondary
|
||
// lands on the mockup's own value in both appearances.
|
||
Rectangle()
|
||
.fill(Color.secondary)
|
||
.frame(width: 1)
|
||
.frame(maxHeight: .infinity)
|
||
.padding(.vertical, 6)
|
||
.accessibilityHidden(true)
|
||
|
||
// RIGHT SECTION — price and savings, every element leading-aligned on
|
||
// ONE axis. The 12pt row spacing plus this column's leading padding
|
||
// puts that axis ~22pt right of the hairline. (The mockup centres the
|
||
// price against the badge so its status dot hangs into the gutter; the
|
||
// redesign was explicitly asked not to do that.)
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
if let badge = recommendationBadge {
|
||
badgeCapsule(badge)
|
||
}
|
||
// Price line: the status dot and the price, leading-aligned on
|
||
// the column's one axis. The star no longer ends this line — it
|
||
// sits under the station logo — so this whole column width now
|
||
// belongs to the price, which is what lets the wide pounds &
|
||
// pence form (£1.58⁷/L) render at full size rather than being
|
||
// scaled down or truncated.
|
||
if let price = station.prices[fuel] {
|
||
HStack(spacing: 5) {
|
||
Circle()
|
||
.fill(ragColor)
|
||
.frame(width: 8, height: 8)
|
||
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
|
||
size: priceSize, weight: .bold)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.8)
|
||
}
|
||
// The visible "+1.8p vs cheapest" line is gone with the
|
||
// redesign, so the comparison rides on the price's label.
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel(priceSpokenLabel(price))
|
||
}
|
||
if let result = fuelSavings?.result {
|
||
savingsCapsule(result)
|
||
}
|
||
}
|
||
// The verdict owns its natural one-line width at normal Dynamic Type
|
||
// sizes. The identity column compresses first, preventing "Approx.
|
||
// Save £…" while keeping the same two-column visual design.
|
||
.fixedSize(horizontal: !dynamicTypeSize.isAccessibilitySize,
|
||
vertical: false)
|
||
.layoutPriority(1)
|
||
// The spec's ~20-24pt of air between the divider and the price
|
||
// column: the row's own 12pt spacing plus this 10pt.
|
||
.padding(.leading, 10)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: handleTap)
|
||
.onLongPressGesture(perform: showDetails)
|
||
}
|
||
|
||
/// The favourite toggle, sitting under the station logo. Kept as its own view
|
||
/// so it stays a separate VoiceOver element — the price beside it is a single
|
||
/// grouped label — and keeps its own tap target inside the row's tap and
|
||
/// long-press gestures.
|
||
private var favouriteButton: some View {
|
||
Button {
|
||
onToggleFavourite()
|
||
} label: {
|
||
Image(systemName: isFavourite ? "star.fill" : "star")
|
||
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
}
|
||
|
||
/// The savings capsule plus the comparison it is measured against. The
|
||
/// capsule itself is the shared verdict view, so a row, the detail sheet and
|
||
/// the compact summary cannot drift apart. The comparison label sits UNDER
|
||
/// the capsule, trailing-aligned with it, at every width — beside it, a long
|
||
/// verdict pushed the caption off the row and the pair no longer read as one
|
||
/// statement.
|
||
@ViewBuilder
|
||
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
FuelSavingsCapsule(result: result)
|
||
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
// Same narrow column as the capsule above it, so the same floor:
|
||
// scaled copy, not a trimmed "vs closest st…".
|
||
.minimumScaleFactor(0.7)
|
||
.multilineTextAlignment(.trailing)
|
||
}
|
||
}
|
||
|
||
/// One recommendation badge, above the price it annotates. Tinted rather
|
||
/// than the mockup's solid fill, like every other badge and capsule in the
|
||
/// app: coloured text keeps its contrast in both appearances, at every
|
||
/// Dynamic Type size, without introducing a colour outside the palette.
|
||
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
|
||
Text(badge.title)
|
||
.font(.caption2.bold())
|
||
.foregroundStyle(badge.tint)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 3)
|
||
.background(Capsule().fill(badge.tint.opacity(0.15)))
|
||
.lineLimit(1)
|
||
.fixedSize()
|
||
}
|
||
}
|
||
|
||
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
|
||
/// they stay out of the shared model the tests cover.
|
||
private extension StationRecommendationBadge {
|
||
var title: String {
|
||
switch self {
|
||
case .bestValue:
|
||
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold")
|
||
case .cheapest:
|
||
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
|
||
}
|
||
}
|
||
|
||
var tint: Color { self == .bestValue ? .green : .blue }
|
||
}
|
||
|
||
// MARK: - Location manager
|
||
|
||
@MainActor
|
||
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
||
@Published var current: Coordinate?
|
||
/// Horizontal accuracy (metres) of the fix behind `current`. Published
|
||
/// alongside it so a surface can tell a real GPS fix from a Wi-Fi guess.
|
||
@Published var currentAccuracy: Double?
|
||
/// How imprecise a fix may be before it is refused outright (see
|
||
/// `didUpdateLocations`). Wi-Fi/cell fixes routinely land at 500 m–3 km;
|
||
/// a GPS fix is tens of metres, so this only ever rejects the former.
|
||
static let maxTrustedFixAccuracyMeters: Double = 300
|
||
/// Called after every location fix (foreground AND background wake-ups).
|
||
/// The app uses it to re-register geofences around the new position —
|
||
/// SwiftUI's `.onChange` never runs in the background, so this delegate
|
||
/// hook is the only path that keeps the 18-region window following the
|
||
/// user while driving with the app suspended.
|
||
var onLocationUpdate: (() -> Void)?
|
||
private let manager = CLLocationManager()
|
||
|
||
override init() {
|
||
super.init()
|
||
manager.delegate = self
|
||
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||
manager.distanceFilter = 250 // metres — re-fires while driving
|
||
manager.pausesLocationUpdatesAutomatically = true
|
||
}
|
||
|
||
/// Continuous tracking while the app is in the foreground, so the list
|
||
/// re-fetches around the user's new position as they drive.
|
||
func startForegroundTracking() {
|
||
switch manager.authorizationStatus {
|
||
case .notDetermined:
|
||
manager.requestWhenInUseAuthorization()
|
||
manager.requestLocation()
|
||
case .authorizedWhenInUse, .authorizedAlways:
|
||
manager.startUpdatingLocation()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
/// One-shot fix (first launch / after permission grant).
|
||
func requestUpdate() {
|
||
manager.requestLocation()
|
||
}
|
||
|
||
/// Background wake-ups on significant movement. Needs Always permission;
|
||
/// called once the user enables alerts so geofences + prices follow them.
|
||
func startBackgroundTracking() {
|
||
guard manager.authorizationStatus == .authorizedAlways else { return }
|
||
manager.startMonitoringSignificantLocationChanges()
|
||
}
|
||
|
||
func stopForegroundTracking() {
|
||
// Significant-change monitoring keeps running in the background.
|
||
manager.stopUpdatingLocation()
|
||
}
|
||
|
||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||
switch manager.authorizationStatus {
|
||
case .authorizedAlways:
|
||
manager.startMonitoringSignificantLocationChanges()
|
||
manager.startUpdatingLocation()
|
||
case .authorizedWhenInUse:
|
||
manager.startUpdatingLocation()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||
guard let loc = locations.last else { return }
|
||
applyFix(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude,
|
||
accuracy: loc.horizontalAccuracy)
|
||
}
|
||
|
||
/// One position report. Real delegate fixes and the DEBUG `FB_FAKE_FIX`
|
||
/// probe both land here, so the probe exercises the guard below instead of
|
||
/// bypassing it.
|
||
///
|
||
/// Junk-fix guard: CoreLocation hands over Wi-Fi/cell fixes that can sit
|
||
/// kilometres out, plus negative accuracies meaning "invalid". Adopting one
|
||
/// as the user's position re-bases EVERY distance on screen — the fix lands
|
||
/// outside the route cache's 600 m ring, so each row falls back to the
|
||
/// straight line marked "Approx." even though its routed figure was
|
||
/// correct, and refreshing never helped because the junk fix kept arriving.
|
||
/// A coarse fix that stays near the position we already have is noise, so
|
||
/// the last trustworthy fix is kept; a coarse fix in another city is
|
||
/// evidence of real travel and IS accepted, or the app would go on serving
|
||
/// the old city's distances.
|
||
func applyFix(lat: Double, lng: Double, accuracy: Double) {
|
||
guard accuracy > 0 else { return }
|
||
let basis = current ?? FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
||
if let basis,
|
||
accuracy > Self.maxTrustedFixAccuracyMeters,
|
||
FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng)
|
||
<= FuelStore.roadDistanceCoarseFixRadiusMeters {
|
||
#if DEBUG
|
||
print("FB-FIX rejected coarse fix acc=\(String(format: "%.0f", accuracy))m "
|
||
+ "offset=\(String(format: "%.0f", FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng)))m")
|
||
#endif
|
||
return
|
||
}
|
||
currentAccuracy = accuracy
|
||
current = Coordinate(lat: lat, lng: lng)
|
||
// Drive the widget: every significant move re-orders the widget list
|
||
// around the new position immediately, instead of waiting for the
|
||
// widget's own 5-minute timeline tick. Throttled — WidgetKit ignores
|
||
// reload spam and the relay has a rate limit, so 60 s is plenty.
|
||
let now = Date()
|
||
if now.timeIntervalSince(lastWidgetReload) >= 60 {
|
||
lastWidgetReload = now
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
}
|
||
// Re-register geofences around the new position. Also fires on
|
||
// background significant-change wake-ups, which SwiftUI onChange
|
||
// never sees — this is what keeps alerts working while driving.
|
||
onLocationUpdate?()
|
||
}
|
||
|
||
private var lastWidgetReload = Date.distantPast
|
||
|
||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||
// Ignore — the app still works price-sorted without location.
|
||
}
|
||
}
|