Notification tap opens directions to the station
- Alerts (live + real-data test) carry the station name + coordinates in userInfo and attach a map snapshot with a red pin, so the expanded notification shows where the station is - Tapping a notification opens Apple Maps driving directions to the station from the user's current location (maps:// daddr) - If Apple Maps hand-off fails (LiveContainer), an in-app map sheet (StationMapView) pins the station + user location with a Directions button
This commit is contained in:
@@ -159,6 +159,9 @@ struct ContentView: View {
|
|||||||
showOnboarding = false
|
showOnboarding = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
|
StationMapView(request: request)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Onboarding runs first on a fresh install — it owns the initial
|
// Onboarding runs first on a fresh install — it owns the initial
|
||||||
// permission prompts. Location tracking starts once it's done.
|
// permission prompts. Location tracking starts once it's done.
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import SwiftUI
|
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
|
/// Geofences favourite + closest stations and fires a local notification when
|
||||||
/// the user enters a station whose price is the cheapest within the radius.
|
/// 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 monitoredStationIDs: [String] = []
|
||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
|
@Published var pendingStationMap: StationMapRequest?
|
||||||
|
|
||||||
private let manager = CLLocationManager()
|
private let manager = CLLocationManager()
|
||||||
private var stations: [FuelStation] = []
|
private var stations: [FuelStation] = []
|
||||||
@@ -172,15 +185,65 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}()
|
}()
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
|
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
|
content.sound = .default
|
||||||
|
addAlertRequest(content: content, station: station)
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
|
|
||||||
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
|
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
|
/// Fires a test alert built from REAL data: the cheapest station selling
|
||||||
/// the monitored fuel within the configured alert radius of the current
|
/// the monitored fuel within the configured alert radius of the current
|
||||||
/// location — the same criteria a live geofence entry would apply, minus
|
/// 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()
|
let content = UNMutableNotificationContent()
|
||||||
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 to open."
|
content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
addAlertRequest(content: content, station: candidate)
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
lastTestResult = "\(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))"
|
||||||
} else {
|
} else {
|
||||||
// No real candidate (no stations loaded yet, or none sell the
|
// 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").
|
/// Tapping a notification opens Apple Maps directions to the station from
|
||||||
/// No extra routing — the app comes to the foreground as-is.
|
/// 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(
|
nonisolated func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
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()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user