From 9032c4a5519b622d744f3ff7fb9e3dda23b9ba8b Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 10:06:48 +0100 Subject: [PATCH] Both test buttons carry real station coords; map status surfaced - Plain test notification now picks the nearest station selling the monitored fuel and carries its real name, price, distance and coordinates (was a static no-station notification) - addAlertRequest reports whether the map snapshot attached or why it failed, appended to the Settings result line instead of silently delivering a notification without the embedded map - Settings routes both test buttons through the live monitor --- FuelBoard/ContentView.swift | 1 + FuelBoard/ProximityMonitor.swift | 70 ++++++++++++++++++++++++-------- FuelBoard/SettingsView.swift | 7 +++- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index ee1fa81..6d157a5 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -150,6 +150,7 @@ struct ContentView: View { alertsRadiusKM: alertsRadius, testAlertResult: monitor.lastTestResult, onTestAlert: { monitor.sendTestNotification() }, + onPlainTestAlert: { monitor.sendPlainTestNotification() }, onShowOnboarding: { showOnboarding = true } ) .tabItem { Label("Settings", systemImage: "gearshape.fill") } diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 17cb666..1238a10 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -195,7 +195,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca /// station's coordinates ride in `userInfo` so the tap handler can route /// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is /// attached so the expanded notification shows where the station is. - private func addAlertRequest(content: UNMutableNotificationContent, station: FuelStation) { + /// `mapStatus` reports whether the snapshot attached (or why not), so the + /// test UI can show it instead of failing silently. + private func addAlertRequest( + content: UNMutableNotificationContent, + station: FuelStation, + mapStatus: ((String) -> Void)? = nil + ) { content.userInfo = [ "stationName": station.name, "stationLat": station.lat, @@ -212,17 +218,20 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca snapshotter.start { [content] snapshot, error in Task { @MainActor in let finalContent = content + var status = "map: no image" if let snapshot, error == nil, let image = Self.mapImage(snapshot: snapshot, station: station) { let url = FileManager.default.temporaryDirectory .appendingPathComponent("alert-map-\(UUID().uuidString).png") - if let data = image.pngData() { - try? data.write(to: url) - if let attachment = try? UNNotificationAttachment(identifier: "map", url: url, options: nil) { - finalContent.attachments = [attachment] - } + if let data = image.pngData(), (try? data.write(to: url)) != nil, + let attachment = try? UNNotificationAttachment(identifier: "map", url: url, options: nil) { + finalContent.attachments = [attachment] + status = "map: attached" } + } else if let error { + status = "map: snapshot failed (\(error.localizedDescription))" } + mapStatus?(status) let request = UNNotificationRequest(identifier: UUID().uuidString, content: finalContent, trigger: nil) UNUserNotificationCenter.current().add(request) } @@ -272,8 +281,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default - addAlertRequest(content: content, station: candidate) - lastTestResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" + let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" + addAlertRequest(content: content, station: candidate) { status in + self.lastTestResult = "\(baseResult) · \(status)" + } + lastTestResult = baseResult } else { // No real candidate (no stations loaded yet, or none sell the // monitored fuel) — still fire so delivery is testable, but say @@ -294,17 +306,39 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca /// 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 + /// coupling. Still carries a REAL station (the nearest one selling the + /// monitored fuel) with its real name, price, distance and coordinates, so + /// the tap action — directions to that station — is testable regardless of + /// every alert condition. If notifications are denied system-wide nothing + /// can display, but this fires regardless of every FuelBoard condition. + func sendPlainTestNotification() { + let unit = FuelStore.loadDistanceUnit() + let location = FuelStore.loadLocation() + let sellers = stations.filter { $0.prices[fuel] != nil } + let nearest = sellers.min { lhs, rhs in + let ld = location.map { lhs.distanceKM(to: $0.lat, lng2: $0.lng) } ?? 0 + let rd = location.map { rhs.distanceKM(to: $0.lat, lng2: $0.lng) } ?? 0 + return ld < rd + } - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) + let content = UNMutableNotificationContent() + if let nearest, let price = nearest.prices[fuel] { + let brand = nearest.brand.isEmpty ? nearest.name : nearest.brand + let distanceText: String = { + guard let location else { return unit.format(0) } + return unit.format(nearest.distanceKM(to: location.lat, lng2: location.lng)) + }() + content.title = "\(brand) — plain test notification" + content.body = "\(nearest.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." + content.sound = .default + addAlertRequest(content: content, station: nearest) + } else { + content.title = "FuelBoard test notification" + content.body = "No station data loaded yet — open the Stations tab first." + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } } // MARK: - Notification presentation + tap diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 625d7a5..f609af3 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -18,6 +18,9 @@ struct SettingsView: View { /// Drives the real-data test — ContentView routes this to the live /// ProximityMonitor so the test uses its actual stations/fuel/radius. var onTestAlert: () -> Void = {} + /// Drives the plain test (no criteria) — routed to the live monitor so it + /// can carry a real station's name, price and coordinates. + var onPlainTestAlert: () -> Void = {} var onShowOnboarding: () -> Void = {} @StateObject private var tipStore = TipStore() @@ -72,14 +75,14 @@ struct SettingsView: View { .foregroundStyle(.green) } Button { - ProximityMonitor.sendPlainTestNotification() + onPlainTestAlert() } label: { Label("Send plain test notification", systemImage: "bell.slash.fill") } } header: { Text("Alerts") } footer: { - Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name and price. The second fires with no criteria at all, just to verify a notification appears and tapping it opens FuelBoard.") + Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions.") } Section {