Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):
- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
used in the savings path. Effect ~2 ppm (invisible), but the conversions
are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
price), pump saving, round-trip extra distance clamped at zero, travel fuel
priced at the candidate station, +/-5p break even, baseline labelled
"Closest station", sorting on the unrounded net saving. The brief's "£0.18"
for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
"2.0 mi"), not a rounding bug in the money.
UI (uncommitted since d7a0ecb):
- Done bar drawn in the bottom safe area from keyboard notifications:
.toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
(the compact banner does not print it today; a future row layout would).
153 tests, 0 failures. Simulator and device builds succeed.
1514 lines
71 KiB
Swift
1514 lines
71 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()
|
|
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
|
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
|
|
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
|
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
|
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
|
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
|
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
|
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = 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 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()
|
|
@State private var location: Coordinate? = {
|
|
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
|
return nil
|
|
}()
|
|
|
|
/// 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 func installLocationUpdateHook() {
|
|
locationManager.onLocationUpdate = { [weak monitor] in
|
|
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
// 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
|
|
return selling.filter {
|
|
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
|
}
|
|
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 displayedStations.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: station.distanceKM(to: location.lat, lng2: location.lng)
|
|
)
|
|
}
|
|
|
|
private var displayedStations: [FuelStation] {
|
|
sortedStations
|
|
}
|
|
|
|
private var sortedStations: [FuelStation] {
|
|
let pool = poolStations
|
|
switch effectiveSortMode {
|
|
case .closest:
|
|
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
|
|
return pool.sorted { lhs, rhs in
|
|
// Closest first; price only breaks ties.
|
|
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
if lDist != rDist { return lDist < rDist }
|
|
return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]!
|
|
}
|
|
case .cheapest:
|
|
return pool.sorted { lhs, rhs in
|
|
// Cheapest first; distance only breaks ties.
|
|
let lPrice = lhs.prices[selectedFuel]!
|
|
let rPrice = rhs.prices[selectedFuel]!
|
|
if lPrice != rPrice { return lPrice < rPrice }
|
|
guard let location else { return false }
|
|
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
|
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
}
|
|
case .worthTheDrive:
|
|
return sortedForWorthTheDrive(pool)
|
|
}
|
|
}
|
|
|
|
/// Row/detail savings lookup with the comparison baseline resolved ONCE.
|
|
///
|
|
/// `nearestSavingsBaseline` scans the whole station list, and StationsView
|
|
/// asks for savings once per visible row (plus again for the detail sheet),
|
|
/// so handing the view a per-station method would rescan ~8k stations
|
|
/// dozens of times per render.
|
|
private func savingsLookup(baseline: FuelStation?) -> (FuelStation) -> FuelSavingsAvailability? {
|
|
guard fuelSavingsSettings.isConfigured else { return { _ in nil } }
|
|
guard let location else { return { _ in .unavailable("Location unavailable") } }
|
|
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
|
|
return { station in
|
|
FuelSavingsEstimator.availability(
|
|
station: station,
|
|
fuel: selectedFuel,
|
|
baseline: baseline,
|
|
settings: fuelSavingsSettings,
|
|
amount: sessionFuelSavingsAmount,
|
|
journey: savingsJourneyMiles(for: station, location: location),
|
|
baselineJourney: baselineJourney
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The nearest station that sells the selected fuel — the Worth-the-Drive
|
|
/// comparison baseline.
|
|
///
|
|
/// Single O(n) pass on purpose: `min(by:)` re-evaluates its closure twice
|
|
/// per comparison, and each evaluation reads the road-distance cache. With
|
|
/// the full UK list that is thousands of redundant lookups per render, all
|
|
/// on the main thread during scene creation (see `loadRoadDistances`).
|
|
private var nearestSavingsBaseline: FuelStation? {
|
|
guard let location else { return nil }
|
|
var bestStation: FuelStation?
|
|
var bestMiles = Double.infinity
|
|
for station in stations where station.prices[selectedFuel] != nil {
|
|
let miles = savingsFallbackMiles(for: station, location: location)
|
|
if miles < bestMiles {
|
|
bestMiles = miles
|
|
bestStation = station
|
|
}
|
|
}
|
|
return bestStation
|
|
}
|
|
|
|
/// 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) {
|
|
return FuelSavingsJourney(miles: roadMeters / FuelSavingsCalculator.metresPerMile, basis: .roadRoute)
|
|
}
|
|
return FuelSavingsJourney(
|
|
miles: savingsFallbackMiles(for: station, location: location),
|
|
basis: .straightLine
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#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 Worth the Drive 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)
|
|
}
|
|
|
|
var body: some View {
|
|
configuredContent
|
|
}
|
|
|
|
private var configuredContent: AnyView {
|
|
interactionObservedContent
|
|
}
|
|
|
|
private var presentedContent: AnyView {
|
|
AnyView(
|
|
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
|
|
StationDetailView(
|
|
station: station,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
fuelSavings: savingsLookup(baseline: nearestSavingsBaseline)(station),
|
|
fuelSavingsAmount: sessionFuelSavingsAmount,
|
|
savingsBaselineStationName: nearestSavingsBaseline?.name,
|
|
isFavourite: favouriteIDs.contains(station.id),
|
|
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
|
|
)
|
|
}
|
|
.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")
|
|
// `-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")
|
|
#else
|
|
let shouldSkipOnboarding = false
|
|
#endif
|
|
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()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
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() {
|
|
locationManager.startForegroundTracking()
|
|
installLocationUpdateHook()
|
|
ensureBackgroundMotionTrackingIfNeeded()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
updateLiveActivity()
|
|
Task { await refresh(force: true) }
|
|
}
|
|
}
|
|
.onChange(of: fuelSavingsSettings) { old, new in
|
|
// 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
|
|
}
|
|
.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()
|
|
}
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
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 {
|
|
location = newLocation
|
|
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).
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
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 {
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
updateLiveActivity()
|
|
}
|
|
.onChange(of: alertsEnabled) { _, newValue in
|
|
FuelStore.saveAlertsEnabled(newValue)
|
|
monitor.setEnabled(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
ensureBackgroundMotionTrackingIfNeeded()
|
|
}
|
|
.onChange(of: alertsRadius) { _, newValue in
|
|
FuelStore.saveAlertsRadius(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
.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)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: newValue, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
.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)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
.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 {
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
}
|
|
.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 {
|
|
let baselineStation = nearestSavingsBaseline
|
|
return StationsView(
|
|
stations: displayedStations,
|
|
totalCount: sortedStations.count,
|
|
isLoading: isLoading,
|
|
selectedFuel: $selectedFuel,
|
|
sortMode: Binding(get: { effectiveSortMode }, set: { sortMode = $0 }),
|
|
stationLimit: $stationLimit,
|
|
distanceUnit: distanceUnit,
|
|
priceDisplayStyle: priceDisplayStyle,
|
|
baselinePrice: baselinePrice,
|
|
topStationID: topStationID,
|
|
location: location,
|
|
favouriteIDs: favouriteIDs,
|
|
stationTapAction: stationTapAction,
|
|
fuelSavingsSettings: fuelSavingsSettings,
|
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
|
savingsForStation: savingsLookup(baseline: baselineStation),
|
|
savingsBaselineStationName: baselineStation?.name,
|
|
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 baselineStation = nearestSavingsBaseline
|
|
return FavouritesView(
|
|
favourites: refreshedFavourites,
|
|
selectedFuel: selectedFuel,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
priceDisplayStyle: priceDisplayStyle,
|
|
stationTapAction: stationTapAction,
|
|
fuelSavingsSettings: fuelSavingsSettings,
|
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
|
savingsForStation: savingsLookup(baseline: baselineStation),
|
|
savingsBaselineStationName: baselineStation?.name,
|
|
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,
|
|
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,
|
|
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,
|
|
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()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
|
|
/// 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()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
|
|
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.
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
// 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?
|
|
let isTopResult: Bool
|
|
let isFavourite: Bool
|
|
/// Label for the cheapest-pump-price badge ("TOP" in price/distance order,
|
|
/// "CHEAPEST" when the list is ranked by net saving).
|
|
var topBadgeText: String = "TOP"
|
|
var tapAction: StationTapAction = .openMap
|
|
var fuelSavings: FuelSavingsAvailability?
|
|
/// The session fill-up figure (the one the adjustment control edits). The
|
|
/// compact banner does not print it today, but a row that carried a hard
|
|
/// zero would print "for £0" the moment it did — pass the real figure.
|
|
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
|
var onToggleFavourite: () -> Void = {}
|
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
|
|
|
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
|
|
}
|
|
|
|
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 {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 12) {
|
|
// LEFT — 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)
|
|
|
|
// MIDDLE — name, full address, distance
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(station.name)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
ForEach(StationNotice.notices(for: station)) { notice in
|
|
Image(systemName: notice.symbolName)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel(notice.title)
|
|
}
|
|
}
|
|
Text("\(station.address), \(station.postcode)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if let location {
|
|
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// RIGHT — price in £ (no p) + difference vs cheapest
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
if isTopResult {
|
|
// The badge always marks the CHEAPEST PUMP PRICE (never
|
|
// "best saving"), so in Worth the Drive order — where the
|
|
// top row is the best net saving — it may sit lower down
|
|
// the list, and says so. It sits ABOVE the price it
|
|
// annotates rather than beside the name, so it reads as
|
|
// part of the price and the name keeps its width.
|
|
Text(topBadgeText)
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(.blue.opacity(0.15)))
|
|
.foregroundStyle(.blue)
|
|
.lineLimit(1)
|
|
.fixedSize()
|
|
}
|
|
if let price = station.prices[fuel] {
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(ragColor)
|
|
.frame(width: 8, height: 8)
|
|
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold)
|
|
}
|
|
if let deltaText {
|
|
Text(deltaText)
|
|
.font(.caption2.bold().monospaced())
|
|
.foregroundStyle(ragColor)
|
|
if !isCheapestPrice {
|
|
// "+8.0p" on its own never said what it measured
|
|
// against; the delta is against the cheapest price
|
|
// in range in EVERY sort mode.
|
|
Text("vs cheapest")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FAR RIGHT — favourite star
|
|
Button {
|
|
onToggleFavourite()
|
|
} label: {
|
|
Image(systemName: isFavourite ? "star.fill" : "star")
|
|
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
if fuelSavings != nil {
|
|
// Full-width verdict banner under the row. Inline in the middle
|
|
// column it truncated ("Save £2.75 after tra…") because the
|
|
// price block squeezes that column — and the verdict is the
|
|
// point of this sort mode, so it gets its own line.
|
|
FuelSavingsSummaryView(
|
|
availability: fuelSavings,
|
|
amount: fuelSavingsAmount,
|
|
baselineStationName: nil,
|
|
compact: true,
|
|
distanceUnit: distanceUnit
|
|
)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(perform: handleTap)
|
|
.onLongPressGesture(perform: showDetails)
|
|
}
|
|
}
|
|
|
|
// MARK: - Location manager
|
|
|
|
@MainActor
|
|
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
|
@Published var current: Coordinate?
|
|
/// 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 }
|
|
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
|
|
// 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.
|
|
}
|
|
}
|