diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 99599bf..cbc5a06 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -8,7 +8,7 @@ import SwiftUI /// Region budget: iOS allows 20 monitored regions per app — favourites are /// always registered first, then the closest remaining stations fill the rest. @MainActor -final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { +final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate { @Published var monitoredStationIDs: [String] = [] @Published var lastAlert: String? @@ -25,6 +25,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyHundredMeters manager.pausesLocationUpdatesAutomatically = true + // Show alert banners even while the app is open, and complete the tap + // action (opening FuelBoard) when the user taps a notification. + UNUserNotificationCenter.current().delegate = self // Restore persisted state on background relaunch so region events work // even before the view fully appears. enabled = FuelStore.loadAlertsEnabled() @@ -187,4 +190,43 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) } + + /// Fires a notification with NO criteria checks at all — no geofence, no + /// cheapest-within-radius, no dedup, no permission gate, no fuel/radius + /// coupling. Purely tests "does a notification appear, and does tapping + /// it open the app". If notifications are denied system-wide nothing can + /// display, but this fires regardless of every FuelBoard condition. + static func sendPlainTestNotification() { + let content = UNMutableNotificationContent() + content.title = "FuelBoard test notification" + content.body = "This is a plain test alert — no criteria were checked. Tap to open FuelBoard." + content.sound = .default + + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + // MARK: - Notification presentation + tap + + /// Show alert banners even while FuelBoard is in the foreground — without + /// this, the system silently suppresses notifications when the app is open. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + Task { @MainActor in + completionHandler([.banner, .sound]) + } + } + + /// Tapping a notification just opens the app (the alert's "Tap to open"). + /// No extra routing — the app comes to the foreground as-is. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + completionHandler() + } } diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 6457c4f..353adce 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -59,10 +59,15 @@ struct SettingsView: View { .font(.footnote) .foregroundStyle(.secondary) } + Button { + ProximityMonitor.sendPlainTestNotification() + } label: { + Label("Send plain test notification", systemImage: "bell.slash.fill") + } } header: { Text("Alerts") } footer: { - Text("Sends a notification exactly like a real price alert for \(alertsFuel.displayName.lowercased()) within \(distanceUnit.format(alertsRadiusKM)). Tapping it opens FuelBoard — just like a real alert.") + Text("The first button sends a notification shaped like a real price alert for \(alertsFuel.displayName.lowercased()) within \(distanceUnit.format(alertsRadiusKM)). The second fires with no criteria at all — no geofence, no cheapest check — just to verify a notification appears and tapping it opens FuelBoard.") } Section {