StikDebug/StikJIT/Views/MapSelectionView.swift
Stephen 5db5f49117
(WIP) Rewrite parts of the app in Swift and fix iOS 26.4 issues (#358)
* Rewrite parts of the app in Swift and fix iOS 26.4 issues (WIP)

* Add more PID stuff

* Improve location simulation

* whoops
2026-03-30 12:34:13 -04:00

1375 lines
48 KiB
Swift

//
// 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<Void, Never>?
@State private var routeSpeedPrefetchTask: Task<Void, Never>?
@State private var routePlaybackTask: Task<Void, Never>?
@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<String>,
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()
}
}
}
}
}