diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 25788ae..ee1fa81 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -159,6 +159,9 @@ struct ContentView: View { showOnboarding = false } } + .sheet(item: $monitor.pendingStationMap) { request in + StationMapView(request: request) + } .onAppear { // Onboarding runs first on a fresh install — it owns the initial // permission prompts. Location tracking starts once it's done. diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 91e1e79..17cb666 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -1,6 +1,18 @@ import CoreLocation import UserNotifications import SwiftUI +import MapKit +import UIKit + +/// A station the user wants to see on a map — set when a notification tap +/// can't hand off to Apple Maps (e.g. inside LiveContainer), so the app shows +/// an in-app map with directions instead. +struct StationMapRequest: Identifiable { + let id = UUID() + let name: String + let latitude: Double + let longitude: Double +} /// Geofences favourite + closest stations and fires a local notification when /// the user enters a station whose price is the cheapest within the radius. @@ -12,6 +24,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca @Published var monitoredStationIDs: [String] = [] @Published var lastAlert: String? @Published private(set) var lastTestResult: String? + @Published var pendingStationMap: StationMapRequest? private let manager = CLLocationManager() private var stations: [FuelStation] = [] @@ -172,15 +185,65 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca }() let content = UNMutableNotificationContent() content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" - content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap to open." + content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default - - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) - + addAlertRequest(content: content, station: station) lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" } + /// Adds a notification whose tap opens directions to `station`. The + /// 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) { + content.userInfo = [ + "stationName": station.name, + "stationLat": station.lat, + "stationLng": station.lng, + ] + let options = MKMapSnapshotter.Options() + options.region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng), + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + ) + options.size = CGSize(width: 800, height: 400) + options.mapType = .standard + let snapshotter = MKMapSnapshotter(options: options) + snapshotter.start { [content] snapshot, error in + Task { @MainActor in + let finalContent = content + 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] + } + } + } + let request = UNNotificationRequest(identifier: UUID().uuidString, content: finalContent, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + } + } + + /// Draws a red pin on the map snapshot at the station's location. + private static func mapImage(snapshot: MKMapSnapshotter.Snapshot, station: FuelStation) -> UIImage? { + let image = snapshot.image + let point = snapshot.point(for: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)) + let renderer = UIGraphicsImageRenderer(size: image.size) + return renderer.image { context in + image.draw(at: .zero) + let pinSize = CGSize(width: 36, height: 36) + let pinRect = CGRect(x: point.x - pinSize.width / 2, y: point.y - pinSize.height, width: pinSize.width, height: pinSize.height) + let pin = UIImage(systemName: "mappin.circle.fill")? + .withTintColor(.systemRed, renderingMode: .alwaysTemplate) + pin?.draw(in: pinRect) + } + } + /// Fires a test alert built from REAL data: the cheapest station selling /// the monitored fuel within the configured alert radius of the current /// location — the same criteria a live geofence entry would apply, minus @@ -207,10 +270,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca }() let content = UNMutableNotificationContent() content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" - content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap to open." + content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.sound = .default - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) + addAlertRequest(content: content, station: candidate) lastTestResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" } else { // No real candidate (no stations loaded yet, or none sell the @@ -259,13 +321,41 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca } } - /// Tapping a notification just opens the app (the alert's "Tap to open"). - /// No extra routing — the app comes to the foreground as-is. + /// Tapping a notification opens Apple Maps directions to the station from + /// the user's current location. If Apple Maps can't be opened (e.g. inside + /// LiveContainer), an in-app map sheet with directions is shown instead. nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { + let userInfo = response.notification.request.content.userInfo + let name = userInfo["stationName"] as? String + let lat = userInfo["stationLat"] as? Double + let lng = userInfo["stationLng"] as? Double + Task { @MainActor in + if let name, let lat, let lng { + openDirections(to: name, latitude: lat, longitude: lng) + } + } completionHandler() } + + /// Opens Apple Maps with driving directions to the station; falls back to + /// the in-app map sheet when the hand-off fails. + @MainActor + private func openDirections(to name: String, latitude: Double, longitude: Double) { + let query = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + guard let url = URL(string: "maps://?daddr=\(latitude),\(longitude)&q=\(query)") else { + pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude) + return + } + UIApplication.shared.open(url) { opened in + Task { @MainActor in + if !opened { + self.pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude) + } + } + } + } } diff --git a/FuelBoard/StationMapView.swift b/FuelBoard/StationMapView.swift new file mode 100644 index 0000000..09f3a4d --- /dev/null +++ b/FuelBoard/StationMapView.swift @@ -0,0 +1,61 @@ +import SwiftUI +import MapKit +import UIKit + +/// In-app fallback for notification taps that can't hand off to Apple Maps +/// (e.g. inside LiveContainer). Shows the station on a map with the user's +/// location, plus a button to open driving directions in Apple Maps. +struct StationMapView: View { + let request: StationMapRequest + @Environment(\.dismiss) private var dismiss + @State private var position: MapCameraPosition = .automatic + + private var stationCoordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: request.latitude, longitude: request.longitude) + } + + var body: some View { + NavigationStack { + Map(position: $position) { + Marker(request.name, coordinate: stationCoordinate) + .tint(.red) + if let userLocation = FuelStore.loadLocation() { + Annotation("You", coordinate: CLLocationCoordinate2D( + latitude: userLocation.lat, + longitude: userLocation.lng + )) { + Image(systemName: "location.circle.fill") + .font(.title) + .foregroundStyle(.blue) + } + } + } + .navigationTitle(request.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { + openAppleMapsDirections() + } label: { + Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill") + } + } + } + .onAppear { + position = .region(MKCoordinateRegion( + center: stationCoordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )) + } + } + } + + private func openAppleMapsDirections() { + let query = request.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + guard let url = URL(string: "maps://?daddr=\(request.latitude),\(request.longitude)&q=\(query)") else { return } + UIApplication.shared.open(url) + } +}