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:
@@ -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") }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user