From 1dcd47c6cdea5d4b966bba13427e4ffb979de6ab Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 18:18:24 +0100 Subject: [PATCH] Fix widget distance menu + geofence notification chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget distance menu: the Distance picker only makes sense for Cheapest ordering. It was hidden for Favourites but Closest still showed it (regressed when the per-widget Distance picker was added). parameterSummary now nests When clauses: Distance visible for Cheapest only; Closest and Favourites hide it. Notifications: three compounding defects kept real geofence alerts from ever firing while the app was suspended: 1. Always permission was never properly requested. requestPermissions fired WhenInUse and Always back-to-back; iOS ignores the second call while the first prompt is pending, leaving the app stuck on WhenInUse — and region entries are never delivered in the background. Added locationManagerDidChangeAuthorization to ProximityMonitor: escalate WhenInUse -> Always, and re-register geofences on grant (regions registered under WhenInUse-only won't deliver in the background). 2. The 18-region window was frozen in the background. Re-registration lived in SwiftUI .onChange(of: locationManager.current), which never runs while suspended. Added a delegate hook (LocationManager.onLocationUpdate) fired from didUpdateLocations on every fix including background significant-change wake-ups; the app wires it to re-register geofences around the new position. 3. Region-registration failures were invisible. monitoringDidFailFor was never implemented, so a failed startMonitoring (region budget, auth, radius) silently stopped alerts. Now surfaced as monitor.lastRegionError and shown in Settings -> Debug; cleared on the next successful registration. --- FuelBoard/ContentView.swift | 23 ++++++++++- FuelBoard/ProximityMonitor.swift | 47 ++++++++++++++++++++++- FuelBoard/SettingsView.swift | 10 ++++- FuelBoardWidgets/WidgetConfigIntent.swift | 24 ++++++++---- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 554102b..1b0f587 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -179,6 +179,16 @@ struct ContentView: View { // network fetch start once it's done. if FuelStore.loadHasCompletedOnboarding() { locationManager.startForegroundTracking() + // Geofences must follow the user even in the background: + // wire the delegate hook (fires on every fix incl. background + // significant-change wake-ups) to re-register the region + // window around the new position. SwiftUI onChange alone + // never runs while suspended, so alerts would otherwise stay + // frozen around the last foreground fix. + locationManager.onLocationUpdate = { [weak monitor] in + monitor?.update(stations: stations, favourites: refreshedFavourites, + fuel: alertsFuel, radiusKM: alertsRadius) + } monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: alertsRadius) monitor.setEnabled(alertsEnabled) @@ -277,7 +287,8 @@ struct ContentView: View { onRefreshDebugStatus: { monitor.refreshDebugStatus() }, onShowOnboarding: { showOnboarding = true }, debugStatus: monitor.debugStatus, - appCheapest: appCheapestStatus + appCheapest: appCheapestStatus, + regionError: monitor.lastRegionError ) .tabItem { Label("Settings", systemImage: "gearshape.fill") } } @@ -457,6 +468,12 @@ struct StationRow: View { @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() { @@ -522,6 +539,10 @@ final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocat 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 diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index c9ded30..37ff638 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -65,6 +65,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca /// Debug-only snapshot for the Settings → Debug section. Recomputed on /// every location/stations change via `refreshDebugStatus()`. @Published private(set) var debugStatus: DebugLocationStatus? + /// Last CoreLocation region-monitoring failure, if any. Shown in Settings + /// → Debug so a silently-failed `startMonitoring` (region budget exceeded, + /// auth not granted, radius too large) is visible instead of invisible. + @Published private(set) var lastRegionError: String? private let manager = CLLocationManager() private var stations: [FuelStation] = [] @@ -117,6 +121,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca guard enabled else { return } + // A successful re-registration pass resets any earlier failure so the + // Debug section only shows the CURRENT region problem. + lastRegionError = nil + // Favourites first (guaranteed slots), then closest stations, max 18. // `self.favourites` is already filtered to the monitored fuel. var candidates: [FuelStation] = self.favourites @@ -198,10 +206,14 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca private func requestPermissions() { // Region monitoring needs Always location for background delivery. + // Never request WhenInUse and Always back-to-back — iOS ignores the + // second call while the first prompt is pending, leaving the app + // stuck on WhenInUse and background geofence entries undelivered. + // Escalation happens in locationManagerDidChangeAuthorization: + // WhenInUse granted -> request Always -> re-register on grant. switch manager.authorizationStatus { case .notDetermined: manager.requestWhenInUseAuthorization() - manager.requestAlwaysAuthorization() case .authorizedWhenInUse: manager.requestAlwaysAuthorization() default: @@ -210,6 +222,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } } + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .authorizedWhenInUse: + // Escalate: background region delivery (the whole point of + // alerts) requires Always. iOS shows the upgrade prompt here. + manager.requestAlwaysAuthorization() + case .authorizedAlways: + // Regions registered under WhenInUse-only won't deliver in the + // background; re-register now that Always is granted. (ContentView's + // LocationManager separately restarts significant-change tracking + // on its own delegate callback, so the region window follows the + // user in the background.) + update(stations: stations, favourites: FuelStore.loadFavourites(), + fuel: fuel, radiusKM: radiusKM) + default: + break + } + } + // MARK: - Region events nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { @@ -222,6 +253,20 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca // Ignore — alerts still work while the app is open. } + nonisolated func locationManager( + _ manager: CLLocationManager, + monitoringDidFailFor region: CLRegion?, + withError error: Error + ) { + Task { @MainActor in + // Surface region-registration failures instead of swallowing + // them: a failed startMonitoring (region budget, auth, radius) + // means alerts silently stop. Cleared on the next successful + // re-registration (update() resets it). + lastRegionError = "\(region?.identifier ?? "?") · \(error.localizedDescription)" + } + } + private func handleEntry(_ region: CLRegion) { let stationID = region.identifier // Fall back to cached data on background wake (stations may not be diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index eb51f84..d6e2eb4 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -30,8 +30,11 @@ struct SettingsView: View { /// Live snapshot of the device fix + stations in range (from the monitor). var debugStatus: DebugLocationStatus? /// The app's own TOP-badge cheapest (selected fuel + stationLimit radius), - /// so the debug "Cheapest in range" row matches the app list exactly. + /// passed in so the Debug section mirrors the app list exactly. var appCheapest: DebugAppCheapest? + /// Last CoreLocation region-monitoring failure (monitor.lastRegionError). + /// Shown in Debug so a silently-failed geofence registration is visible. + var regionError: String? @StateObject private var tipStore = TipStore() @State private var showTipAlert = false @@ -90,6 +93,11 @@ struct SettingsView: View { .font(.footnote) .foregroundStyle(.green) } + if let regionError { + Label(regionError, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + } Button { onPlainTestAlert() } label: { diff --git a/FuelBoardWidgets/WidgetConfigIntent.swift b/FuelBoardWidgets/WidgetConfigIntent.swift index d02ebf0..bb28dca 100644 --- a/FuelBoardWidgets/WidgetConfigIntent.swift +++ b/FuelBoardWidgets/WidgetConfigIntent.swift @@ -70,20 +70,28 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent { @Parameter(title: "Distance", default: .five) var distance: WidgetDistance - /// Edit-Widget UI: Favourites ranks pinned stations cheapest-first and is - /// not radius-bound, so the Distance picker is hidden in that mode. + /// Edit-Widget UI: the Distance picker only makes sense for Cheapest + /// ordering — Closest is inherently "nearest within range" and Favourites + /// is not radius-bound. Show it for Cheapest only; hide for both others. static var parameterSummary: some ParameterSummary { - When(\.$sort, .equalTo, WidgetSort.favourites) { - Summary("Show \(\.$fuel) favourites") { - \.$fuel - \.$sort - } - } otherwise: { + When(\.$sort, .equalTo, WidgetSort.cheapest) { Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") { \.$fuel \.$sort \.$distance } + } otherwise: { + When(\.$sort, .equalTo, WidgetSort.favourites) { + Summary("Show \(\.$fuel) favourites") { + \.$fuel + \.$sort + } + } otherwise: { + Summary("Show \(\.$fuel) by \(\.$sort)") { + \.$fuel + \.$sort + } + } } } }