Two things the listing row was saying twice, or too loosely.
- The verdict capsule used a Label, and a Label lays its icon and title out
across the width it is given: in a listing row that left 22pt of empty space
between the symbol and "Save £1.07 after travel". It is now an HStack with
an explicit 3pt spacing (5pt in the detail sheet), so the text sits beside its
icon and the capsule background hugs the pair. Measured on the same row: the
capsule text moved x 412 -> 341 and the pill 270-831 -> 270-691.
- The address line printed the station name a second time. The official Fuel
Finder address field usually opens with the station's own name ("CHIDSWELL
SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell"): 14% of
the dump's addresses start with the exact name, and 13% also end with the
postcode the row appended again. StationAddressLine (new, in Shared/, so the
row and the detail sheet share one rule) drops a leading repeat of the name
and writes the postcode exactly once.
The rules are deliberately conservative: a leading segment carrying a house
number is a real street address and is left alone ("41 ANNACLOY RD ANNACLOY"),
and a partial-word match ("Jet" against "JETSON FILLING STATION") never cuts
into a word. 14 new tests (169 total, 0 failures) pin the shapes found in the
real dump as well as the examples above.
Verified on the simulator: the first listing row's second line went from
"CHIDSWELL SERVICE STA..." to "LEEDS ROAD, DEWSBURY...", and the detail sheet
reads "Sainsbury's Halifax / WADE STREET, HX1 1SB".
1516 lines
72 KiB
Swift
1516 lines
72 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(StationAddressLine.display(address: station.address,
|
|
postcode: station.postcode,
|
|
name: station.name))
|
|
.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.
|
|
}
|
|
}
|