Fix widget distance menu + geofence notification chain
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.
This commit is contained in:
@@ -179,6 +179,16 @@ struct ContentView: View {
|
|||||||
// network fetch start once it's done.
|
// network fetch start once it's done.
|
||||||
if FuelStore.loadHasCompletedOnboarding() {
|
if FuelStore.loadHasCompletedOnboarding() {
|
||||||
locationManager.startForegroundTracking()
|
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,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
@@ -277,7 +287,8 @@ struct ContentView: View {
|
|||||||
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
|
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
|
||||||
onShowOnboarding: { showOnboarding = true },
|
onShowOnboarding: { showOnboarding = true },
|
||||||
debugStatus: monitor.debugStatus,
|
debugStatus: monitor.debugStatus,
|
||||||
appCheapest: appCheapestStatus
|
appCheapest: appCheapestStatus,
|
||||||
|
regionError: monitor.lastRegionError
|
||||||
)
|
)
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||||
}
|
}
|
||||||
@@ -457,6 +468,12 @@ struct StationRow: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
||||||
@Published var current: Coordinate?
|
@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()
|
private let manager = CLLocationManager()
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
@@ -522,6 +539,10 @@ final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocat
|
|||||||
lastWidgetReload = now
|
lastWidgetReload = now
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
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
|
private var lastWidgetReload = Date.distantPast
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
||||||
/// every location/stations change via `refreshDebugStatus()`.
|
/// every location/stations change via `refreshDebugStatus()`.
|
||||||
@Published private(set) var debugStatus: DebugLocationStatus?
|
@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 let manager = CLLocationManager()
|
||||||
private var stations: [FuelStation] = []
|
private var stations: [FuelStation] = []
|
||||||
@@ -117,6 +121,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
|
|
||||||
guard enabled else { return }
|
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.
|
// Favourites first (guaranteed slots), then closest stations, max 18.
|
||||||
// `self.favourites` is already filtered to the monitored fuel.
|
// `self.favourites` is already filtered to the monitored fuel.
|
||||||
var candidates: [FuelStation] = self.favourites
|
var candidates: [FuelStation] = self.favourites
|
||||||
@@ -198,10 +206,14 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
|
|
||||||
private func requestPermissions() {
|
private func requestPermissions() {
|
||||||
// Region monitoring needs Always location for background delivery.
|
// 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 {
|
switch manager.authorizationStatus {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
manager.requestWhenInUseAuthorization()
|
manager.requestWhenInUseAuthorization()
|
||||||
manager.requestAlwaysAuthorization()
|
|
||||||
case .authorizedWhenInUse:
|
case .authorizedWhenInUse:
|
||||||
manager.requestAlwaysAuthorization()
|
manager.requestAlwaysAuthorization()
|
||||||
default:
|
default:
|
||||||
@@ -210,6 +222,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
|
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
|
// MARK: - Region events
|
||||||
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
|
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.
|
// 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) {
|
private func handleEntry(_ region: CLRegion) {
|
||||||
let stationID = region.identifier
|
let stationID = region.identifier
|
||||||
// Fall back to cached data on background wake (stations may not be
|
// Fall back to cached data on background wake (stations may not be
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ struct SettingsView: View {
|
|||||||
/// Live snapshot of the device fix + stations in range (from the monitor).
|
/// Live snapshot of the device fix + stations in range (from the monitor).
|
||||||
var debugStatus: DebugLocationStatus?
|
var debugStatus: DebugLocationStatus?
|
||||||
/// The app's own TOP-badge cheapest (selected fuel + stationLimit radius),
|
/// 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?
|
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()
|
@StateObject private var tipStore = TipStore()
|
||||||
@State private var showTipAlert = false
|
@State private var showTipAlert = false
|
||||||
@@ -90,6 +93,11 @@ struct SettingsView: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
}
|
}
|
||||||
|
if let regionError {
|
||||||
|
Label(regionError, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
Button {
|
Button {
|
||||||
onPlainTestAlert()
|
onPlainTestAlert()
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -70,19 +70,27 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent {
|
|||||||
@Parameter(title: "Distance", default: .five)
|
@Parameter(title: "Distance", default: .five)
|
||||||
var distance: WidgetDistance
|
var distance: WidgetDistance
|
||||||
|
|
||||||
/// Edit-Widget UI: Favourites ranks pinned stations cheapest-first and is
|
/// Edit-Widget UI: the Distance picker only makes sense for Cheapest
|
||||||
/// not radius-bound, so the Distance picker is hidden in that mode.
|
/// 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 {
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
When(\.$sort, .equalTo, WidgetSort.cheapest) {
|
||||||
|
Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
|
||||||
|
\.$fuel
|
||||||
|
\.$sort
|
||||||
|
\.$distance
|
||||||
|
}
|
||||||
|
} otherwise: {
|
||||||
When(\.$sort, .equalTo, WidgetSort.favourites) {
|
When(\.$sort, .equalTo, WidgetSort.favourites) {
|
||||||
Summary("Show \(\.$fuel) favourites") {
|
Summary("Show \(\.$fuel) favourites") {
|
||||||
\.$fuel
|
\.$fuel
|
||||||
\.$sort
|
\.$sort
|
||||||
}
|
}
|
||||||
} otherwise: {
|
} otherwise: {
|
||||||
Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
|
Summary("Show \(\.$fuel) by \(\.$sort)") {
|
||||||
\.$fuel
|
\.$fuel
|
||||||
\.$sort
|
\.$sort
|
||||||
\.$distance
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user