diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 6eed94a..71fe7fb 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -405,7 +405,10 @@ struct ContentView: View { regionError: monitor.lastRegionError, monitoredCount: monitor.monitoredStationIDs.count, alertLog: monitor.alertLog, - regionEventCount: monitor.regionEventCount + regionEventCount: monitor.regionEventCount, + debugFenceIdentifier: monitor.debugFenceIdentifier, + onDebugFence: { monitor.registerDebugFenceAroundMe() }, + onClearDebugFence: { monitor.clearDebugFence() } ) .tabItem { Label("Settings", systemImage: "gearshape.fill") } } diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 17eba8f..f1a8dd2 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -96,6 +96,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca /// How many `didEnterRegion` callbacks this session — proof the geofences /// actually fire (vs. a gate silently blocking every alert). @Published private(set) var regionEventCount = 0 + /// Identifier of the armed debug fence (Settings → Debug → "Register + /// 100 m fence"), nil when not armed. The fence is a 100 m circle at the + /// current location that exists only to prove iOS delivers region events + /// on this install; entering it fires a plain notification, no gates. + @Published private(set) var debugFenceIdentifier: String? private let manager = CLLocationManager() private var stations: [FuelStation] = [] @@ -146,7 +151,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca refreshDebugStatus() - for region in manager.monitoredRegions { + // Drop every region EXCEPT the debug fence (if armed) — the fence is + // a fixed 100 m circle at a fixed point that must survive re-registration + // churn; stations re-register from scratch every pass. + for region in manager.monitoredRegions where !region.identifier.hasPrefix("debug-fence") { manager.stopMonitoring(for: region) } monitoredStationIDs = [] @@ -200,6 +208,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca manager.stopMonitoring(for: region) } monitoredStationIDs = [] + debugFenceIdentifier = nil } else { requestPermissions() // Re-register geofences from restored cache immediately. @@ -215,6 +224,42 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca if alertLog.count > 8 { alertLog.removeLast() } } + // MARK: - Debug fence + + /// Debug-only (Settings → Debug → "Register 100 m fence"): arms a 100 m + /// circle around the last known fix purely to prove iOS delivers region + /// events on this install. Stepping 100 m away and back should tick + /// "Region events this session" and fire a plain notification — no alert + /// gates apply. If that produces nothing, region delivery is broken at the + /// system level and no amount of alert-logic fixing will help. + func registerDebugFenceAroundMe() { + guard let fix = FuelStore.loadLocationWithDate() else { + logAlert(.error, "debug fence — no location fix yet (wait for one, then retry)") + return + } + clearDebugFence() + let id = "debug-fence-\(UUID().uuidString.prefix(8))" + let region = CLCircularRegion( + center: CLLocationCoordinate2D(latitude: fix.coordinate.lat, longitude: fix.coordinate.lng), + radius: 100, + identifier: id + ) + region.notifyOnEntry = true + region.notifyOnExit = false + manager.startMonitoring(for: region) + debugFenceIdentifier = id + logAlert(.entry, "debug fence armed — 100 m circle at current location (step away and back in)") + } + + func clearDebugFence() { + guard let id = debugFenceIdentifier else { return } + for region in manager.monitoredRegions where region.identifier == id { + manager.stopMonitoring(for: region) + } + debugFenceIdentifier = nil + logAlert(.gate, "debug fence cleared") + } + /// Recomputes the Settings → Debug location snapshot from current state. /// "In range" uses the SAME criteria as live alerts: stations selling the /// monitored fuel within the alert radius of the last known location. @@ -288,6 +333,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca // "delivery fine, a gate blocked the alert". self.regionEventCount += 1 self.logAlert(.entry, "didEnterRegion — \(region.identifier)") + // Debug fence (Settings → Debug): exists only to prove iOS + // delivers region events on this install. Entering it fires a + // plain notification with no alert gates — if the counter ticks + // and a banner shows after stepping out and back, delivery works. + if region.identifier.hasPrefix("debug-fence") { + self.logAlert(.fired, "debug fence entered — iOS delivered the region event (100 m)") + self.sendPlainTestNotification() + return + } self.handleEntry(region) } } @@ -585,6 +639,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions." let baseResult = "\(fuel.displayName) · \(choices.count) tied at \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))" lastTestResult = baseResult + logAlert(.fired, "test alert scheduled — \(choices.count) tied at \(String(format: "%.1fp", price))") addAlertRequest(content: content, station: candidate, ties: choices) { status in self.lastTestResult = "\(baseResult) · \(status)" } @@ -593,6 +648,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" lastTestResult = baseResult + logAlert(.fired, "test alert scheduled — \(brand) · \(String(format: "%.1fp", price))") addAlertRequest(content: content, station: candidate) { status in self.lastTestResult = "\(baseResult) · \(status)" } @@ -609,6 +665,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) + logAlert(.fired, "test alert scheduled — no \(fuel.displayName.lowercased()) candidate, fallback copy sent") lastTestResult = sellers.isEmpty ? "No stations loaded — open the Stations tab first" : "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))" @@ -642,6 +699,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.title = "\(brand) — plain test notification" content.body = "\(nearest.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default + logAlert(.fired, "plain test notification scheduled — \(brand)") addAlertRequest(content: content, station: nearest) } else { content.title = "FuelBoard test notification" @@ -649,6 +707,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) + logAlert(.fired, "plain test notification scheduled — no station data") } } diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 14654fb..babe622 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -46,6 +46,12 @@ struct SettingsView: View { var alertLog: [AlertLogEntry] = [] /// Region events received this session (monitor.regionEventCount). var regionEventCount: Int = 0 + /// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared. + var debugFenceIdentifier: String? + /// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe). + var onDebugFence: () -> Void = {} + /// Clears the armed fence (monitor.clearDebugFence). + var onClearDebugFence: () -> Void = {} @StateObject private var tipStore = TipStore() @State private var showTipAlert = false @@ -234,6 +240,27 @@ struct SettingsView: View { Text("Geofence monitoring") } + Section { + if debugFenceIdentifier != nil { + LabeledContent("Debug fence", value: "armed (100 m)") + Text("Step 100 m away from your current position, then walk back in. 'Region events this session' should tick and a plain notification fires — proof iOS delivers region events on this install, with no alert gates involved.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Clear debug fence", role: .destructive) { + onClearDebugFence() + } + } else { + Button("Register 100 m fence at current location") { + onDebugFence() + } + Text("A debug-only circle around your current position that proves geofence delivery end-to-end: step out 100 m and back in, and watch 'Region events this session' tick.") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Debug fence (delivery test)") + } + Section { LabeledContent("Region events this session", value: "\(regionEventCount)") if alertLog.isEmpty {