From 6cbf1dfc8f46401ba769e0c3d691ccb08fe9ba69 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sat, 15 Aug 2026 11:46:00 +0100 Subject: [PATCH] Debug: 100 m fence delivery test + test-button trace logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Debug → 'Debug fence (delivery test)': registers a 100 m CLCircularRegion at the current fix that exists solely to prove iOS delivers region events on this install. Entering it ticks 'Region events this session', logs 'debug fence entered' in the Alert trace, and fires a plain notification — zero alert gates. Cleared via button; survives the station re-registration loop (update() now skips debug-fence regions); cleared when alerts are disabled. The Test alert / Plain test buttons now also write to the Alert trace ('test alert scheduled — …'), so the trace reflects every probe, not just live geofence entries. 83 tests, Release build green. --- FuelBoard/ContentView.swift | 5 ++- FuelBoard/ProximityMonitor.swift | 61 +++++++++++++++++++++++++++++++- FuelBoard/SettingsView.swift | 27 ++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) 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 {