// // MapSelectionView.swift // StikJIT // // Created by Stephen on 11/3/25. // import SwiftUI import MapKit import UIKit private struct CoordinateSnapshot: Equatable { let latitude: Double let longitude: Double init(_ coordinate: CLLocationCoordinate2D) { latitude = coordinate.latitude longitude = coordinate.longitude } var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } } private struct RouteSearchSelection { let title: String let coordinate: CLLocationCoordinate2D } private enum RouteSearchField { case start case end } private struct RouteSimulationPlan { let displayCoordinates: [CLLocationCoordinate2D] let distance: CLLocationDistance let expectedTravelTime: TimeInterval } private enum RouteSimulationDefaults { static let pathSamplingDistance: CLLocationDistance = 10 static let playbackTickInterval: TimeInterval = 0.5 static let minimumSpeedMetersPerSecond: CLLocationSpeed = 1.0 } private struct RoutePlaybackSample { let coordinate: CLLocationCoordinate2D let delayFromPrevious: TimeInterval } private struct OpenStreetMapWay { let geometry: [CLLocationCoordinate2D] let speedLimitMetersPerSecond: CLLocationSpeed } private enum OpenStreetMapSpeedLimitService { static let endpoint = URL(string: "https://overpass-api.de/api/interpreter")! static let copyrightURL = URL(string: "https://www.openstreetmap.org/copyright")! static let boundingBoxPaddingDegrees = 0.0015 static let nearestWayThreshold: CLLocationDistance = 40 } private struct OverpassResponse: Decodable { let elements: [Element] struct Element: Decodable { let tags: [String: String]? let geometry: [Coordinate]? } struct Coordinate: Decodable { let lat: Double let lon: Double } } private extension MKPolyline { var coordinateArray: [CLLocationCoordinate2D] { var coordinates = [CLLocationCoordinate2D]( repeating: CLLocationCoordinate2D(latitude: 0, longitude: 0), count: pointCount ) getCoordinates(&coordinates, range: NSRange(location: 0, length: pointCount)) return coordinates } } private func interpolateCoordinate( from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D, fraction: Double ) -> CLLocationCoordinate2D { CLLocationCoordinate2D( latitude: start.latitude + ((end.latitude - start.latitude) * fraction), longitude: start.longitude + ((end.longitude - start.longitude) * fraction) ) } private func sampledRouteCoordinates( from coordinates: [CLLocationCoordinate2D], targetDistance: CLLocationDistance ) -> [CLLocationCoordinate2D] { guard coordinates.count > 1 else { return coordinates } var sampled = [coordinates[0]] for (start, end) in zip(coordinates, coordinates.dropFirst()) { let distance = CLLocation(latitude: start.latitude, longitude: start.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) let segmentCount = max(1, Int(ceil(distance / targetDistance))) for index in 1...segmentCount { let point = interpolateCoordinate( from: start, to: end, fraction: Double(index) / Double(segmentCount) ) if sampled.last.map(CoordinateSnapshot.init) != CoordinateSnapshot(point) { sampled.append(point) } } } return sampled } private func midpointCoordinate( from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D ) -> CLLocationCoordinate2D { interpolateCoordinate(from: start, to: end, fraction: 0.5) } private func distanceFromPoint( _ point: MKMapPoint, toSegmentFrom start: MKMapPoint, to end: MKMapPoint ) -> CLLocationDistance { let dx = end.x - start.x let dy = end.y - start.y guard dx != 0 || dy != 0 else { return point.distance(to: start) } let projection = max(0, min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / ((dx * dx) + (dy * dy)))) let projectedPoint = MKMapPoint( x: start.x + (dx * projection), y: start.y + (dy * projection) ) return point.distance(to: projectedPoint) } private func parseSpeedLimitMetersPerSecond(from rawValue: String) -> CLLocationSpeed? { let normalized = rawValue .lowercased() .split(separator: ";") .first? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !normalized.isEmpty else { return nil } guard normalized != "none", normalized != "signals", normalized != "implicit", normalized != "walk" else { return nil } let scanner = Scanner(string: normalized) guard let numericValue = scanner.scanDouble() else { return nil } if normalized.contains("mph") { return numericValue * 0.44704 } if normalized.contains("knot") { return numericValue * 0.514444 } return numericValue / 3.6 } private func speedLimitMetersPerSecond(from tags: [String: String]) -> CLLocationSpeed? { if let maxspeed = tags["maxspeed"], let parsed = parseSpeedLimitMetersPerSecond(from: maxspeed) { return parsed } let directionalValues = [ tags["maxspeed:forward"], tags["maxspeed:backward"] ] .compactMap { $0 } .compactMap(parseSpeedLimitMetersPerSecond(from:)) guard !directionalValues.isEmpty else { return nil } return directionalValues.min() } private func overpassQuery(for coordinates: [CLLocationCoordinate2D]) -> String? { guard let first = coordinates.first else { return nil } var minLatitude = first.latitude var maxLatitude = first.latitude var minLongitude = first.longitude var maxLongitude = first.longitude for coordinate in coordinates.dropFirst() { minLatitude = min(minLatitude, coordinate.latitude) maxLatitude = max(maxLatitude, coordinate.latitude) minLongitude = min(minLongitude, coordinate.longitude) maxLongitude = max(maxLongitude, coordinate.longitude) } let padding = OpenStreetMapSpeedLimitService.boundingBoxPaddingDegrees let south = minLatitude - padding let west = minLongitude - padding let north = maxLatitude + padding let east = maxLongitude + padding let bbox = String(format: "%.6f,%.6f,%.6f,%.6f", south, west, north, east) return """ [out:json][timeout:20]; ( way(\(bbox))[highway][maxspeed]; way(\(bbox))[highway]["maxspeed:forward"]; way(\(bbox))[highway]["maxspeed:backward"]; ); out tags geom; """ } private func fetchOpenStreetMapWays(for coordinates: [CLLocationCoordinate2D]) async throws -> [OpenStreetMapWay] { guard let query = overpassQuery(for: coordinates) else { return [] } var components = URLComponents(url: OpenStreetMapSpeedLimitService.endpoint, resolvingAgainstBaseURL: false) components?.queryItems = [URLQueryItem(name: "data", value: query)] guard let url = components?.url else { return [] } let (data, response) = try await URLSession.shared.data(from: url) if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { throw NSError( domain: "OpenStreetMapSpeedLimits", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Overpass returned HTTP \(httpResponse.statusCode)."] ) } let decoded = try JSONDecoder().decode(OverpassResponse.self, from: data) return decoded.elements.compactMap { element in guard let tags = element.tags, let speedLimit = speedLimitMetersPerSecond(from: tags), let geometry = element.geometry?.map({ CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }), geometry.count > 1 else { return nil } return OpenStreetMapWay( geometry: geometry, speedLimitMetersPerSecond: speedLimit ) } } private func nearestSpeedLimit( forSegmentFrom start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D, using ways: [OpenStreetMapWay] ) -> CLLocationSpeed? { let midpoint = MKMapPoint(midpointCoordinate(from: start, to: end)) var bestMatch: (speed: CLLocationSpeed, distance: CLLocationDistance)? for way in ways { for (wayStart, wayEnd) in zip(way.geometry, way.geometry.dropFirst()) { let candidateDistance = distanceFromPoint( midpoint, toSegmentFrom: MKMapPoint(wayStart), to: MKMapPoint(wayEnd) ) if bestMatch == nil || candidateDistance < bestMatch!.distance { bestMatch = (way.speedLimitMetersPerSecond, candidateDistance) } } } guard let bestMatch, bestMatch.distance <= OpenStreetMapSpeedLimitService.nearestWayThreshold else { return nil } return bestMatch.speed } private func buildPlaybackSamples( from displayCoordinates: [CLLocationCoordinate2D], speedWays: [OpenStreetMapWay], fallbackSpeedMetersPerSecond: CLLocationSpeed ) -> [RoutePlaybackSample] { guard let firstCoordinate = displayCoordinates.first else { return [] } var samples = [RoutePlaybackSample(coordinate: firstCoordinate, delayFromPrevious: 0)] for (start, end) in zip(displayCoordinates, displayCoordinates.dropFirst()) { let segmentDistance = CLLocation(latitude: start.latitude, longitude: start.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) guard segmentDistance > 0 else { continue } let speedLimit = nearestSpeedLimit(forSegmentFrom: start, to: end, using: speedWays) ?? fallbackSpeedMetersPerSecond let clampedSpeed = max(speedLimit, RouteSimulationDefaults.minimumSpeedMetersPerSecond) let segmentTravelTime = segmentDistance / clampedSpeed let segmentStepCount = max(1, Int(ceil(segmentTravelTime / RouteSimulationDefaults.playbackTickInterval))) let stepDelay = segmentTravelTime / Double(segmentStepCount) for index in 1...segmentStepCount { let coordinate = interpolateCoordinate( from: start, to: end, fraction: Double(index) / Double(segmentStepCount) ) if samples.last.map({ CoordinateSnapshot($0.coordinate) }) != CoordinateSnapshot(coordinate) { samples.append(RoutePlaybackSample(coordinate: coordinate, delayFromPrevious: stepDelay)) } } } return samples } private func prefetchRoutePlaybackSamples( displayCoordinates: [CLLocationCoordinate2D], fallbackSpeedMetersPerSecond: CLLocationSpeed ) async -> [RoutePlaybackSample] { let speedWays = (try? await fetchOpenStreetMapWays(for: displayCoordinates)) ?? [] return buildPlaybackSamples( from: displayCoordinates, speedWays: speedWays, fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond ) } // MARK: - Bookmark Model struct LocationBookmark: Identifiable, Codable { var id: UUID = UUID() var name: String var latitude: Double var longitude: Double var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } } // MARK: - Search Completer @MainActor final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCompleterDelegate { @Published var results: [MKLocalSearchCompletion] = [] private let completer = MKLocalSearchCompleter() override init() { super.init() completer.delegate = self completer.resultTypes = [.address, .pointOfInterest] } func update(query: String) { guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { results = [] completer.queryFragment = "" return } completer.queryFragment = query } nonisolated func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { let results = completer.results Task { @MainActor in self.results = results } } nonisolated func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { Task { @MainActor in self.results = [] } } } struct LocationSimulationView: View { // Serial queue: the location simulation helpers share process-wide state, so // serialising all calls avoids handle lifetime races. private static let locationQueue = DispatchQueue(label: "com.stik.location-sim", qos: .userInitiated) @State private var coordinate: CLLocationCoordinate2D? @State private var position: MapCameraPosition = .userLocation(fallback: .automatic) @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid @State private var resendTimer: Timer? @State private var routeLoadTask: Task? @State private var routeSpeedPrefetchTask: Task? @State private var routePlaybackTask: Task? @State private var isBusy = false @State private var isLoadingRoute = false @State private var isPrefetchingRouteSpeeds = false @State private var showAlert = false @State private var alertTitle = "" @State private var alertMessage = "" @State private var searchText = "" @StateObject private var searchCompleter = LocationSearchCompleter() @State private var showRouteSearch = false @State private var routeStartSelection: RouteSearchSelection? @State private var routeEndSelection: RouteSearchSelection? @State private var routePlan: RouteSimulationPlan? @State private var routePlaybackSamples: [RoutePlaybackSample] = [] @State private var routePlaybackCoordinate: CLLocationCoordinate2D? @State private var simulatedCoordinate: CLLocationCoordinate2D? @State private var routeRequestID = UUID() // Bookmarks @State private var bookmarks: [LocationBookmark] = [] @State private var showBookmarks = false @State private var showSaveBookmark = false @State private var newBookmarkName = "" private var pairingFilePath: String { PairingFileStore.prepareURL().path() } private var pairingExists: Bool { FileManager.default.fileExists(atPath: pairingFilePath) } private var deviceIP: String { let stored = UserDefaults.standard.string(forKey: "customTargetIP") ?? "" return stored.isEmpty ? "10.7.0.1" : stored } private var routePolyline: MKPolyline? { guard let routePlan, routePlan.displayCoordinates.count > 1 else { return nil } return routePlan.displayCoordinates.withUnsafeBufferPointer { buffer in guard let baseAddress = buffer.baseAddress else { return nil } return MKPolyline(coordinates: baseAddress, count: buffer.count) } } private var routeStartCoordinate: CLLocationCoordinate2D? { routeStartSelection?.coordinate } private var routeEndCoordinate: CLLocationCoordinate2D? { routeEndSelection?.coordinate } private var hasActiveSimulation: Bool { simulatedCoordinate != nil || routePlaybackTask != nil } private var isRouteRunning: Bool { routePlaybackTask != nil } private var hasRouteContext: Bool { routeStartSelection != nil || routeEndSelection != nil || routePlan != nil || isLoadingRoute || isPrefetchingRouteSpeeds || routePlaybackCoordinate != nil } private var routeSummaryText: String? { guard let routePlan else { return nil } let distanceText = Measurement( value: routePlan.distance / 1000, unit: UnitLength.kilometers ).formatted(.measurement(width: .abbreviated, usage: .road)) let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .dropAll let durationText = formatter.string(from: routePlan.expectedTravelTime) if let durationText, !durationText.isEmpty { return "\(distanceText) • ETA \(durationText)" } return distanceText } private var routeStatusText: String { if isLoadingRoute { return "Calculating route…" } if isPrefetchingRouteSpeeds { return "Prefetching road speeds…" } if routePlan != nil { return "Route ready." } if routeStartSelection != nil || routeEndSelection != nil { return "Pick both route endpoints to build the drive." } return "Plan a route from the toolbar." } private var routeAttributionLink: some View { Link( "Speed limit data © OpenStreetMap contributors (ODbL)", destination: OpenStreetMapSpeedLimitService.copyrightURL ) .font(.caption2) .foregroundStyle(.secondary) } private var searchResultsListBase: some View { List(searchCompleter.results.prefix(5), id: \.self) { result in Button { selectSearchResult(result) } label: { VStack(alignment: .leading, spacing: 2) { Text(result.title) .font(.subheadline) if !result.subtitle.isEmpty { Text(result.subtitle) .font(.caption) .foregroundStyle(.secondary) } } } } .listStyle(.plain) .frame(maxHeight: 350) .scrollDisabled(true) .padding(.horizontal, 16) .padding(.top, 8) } @ViewBuilder private var searchResultsList: some View { if #available(iOS 26, *) { searchResultsListBase .glassEffect(in: .rect(cornerRadius: 12)) } else { searchResultsListBase } } var body: some View { ZStack(alignment: .bottom) { MapReader { proxy in Map(position: $position) { if hasRouteContext { if let routePolyline { MapPolyline(routePolyline) .stroke(.blue.opacity(0.8), lineWidth: 5) } if let routeStartCoordinate { Marker("Start", coordinate: routeStartCoordinate) .tint(.green) } if let routeEndCoordinate { Marker("End", coordinate: routeEndCoordinate) .tint(.red) } if let routePlaybackCoordinate { Marker("Current", coordinate: routePlaybackCoordinate) .tint(.blue) } } else if let coordinate { Marker("Pin", coordinate: coordinate) .tint(.red) } } .mapStyle(.standard(elevation: .realistic)) .onTapGesture { point in if let loc = proxy.convert(point, from: .local) { applySelection(loc) } } .mapControls { MapCompass() } } .ignoresSafeArea() .onChange(of: coordinate.map(CoordinateSnapshot.init)) { _, new in if let new { position = .region( MKCoordinateRegion( center: new.coordinate, latitudinalMeters: 1000, longitudinalMeters: 1000 ) ) } } VStack(spacing: 0) { if !searchCompleter.results.isEmpty { searchResultsList } Spacer() VStack(spacing: 12) { if hasRouteContext { routeControls } else { pinControls } } .padding(.bottom, 24) .padding(.horizontal, 16) .padding(.horizontal, 16) } } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button { showBookmarks = true } label: { Image(systemName: "bookmark.fill") } Button { showRouteSearch = true } label: { Image(systemName: "point.topleft.down.curvedto.point.bottomright.up") } .disabled(isBusy || isRouteRunning) } ToolbarItem(placement: .topBarTrailing) { TextField("Search location...", text: $searchText) .padding(.leading, 6) .autocorrectionDisabled() .onChange(of: searchText) { _, newValue in searchCompleter.update(query: newValue) } } } .alert(alertTitle, isPresented: $showAlert) { Button("OK", role: .cancel) { } } message: { Text(alertMessage) } .alert("Save Bookmark", isPresented: $showSaveBookmark) { TextField("Name", text: $newBookmarkName) Button("Save") { addBookmark() } Button("Cancel", role: .cancel) { newBookmarkName = "" } } message: { Text("Enter a name for this location.") } .sheet(isPresented: $showBookmarks) { BookmarksView(bookmarks: $bookmarks) { bookmark in applySelection(bookmark.coordinate) showBookmarks = false } onDelete: { offsets in bookmarks.remove(atOffsets: offsets) saveBookmarks() } } .sheet(isPresented: $showRouteSearch) { RouteSearchSheet( initialStart: routeStartSelection, initialEnd: routeEndSelection ) { startSelection, endSelection in routeStartSelection = startSelection routeEndSelection = endSelection refreshRoute() } } .onAppear { loadBookmarks() } .onDisappear { routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil cancelRoutePlayback(resetMarker: true) stopResendLoop() if backgroundTaskID != .invalid { BackgroundLocationManager.shared.requestStop() } endBackgroundTask() } } // MARK: - Bookmarks private func loadBookmarks() { guard let data = UserDefaults.standard.data(forKey: "locationBookmarks"), let decoded = try? JSONDecoder().decode([LocationBookmark].self, from: data) else { return } bookmarks = decoded } private func saveBookmarks() { if let data = try? JSONEncoder().encode(bookmarks) { UserDefaults.standard.set(data, forKey: "locationBookmarks") } } private func addBookmark() { guard let coord = coordinate else { return } let name = newBookmarkName.trimmingCharacters(in: .whitespacesAndNewlines) let bookmark = LocationBookmark( name: name.isEmpty ? String(format: "%.4f, %.4f", coord.latitude, coord.longitude) : name, latitude: coord.latitude, longitude: coord.longitude ) bookmarks.append(bookmark) saveBookmarks() newBookmarkName = "" } // MARK: - Location private func selectSearchResult(_ result: MKLocalSearchCompletion) { searchText = "" searchCompleter.results = [] let request = MKLocalSearch.Request(completion: result) MKLocalSearch(request: request).start { response, _ in if let item = response?.mapItems.first { applySelection(item.placemark.coordinate) } } } @ViewBuilder private var pinControls: some View { if let coord = coordinate { Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude)) .font(.footnote.monospaced()) .foregroundStyle(.secondary) HStack(spacing: 12) { Button("Stop", action: clear) .buttonStyle(.bordered) .tint(.red) .disabled(!pairingExists || isBusy || !hasActiveSimulation) Button("Simulate Location", action: simulate) .buttonStyle(.borderedProminent) .disabled(!pairingExists || isBusy || isLoadingRoute) Button { showSaveBookmark = true } label: { Image(systemName: "bookmark") } .buttonStyle(.bordered) .tint(.blue) .disabled(isRouteRunning) } } else { Text("Tap map to drop pin") .font(.subheadline) .foregroundStyle(.secondary) } } private var routeControls: some View { VStack(spacing: 10) { Text(routeStatusText) .font(.footnote) .foregroundStyle(.secondary) if isLoadingRoute || isPrefetchingRouteSpeeds { ProgressView() .controlSize(.small) } else if let routeSummaryText { Text(routeSummaryText) .font(.footnote.monospaced()) .foregroundStyle(.secondary) } routeAttributionLink HStack(spacing: 12) { Button("Stop", action: clear) .buttonStyle(.bordered) .tint(.red) .disabled(!pairingExists || isBusy || !hasActiveSimulation) Button("Play Route", action: simulateRoute) .buttonStyle(.borderedProminent) .disabled( !pairingExists || isBusy || isLoadingRoute || isPrefetchingRouteSpeeds || routePlan == nil || routePlaybackSamples.isEmpty ) Button("Reset", action: resetRouteSelection) .buttonStyle(.bordered) .disabled(isBusy || isRouteRunning) } } } private func simulate() { guard pairingExists, let coord = coordinate, !isBusy else { return } runLocationCommand( errorTitle: "Simulation Failed", errorMessage: { code in "Could not simulate location (error \(code)). Make sure the device is connected and the DDI is mounted." }, operation: { locationUpdateCode(for: coord) } ) { routePlaybackCoordinate = nil beginBackgroundTask() startResendLoop(with: coord) BackgroundLocationManager.shared.requestStart() } } private func simulateRoute() { guard pairingExists, routePlan != nil, let firstCoordinate = routePlaybackSamples.first?.coordinate, !isBusy else { return } stopResendLoop() cancelRoutePlayback(resetMarker: false) runLocationCommand( errorTitle: "Route Simulation Failed", errorMessage: { code in "Could not start route simulation (error \(code)). Make sure the device is connected and the DDI is mounted." }, operation: { locationUpdateCode(for: firstCoordinate) } ) { beginBackgroundTask() BackgroundLocationManager.shared.requestStart() simulatedCoordinate = nil routePlaybackCoordinate = firstCoordinate startRoutePlayback() } } private func runLocationCommand( errorTitle: String, errorMessage: @escaping (Int32) -> String, operation: @escaping () -> Int32, onSuccess: @escaping () -> Void ) { isBusy = true Self.locationQueue.async { let code = operation() DispatchQueue.main.async { isBusy = false if code == 0 { onSuccess() } else { alertTitle = errorTitle alertMessage = errorMessage(code) showAlert = true } } } } private func clear() { guard pairingExists, !isBusy else { return } routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil cancelRoutePlayback(resetMarker: true) stopResendLoop() runLocationCommand( errorTitle: "Clear Failed", errorMessage: { code in "Could not clear simulated location (error \(code))." }, operation: clear_simulated_location ) { endBackgroundTask() BackgroundLocationManager.shared.requestStop() } } private func beginBackgroundTask() { guard backgroundTaskID == .invalid else { return } backgroundTaskID = UIApplication.shared.beginBackgroundTask { endBackgroundTask() } } private func endBackgroundTask() { guard backgroundTaskID != .invalid else { return } UIApplication.shared.endBackgroundTask(backgroundTaskID) backgroundTaskID = .invalid } private func startResendLoop(with coordinate: CLLocationCoordinate2D) { simulatedCoordinate = coordinate resendTimer?.invalidate() resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in guard let simulatedCoordinate else { return } Self.locationQueue.async { _ = locationUpdateCode(for: simulatedCoordinate) } } } private func stopResendLoop() { resendTimer?.invalidate() resendTimer = nil simulatedCoordinate = nil } private func cancelRoutePlayback(resetMarker: Bool) { routePlaybackTask?.cancel() routePlaybackTask = nil if resetMarker { routePlaybackCoordinate = nil } } private func applySelection(_ coordinate: CLLocationCoordinate2D) { guard !isRouteRunning else { return } if hasRouteContext { resetRouteSelection() } self.coordinate = coordinate } private func resetRouteSelection() { routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil routeRequestID = UUID() routePlan = nil routeStartSelection = nil routeEndSelection = nil routePlaybackSamples = [] routePlaybackCoordinate = nil isLoadingRoute = false isPrefetchingRouteSpeeds = false } private func refreshRoute() { routeLoadTask?.cancel() routeSpeedPrefetchTask?.cancel() routePlan = nil routePlaybackSamples = [] guard let routeStart = routeStartSelection?.coordinate, let routeEnd = routeEndSelection?.coordinate else { isLoadingRoute = false isPrefetchingRouteSpeeds = false return } let requestID = UUID() routeRequestID = requestID isLoadingRoute = true isPrefetchingRouteSpeeds = false let request = MKDirections.Request() request.source = MKMapItem(placemark: MKPlacemark(coordinate: routeStart)) request.destination = MKMapItem(placemark: MKPlacemark(coordinate: routeEnd)) request.requestsAlternateRoutes = false request.transportType = .automobile routeLoadTask = Task { do { let response = try await MKDirections(request: request).calculate() guard !Task.isCancelled else { return } guard let route = response.routes.first else { throw NSError( domain: "RouteSimulation", code: -1, userInfo: [NSLocalizedDescriptionKey: "No drivable route was returned."] ) } let displayCoordinates = sampledRouteCoordinates( from: route.polyline.coordinateArray, targetDistance: RouteSimulationDefaults.pathSamplingDistance ) let routePlan = RouteSimulationPlan( displayCoordinates: displayCoordinates, distance: route.distance, expectedTravelTime: route.expectedTravelTime ) await MainActor.run { guard routeRequestID == requestID else { return } self.routePlan = routePlan isLoadingRoute = false isPrefetchingRouteSpeeds = true if let routePolyline { position = .rect(routePolyline.boundingMapRect) } } let fallbackSpeed = route.expectedTravelTime > 0 ? route.distance / route.expectedTravelTime : 13.4 await MainActor.run { guard routeRequestID == requestID else { return } routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = Task.detached(priority: .utility) { let playbackSamples = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed ) guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } routePlaybackSamples = playbackSamples isPrefetchingRouteSpeeds = false } } } } catch is CancellationError { await MainActor.run { guard routeRequestID == requestID else { return } isLoadingRoute = false isPrefetchingRouteSpeeds = false } } catch { await MainActor.run { guard routeRequestID == requestID else { return } isLoadingRoute = false isPrefetchingRouteSpeeds = false alertTitle = "Route Failed" alertMessage = error.localizedDescription showAlert = true } } } } private func startRoutePlayback() { routePlaybackTask = Task { var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate for sample in routePlaybackSamples.dropFirst() { try? await Task.sleep(for: .seconds(sample.delayFromPrevious)) guard !Task.isCancelled else { return } let code = await sendLocationUpdate(for: sample.coordinate) guard code == 0 else { await MainActor.run { routePlaybackTask = nil routePlaybackCoordinate = lastSuccessfulCoordinate if let lastSuccessfulCoordinate { startResendLoop(with: lastSuccessfulCoordinate) } alertTitle = "Route Simulation Failed" alertMessage = "Could not continue route simulation (error \(code))." showAlert = true } return } lastSuccessfulCoordinate = sample.coordinate await MainActor.run { routePlaybackCoordinate = sample.coordinate } } await MainActor.run { routePlaybackTask = nil if let lastSuccessfulCoordinate { routePlaybackCoordinate = lastSuccessfulCoordinate startResendLoop(with: lastSuccessfulCoordinate) } } } } private func sendLocationUpdate(for coordinate: CLLocationCoordinate2D) async -> Int32 { await withCheckedContinuation { continuation in Self.locationQueue.async { continuation.resume(returning: locationUpdateCode(for: coordinate)) } } } private func locationUpdateCode(for coordinate: CLLocationCoordinate2D) -> Int32 { simulate_location(deviceIP, coordinate.latitude, coordinate.longitude, pairingFilePath) } } private struct RouteSearchSheet: View { @Environment(\.dismiss) private var dismiss let initialStart: RouteSearchSelection? let initialEnd: RouteSearchSelection? let onApply: (RouteSearchSelection, RouteSearchSelection) -> Void @StateObject private var startCompleter = LocationSearchCompleter() @StateObject private var endCompleter = LocationSearchCompleter() @State private var startQuery: String @State private var endQuery: String @State private var startSelection: RouteSearchSelection? @State private var endSelection: RouteSearchSelection? @State private var isResolvingSelection = false @State private var errorMessage: String? @FocusState private var focusedField: RouteSearchField? init( initialStart: RouteSearchSelection?, initialEnd: RouteSearchSelection?, onApply: @escaping (RouteSearchSelection, RouteSearchSelection) -> Void ) { self.initialStart = initialStart self.initialEnd = initialEnd self.onApply = onApply _startQuery = State(initialValue: initialStart?.title ?? "") _endQuery = State(initialValue: initialEnd?.title ?? "") _startSelection = State(initialValue: initialStart) _endSelection = State(initialValue: initialEnd) } private var activeResults: [MKLocalSearchCompletion] { switch focusedField { case .start: return startCompleter.results case .end: return endCompleter.results case .none: return [] } } private var canApply: Bool { startSelection != nil && endSelection != nil && !isResolvingSelection } var body: some View { NavigationStack { VStack(alignment: .leading, spacing: 16) { routeField( title: "Start", icon: "circle.fill", tint: .green, text: $startQuery, selection: startSelection, field: .start ) routeField( title: "End", icon: "flag.checkered.circle.fill", tint: .red, text: $endQuery, selection: endSelection, field: .end ) if let errorMessage { Text(errorMessage) .font(.footnote) .foregroundStyle(.red) } if isResolvingSelection { ProgressView("Resolving location…") .font(.footnote) } else if !activeResults.isEmpty { ScrollView { LazyVStack(spacing: 0) { ForEach(Array(activeResults.enumerated()), id: \.offset) { index, result in Button { resolve(result) } label: { VStack(alignment: .leading, spacing: 2) { Text(result.title) .font(.subheadline) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) if !result.subtitle.isEmpty { Text(result.subtitle) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.vertical, 10) .padding(.horizontal, 12) } .buttonStyle(.plain) if index < activeResults.count - 1 { Divider() } } } } .frame(maxHeight: 260) } else { Text("Search for a start and destination to build the route.") .font(.footnote) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(16) .navigationTitle("Simulate Route") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Use Route") { guard let startSelection, let endSelection else { return } onApply(startSelection, endSelection) dismiss() } .disabled(!canApply) } } } .presentationDetents([.medium, .large]) .onAppear { if startSelection == nil { focusedField = .start } else if endSelection == nil { focusedField = .end } } } private func routeField( title: String, icon: String, tint: Color, text: Binding, selection: RouteSearchSelection?, field: RouteSearchField ) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) HStack(spacing: 10) { Image(systemName: icon) .foregroundStyle(tint) TextField(title, text: text) .textInputAutocapitalization(.words) .autocorrectionDisabled() .focused($focusedField, equals: field) .submitLabel(field == .start ? .next : .done) .onChange(of: text.wrappedValue) { _, newValue in errorMessage = nil update(query: newValue, for: field) } .onSubmit { if field == .start { focusedField = .end } else { focusedField = nil } } } .padding(.horizontal, 2) .padding(.vertical, 4) if let selection { Text(String(format: "%.5f, %.5f", selection.coordinate.latitude, selection.coordinate.longitude)) .font(.caption.monospaced()) .foregroundStyle(.secondary) } } } private func update(query: String, for field: RouteSearchField) { switch field { case .start: if query != startSelection?.title { startSelection = nil } startCompleter.update(query: query) case .end: if query != endSelection?.title { endSelection = nil } endCompleter.update(query: query) } } private func resolve(_ completion: MKLocalSearchCompletion) { let field = focusedField ?? .start let request = MKLocalSearch.Request(completion: completion) isResolvingSelection = true errorMessage = nil MKLocalSearch(request: request).start { response, error in DispatchQueue.main.async { isResolvingSelection = false guard let item = response?.mapItems.first else { errorMessage = error?.localizedDescription ?? "Could not resolve that location." return } let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let title = name.isEmpty ? completion.title : name let selection = RouteSearchSelection(title: title, coordinate: item.placemark.coordinate) switch field { case .start: startSelection = selection startQuery = title startCompleter.results = [] focusedField = .end case .end: endSelection = selection endQuery = title endCompleter.results = [] focusedField = nil } } } } } // MARK: - Bookmarks Sheet struct BookmarksView: View { @Binding var bookmarks: [LocationBookmark] let onSelect: (LocationBookmark) -> Void let onDelete: (IndexSet) -> Void var body: some View { NavigationStack { Group { if bookmarks.isEmpty { ContentUnavailableView( "No Bookmarks", systemImage: "bookmark.slash", description: Text("Drop a pin on the map and tap the bookmark icon to save a location.") ) } else { List { ForEach(bookmarks) { bookmark in Button { onSelect(bookmark) } label: { VStack(alignment: .leading, spacing: 2) { Text(bookmark.name) .foregroundStyle(.primary) Text(String(format: "%.6f, %.6f", bookmark.latitude, bookmark.longitude)) .font(.caption.monospaced()) .foregroundStyle(.secondary) } } } .onDelete(perform: onDelete) } } } .navigationTitle("Bookmarks") .navigationBarTitleDisplayMode(.inline) .toolbar { if !bookmarks.isEmpty { EditButton() } } } } }