From b56d2a129c353e79e056b28b1d996e7fdc237212 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 10:12:35 +0100 Subject: [PATCH] Deliver alert immediately; map attaches in place afterwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification used to be added only inside the MKMapSnapshotter completion — if the snapshot hung or failed, no notification was ever delivered and the result line never showed the map status. Now the notification fires immediately (same identifier), the snapshot renders in the background with an 8s timeout, and on success the delivered notification is replaced in place with the map attached. Result line always updates: preparing -> attached / failed / timed out. --- FuelBoard/ProximityMonitor.swift | 37 ++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 1238a10..ee00a1d 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -207,6 +207,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca "stationLat": station.lat, "stationLng": station.lng, ] + let identifier = UUID().uuidString + + // Deliver the notification IMMEDIATELY — the alert must never wait on + // the map. If the snapshot later succeeds, the same identifier is used + // to replace the delivered notification with the map attached. + mapStatus?("map: preparing…") + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + ) + let options = MKMapSnapshotter.Options() options.region = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng), @@ -215,8 +225,24 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca options.size = CGSize(width: 800, height: 400) options.mapType = .standard let snapshotter = MKMapSnapshotter(options: options) - snapshotter.start { [content] snapshot, error in + + // Timeout so a hung snapshot can't leave the result line stuck on + // "preparing" forever. + var finished = false + let timeout = DispatchWorkItem { Task { @MainActor in + guard !finished else { return } + finished = true + mapStatus?("map: timed out") + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 8, execute: timeout) + + snapshotter.start { [content] snapshot, error in + timeout.cancel() + Task { @MainActor in + guard !finished else { return } + finished = true let finalContent = content var status = "map: no image" if let snapshot, error == nil, @@ -232,8 +258,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca status = "map: snapshot failed (\(error.localizedDescription))" } mapStatus?(status) - let request = UNNotificationRequest(identifier: UUID().uuidString, content: finalContent, trigger: nil) - UNUserNotificationCenter.current().add(request) + // Replace the delivered notification (same identifier) so the + // map appears in place. + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: identifier, content: finalContent, trigger: nil) + ) } } } @@ -282,10 +311,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" + lastTestResult = baseResult 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