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
This commit is contained in:
FuelBoard Contributor
2026-08-12 10:06:48 +01:00
parent a964a45d49
commit 9032c4a551
3 changed files with 58 additions and 20 deletions
+1
View File
@@ -150,6 +150,7 @@ struct ContentView: View {
alertsRadiusKM: alertsRadius, alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult, testAlertResult: monitor.lastTestResult,
onTestAlert: { monitor.sendTestNotification() }, onTestAlert: { monitor.sendTestNotification() },
onPlainTestAlert: { monitor.sendPlainTestNotification() },
onShowOnboarding: { showOnboarding = true } onShowOnboarding: { showOnboarding = true }
) )
.tabItem { Label("Settings", systemImage: "gearshape.fill") } .tabItem { Label("Settings", systemImage: "gearshape.fill") }
+49 -15
View File
@@ -195,7 +195,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// station's coordinates ride in `userInfo` so the tap handler can route /// 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 /// 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. /// 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 = [ content.userInfo = [
"stationName": station.name, "stationName": station.name,
"stationLat": station.lat, "stationLat": station.lat,
@@ -212,17 +218,20 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
snapshotter.start { [content] snapshot, error in snapshotter.start { [content] snapshot, error in
Task { @MainActor in Task { @MainActor in
let finalContent = content let finalContent = content
var status = "map: no image"
if let snapshot, error == nil, if let snapshot, error == nil,
let image = Self.mapImage(snapshot: snapshot, station: station) { let image = Self.mapImage(snapshot: snapshot, station: station) {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("alert-map-\(UUID().uuidString).png") .appendingPathComponent("alert-map-\(UUID().uuidString).png")
if let data = image.pngData() { if let data = image.pngData(), (try? data.write(to: url)) != nil,
try? data.write(to: url) let attachment = try? UNNotificationAttachment(identifier: "map", url: url, options: nil) {
if let attachment = try? UNNotificationAttachment(identifier: "map", url: url, options: nil) {
finalContent.attachments = [attachment] 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) let request = UNNotificationRequest(identifier: UUID().uuidString, content: finalContent, trigger: nil)
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request)
} }
@@ -272,8 +281,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" content.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
content.sound = .default content.sound = .default
addAlertRequest(content: content, station: candidate) let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))"
lastTestResult = "\(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 { } else {
// No real candidate (no stations loaded yet, or none sell the // No real candidate (no stations loaded yet, or none sell the
// monitored fuel) still fire so delivery is testable, but say // monitored fuel) still fire so delivery is testable, but say
@@ -294,18 +306,40 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// Fires a notification with NO criteria checks at all no geofence, no /// Fires a notification with NO criteria checks at all no geofence, no
/// cheapest-within-radius, no dedup, no permission gate, no fuel/radius /// cheapest-within-radius, no dedup, no permission gate, no fuel/radius
/// coupling. Purely tests "does a notification appear, and does tapping /// coupling. Still carries a REAL station (the nearest one selling the
/// it open the app". If notifications are denied system-wide nothing can /// monitored fuel) with its real name, price, distance and coordinates, so
/// display, but this fires regardless of every FuelBoard condition. /// the tap action directions to that station is testable regardless of
static func sendPlainTestNotification() { /// every alert condition. If notifications are denied system-wide nothing
let content = UNMutableNotificationContent() /// can display, but this fires regardless of every FuelBoard condition.
content.title = "FuelBoard test notification" func sendPlainTestNotification() {
content.body = "This is a plain test alert — no criteria were checked. Tap to open FuelBoard." let unit = FuelStore.loadDistanceUnit()
content.sound = .default 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 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) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request)
} }
}
// MARK: - Notification presentation + tap // MARK: - Notification presentation + tap
+5 -2
View File
@@ -18,6 +18,9 @@ struct SettingsView: View {
/// Drives the real-data test ContentView routes this to the live /// Drives the real-data test ContentView routes this to the live
/// ProximityMonitor so the test uses its actual stations/fuel/radius. /// ProximityMonitor so the test uses its actual stations/fuel/radius.
var onTestAlert: () -> Void = {} 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 = {} var onShowOnboarding: () -> Void = {}
@StateObject private var tipStore = TipStore() @StateObject private var tipStore = TipStore()
@@ -72,14 +75,14 @@ struct SettingsView: View {
.foregroundStyle(.green) .foregroundStyle(.green)
} }
Button { Button {
ProximityMonitor.sendPlainTestNotification() onPlainTestAlert()
} label: { } label: {
Label("Send plain test notification", systemImage: "bell.slash.fill") Label("Send plain test notification", systemImage: "bell.slash.fill")
} }
} header: { } header: {
Text("Alerts") Text("Alerts")
} footer: { } 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 { Section {