When several stations share the cheapest price within the radius (within 0.01p), the alert registers a dynamic UNNotificationCategory with up to 4 action buttons (brand + distance, nearest first) and taps open directions to the chosen station. Shared FuelStore.tiedStations helper used by both the live geofence path and the Settings real-data test; 35 tests pass.
541 lines
25 KiB
Swift
541 lines
25 KiB
Swift
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
|
|
}
|
|
|
|
/// One station offered as a dynamic notification action button when several
|
|
/// stations tie for the cheapest price within the radius.
|
|
struct TieChoice {
|
|
let station: FuelStation
|
|
/// Short button label, e.g. "Tesco · 0.8 mi".
|
|
let buttonTitle: String
|
|
}
|
|
|
|
/// Geofences favourite + closest stations and fires a local notification when
|
|
/// the user enters a station whose price is the cheapest within the radius.
|
|
///
|
|
/// Region budget: iOS allows 20 monitored regions per app — favourites are
|
|
/// always registered first, then the closest remaining stations fill the rest.
|
|
@MainActor
|
|
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
|
|
@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] = []
|
|
private var favourites: [FuelStation] = []
|
|
private var fuel: FuelType = .e10
|
|
private var radiusKM: Double = 3.0
|
|
private var lastNotified: [String: Date] = [:] // dedup per station
|
|
private var enabled = false
|
|
/// Dynamically registered categories for tie-choice notifications. Kept so
|
|
/// setNotificationCategories never drops previously registered categories.
|
|
private var tieCategories: Set<UNNotificationCategory> = []
|
|
|
|
override init() {
|
|
super.init()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
manager.pausesLocationUpdatesAutomatically = true
|
|
// Show alert banners even while the app is open, and complete the tap
|
|
// action (opening FuelBoard) when the user taps a notification.
|
|
UNUserNotificationCenter.current().delegate = self
|
|
// Restore persisted state on background relaunch so region events work
|
|
// even before the view fully appears.
|
|
enabled = FuelStore.loadAlertsEnabled()
|
|
fuel = FuelStore.loadAlertsFuel()
|
|
radiusKM = FuelStore.loadAlertsRadius()
|
|
stations = FuelStore.loadStations()
|
|
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
|
}
|
|
|
|
/// Re-registers geofences. Call whenever stations/favourites/settings change.
|
|
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
|
|
/// get priority slots (a Diesel favourite must not grab a slot while
|
|
/// monitoring Unleaded).
|
|
func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) {
|
|
// Fall back to the shared cache when called before the first fetch
|
|
// completes (launch, background region-event wake).
|
|
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
|
|
self.favourites = favourites.isEmpty
|
|
? FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
|
: favourites.filter { $0.fuel == fuel }.map(\.station)
|
|
self.fuel = fuel
|
|
self.radiusKM = radiusKM
|
|
|
|
for region in manager.monitoredRegions {
|
|
manager.stopMonitoring(for: region)
|
|
}
|
|
monitoredStationIDs = []
|
|
|
|
guard enabled else { return }
|
|
|
|
// Favourites first (guaranteed slots), then closest stations, max 18.
|
|
// `self.favourites` is already filtered to the monitored fuel.
|
|
var candidates: [FuelStation] = self.favourites
|
|
let favIDs = Set(self.favourites.map(\.id))
|
|
let location = FuelStore.loadLocation()
|
|
let others = stations
|
|
.filter { !favIDs.contains($0.id) }
|
|
.sorted { lhs, rhs in
|
|
guard let location else { return false }
|
|
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
|
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
}
|
|
candidates.append(contentsOf: others)
|
|
|
|
var registered: [String] = []
|
|
for station in candidates.prefix(18) where station.prices[fuel] != nil {
|
|
let region = CLCircularRegion(
|
|
center: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng),
|
|
radius: 300,
|
|
identifier: station.id
|
|
)
|
|
region.notifyOnEntry = true
|
|
region.notifyOnExit = false
|
|
manager.startMonitoring(for: region)
|
|
registered.append(station.id)
|
|
}
|
|
monitoredStationIDs = registered
|
|
}
|
|
|
|
func setEnabled(_ enabled: Bool) {
|
|
self.enabled = enabled
|
|
if !enabled {
|
|
for region in manager.monitoredRegions {
|
|
manager.stopMonitoring(for: region)
|
|
}
|
|
monitoredStationIDs = []
|
|
} else {
|
|
requestPermissions()
|
|
// Re-register geofences from restored cache immediately.
|
|
update(stations: stations, favourites: FuelStore.loadFavourites(), fuel: fuel, radiusKM: radiusKM)
|
|
}
|
|
}
|
|
|
|
var isEnabled: Bool { enabled }
|
|
|
|
private func requestPermissions() {
|
|
// Region monitoring needs Always location for background delivery.
|
|
switch manager.authorizationStatus {
|
|
case .notDetermined:
|
|
manager.requestWhenInUseAuthorization()
|
|
manager.requestAlwaysAuthorization()
|
|
case .authorizedWhenInUse:
|
|
manager.requestAlwaysAuthorization()
|
|
default:
|
|
break
|
|
}
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
|
|
}
|
|
|
|
// MARK: - Region events
|
|
|
|
nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
|
|
Task { @MainActor in
|
|
self.handleEntry(region)
|
|
}
|
|
}
|
|
|
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
// Ignore — alerts still work while the app is open.
|
|
}
|
|
|
|
private func handleEntry(_ region: CLRegion) {
|
|
let stationID = region.identifier
|
|
// Fall back to cached data on background wake (stations may not be
|
|
// fetched yet when the app is relaunched by a region event).
|
|
if stations.isEmpty { stations = FuelStore.loadStations() }
|
|
guard let station = stations.first(where: { $0.id == stationID }) else { return }
|
|
|
|
// Dedup: one alert per station per hour.
|
|
if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 { return }
|
|
|
|
Task {
|
|
do {
|
|
// Fresh prices around the entered station (alert radius in km).
|
|
let fresh = try await FuelPriceProvider.active.fetchStations(
|
|
near: station.lat, lng: station.lng, fuel: fuel,
|
|
radiusKM: max(radiusKM, 10) // fetch wider than trigger radius
|
|
)
|
|
let withinRadius = fresh.filter {
|
|
$0.prices[fuel] != nil &&
|
|
$0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
|
|
}
|
|
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }),
|
|
cheapest.id == station.id,
|
|
let price = cheapest.prices[fuel] else { return }
|
|
|
|
// Ties: all stations in radius at the same cheapest price.
|
|
let tied = FuelStore.tiedStations(
|
|
in: withinRadius, fuel: fuel, price: price,
|
|
fromLat: station.lat, lng: station.lng
|
|
)
|
|
|
|
fireAlert(for: cheapest, price: price, ties: tied)
|
|
lastNotified[stationID] = Date()
|
|
} catch {
|
|
// Silent — geofence state stays valid for next entry.
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fireAlert(for station: FuelStation, price: Double, ties: [FuelStation] = []) {
|
|
let brand = station.brand.isEmpty ? station.name : station.brand
|
|
let unit = FuelStore.loadDistanceUnit()
|
|
let location = FuelStore.loadLocation()
|
|
let distanceText: String = {
|
|
guard let location else { return unit.format(0) }
|
|
return unit.format(station.distanceKM(to: location.lat, lng2: location.lng))
|
|
}()
|
|
let content = UNMutableNotificationContent()
|
|
content.sound = .default
|
|
|
|
// When several stations share the cheapest price, offer each as an
|
|
// action button so the user can pick which to get directions to.
|
|
let choices = ties.map { tie in
|
|
let tieBrand = tie.brand.isEmpty ? tie.name : tie.brand
|
|
let tieDistance: String = {
|
|
guard let location else { return unit.format(0) }
|
|
return unit.format(tie.distanceKM(to: location.lat, lng2: location.lng))
|
|
}()
|
|
return TieChoice(station: tie, buttonTitle: "\(tieBrand) · \(tieDistance)")
|
|
}
|
|
|
|
if choices.count > 1 {
|
|
content.title = "Cheapest \(fuel.displayName) nearby: \(choices.count) stations"
|
|
content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions."
|
|
addAlertRequest(content: content, station: station, ties: choices)
|
|
} else {
|
|
content.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
|
|
content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
|
|
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.
|
|
/// `mapStatus` reports whether the snapshot attached (or why not), so the
|
|
/// test UI can show it instead of failing silently.
|
|
///
|
|
/// When `ties` is non-empty (several stations share the cheapest price), a
|
|
/// dynamic UNNotificationCategory with up to 4 action buttons is registered
|
|
/// — one per tied station — and the tie list rides in `userInfo` so the tap
|
|
/// handler can resolve which station an action button refers to.
|
|
private func addAlertRequest(
|
|
content: UNMutableNotificationContent,
|
|
station: FuelStation,
|
|
ties: [TieChoice] = [],
|
|
mapStatus: ((String) -> Void)? = nil
|
|
) {
|
|
content.userInfo = [
|
|
"stationName": station.name,
|
|
"stationLat": station.lat,
|
|
"stationLng": station.lng,
|
|
]
|
|
if !ties.isEmpty {
|
|
content.userInfo["tieStations"] = ties.map { tie in
|
|
[
|
|
"name": tie.station.name,
|
|
"lat": tie.station.lat,
|
|
"lng": tie.station.lng,
|
|
]
|
|
}
|
|
}
|
|
let identifier = UUID().uuidString
|
|
|
|
// Render the map FIRST, then deliver ONE notification with the map
|
|
// attached. (Earlier, delivering immediately and "replacing" via a
|
|
// second add() showed up as two notifications in LiveContainer — the
|
|
// replace-in-place contract isn't honoured there.) A timeout
|
|
// guarantees the alert still fires, without the map, if the snapshot
|
|
// hangs.
|
|
mapStatus?("map: preparing…")
|
|
|
|
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)
|
|
|
|
var finished = false
|
|
let deliver: (String) -> Void = { status in
|
|
guard !finished else { return }
|
|
finished = true
|
|
if !ties.isEmpty {
|
|
self.registerTieCategory(identifier: identifier, ties: ties)
|
|
content.categoryIdentifier = "tie-\(identifier)"
|
|
}
|
|
mapStatus?(status)
|
|
UNUserNotificationCenter.current().add(
|
|
UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
|
|
)
|
|
}
|
|
|
|
// Timeout so a hung snapshot can't leave the alert undelivered (and
|
|
// the result line stuck on "preparing") forever.
|
|
let timeout = DispatchWorkItem {
|
|
Task { @MainActor in
|
|
deliver("map: timed out — no image")
|
|
}
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timeout)
|
|
|
|
snapshotter.start { [content] snapshot, error in
|
|
timeout.cancel()
|
|
Task { @MainActor in
|
|
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)) != nil,
|
|
let attachment = try? UNNotificationAttachment(identifier: "map", url: url, options: nil) {
|
|
content.attachments = [attachment]
|
|
status = "map: attached"
|
|
}
|
|
} else if let error {
|
|
status = "map: snapshot failed (\(error.localizedDescription))"
|
|
}
|
|
deliver(status)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Registers (or updates) a notification category whose action buttons are
|
|
/// the tied stations, capped at the 4 the system shows. Categories are
|
|
/// accumulated so prior registrations are never dropped.
|
|
private func registerTieCategory(identifier: String, ties: [TieChoice]) {
|
|
let actions = ties.prefix(4).enumerated().map { index, tie in
|
|
UNNotificationAction(
|
|
identifier: "tie_\(index)",
|
|
title: tie.buttonTitle,
|
|
options: [.foreground]
|
|
)
|
|
}
|
|
let category = UNNotificationCategory(
|
|
identifier: "tie-\(identifier)",
|
|
actions: Array(actions),
|
|
intentIdentifiers: [],
|
|
options: []
|
|
)
|
|
tieCategories.insert(category)
|
|
UNUserNotificationCenter.current().setNotificationCategories(tieCategories)
|
|
}
|
|
|
|
/// 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
|
|
/// the geofence itself. Uses the monitor's live state (fuel, radius,
|
|
/// stations) so the notification reflects the Alerts-tab configuration.
|
|
func sendTestNotification() {
|
|
let unit = FuelStore.loadDistanceUnit()
|
|
let location = FuelStore.loadLocation()
|
|
let sellers = stations.filter { $0.prices[fuel] != nil }
|
|
|
|
// Cheapest within radius, judged around the current location —
|
|
// identical to handleEntry's withinRadius logic.
|
|
let inRadius = sellers.filter { station in
|
|
guard let location else { return true }
|
|
return station.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
|
}
|
|
let candidate = inRadius.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
|
|
|
if let candidate, let price = candidate.prices[fuel] {
|
|
let brand = candidate.brand.isEmpty ? candidate.name : candidate.brand
|
|
let distanceText: String = {
|
|
guard let location else { return unit.format(0) }
|
|
return unit.format(candidate.distanceKM(to: location.lat, lng2: location.lng))
|
|
}()
|
|
let content = UNMutableNotificationContent()
|
|
content.sound = .default
|
|
|
|
// Ties: every station in radius at the same cheapest price, sorted
|
|
// nearest first. If more than one, offer them as action buttons.
|
|
let tieLat = location?.lat ?? candidate.lat
|
|
let tieLng = location?.lng ?? candidate.lng
|
|
let tied = FuelStore.tiedStations(
|
|
in: inRadius, fuel: fuel, price: price,
|
|
fromLat: tieLat, lng: tieLng
|
|
)
|
|
let choices = tied.map { station in
|
|
let tieBrand = station.brand.isEmpty ? station.name : station.brand
|
|
let tieDistance: String = {
|
|
guard let location else { return unit.format(0) }
|
|
return unit.format(station.distanceKM(to: location.lat, lng2: location.lng))
|
|
}()
|
|
return TieChoice(station: station, buttonTitle: "\(tieBrand) · \(tieDistance)")
|
|
}
|
|
|
|
if choices.count > 1 {
|
|
content.title = "Cheapest \(fuel.displayName) nearby: \(choices.count) stations"
|
|
content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions."
|
|
let baseResult = "\(fuel.displayName) · \(choices.count) tied at \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))"
|
|
lastTestResult = baseResult
|
|
addAlertRequest(content: content, station: candidate, ties: choices) { status in
|
|
self.lastTestResult = "\(baseResult) · \(status)"
|
|
}
|
|
} else {
|
|
content.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
|
|
content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
|
|
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)"
|
|
}
|
|
}
|
|
} else {
|
|
// No real candidate (no stations loaded yet, or none sell the
|
|
// monitored fuel) — still fire so delivery is testable, but say
|
|
// why the real criteria found nothing.
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "FuelBoard test alert"
|
|
content.body = sellers.isEmpty
|
|
? "No station data loaded yet — open the Stations tab first, then retry."
|
|
: "No \(fuel.displayName.lowercased()) station within \(unit.format(radiusKM)) of you."
|
|
content.sound = .default
|
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
UNUserNotificationCenter.current().add(request)
|
|
lastTestResult = sellers.isEmpty
|
|
? "No stations loaded — open the Stations tab first"
|
|
: "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))"
|
|
}
|
|
}
|
|
|
|
/// Fires a notification with NO criteria checks at all — no geofence, no
|
|
/// cheapest-within-radius, no dedup, no permission gate, no fuel/radius
|
|
/// 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 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
|
|
|
|
/// Show alert banners even while FuelBoard is in the foreground — without
|
|
/// this, the system silently suppresses notifications when the app is open.
|
|
nonisolated func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification,
|
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
) {
|
|
Task { @MainActor in
|
|
completionHandler([.banner, .sound])
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// When the notification carried tie-choice action buttons, the tapped
|
|
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
|
/// station from userInfo["tieStations"]; tapping the body itself falls
|
|
/// back to the primary station.
|
|
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 response.actionIdentifier.hasPrefix("tie_"),
|
|
let index = Int(response.actionIdentifier.dropFirst(4)),
|
|
let ties = userInfo["tieStations"] as? [[String: Any]],
|
|
ties.indices.contains(index),
|
|
let tieName = ties[index]["name"] as? String,
|
|
let tieLat = ties[index]["lat"] as? Double,
|
|
let tieLng = ties[index]["lng"] as? Double {
|
|
openDirections(to: tieName, latitude: tieLat, longitude: tieLng)
|
|
} else 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|