Settings: plain test notification + foreground banner delegate

- New 'Send plain test notification' button fires with zero criteria checks
  (no geofence, no cheapest check, no permission gate) to isolate delivery
  and tap behaviour
- ProximityMonitor is now the UNUserNotificationCenter delegate: banners
  show even in the foreground (system suppresses them otherwise), and taps
  complete the 'open FuelBoard' action
This commit is contained in:
FuelBoard Contributor
2026-08-12 09:24:59 +01:00
parent d6550409df
commit ae37e224c6
2 changed files with 49 additions and 2 deletions
+43 -1
View File
@@ -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()
}
}
+6 -1
View File
@@ -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 {