StikDebug/StikJIT/Views/MapSelectionView.swift
2026-05-30 09:17:51 +08:00

1864 lines
66 KiB
Swift

//
// MapSelectionView.swift
// StikJIT
//
// Created by Stephen on 11/3/25.
//
import SwiftUI
import MapKit
import UIKit
import UniformTypeIdentifiers
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
static let importedRouteFallbackSpeedMetersPerSecond: CLLocationSpeed = 13.4
}
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 distanceAlong(_ coordinates: [CLLocationCoordinate2D]) -> CLLocationDistance {
zip(coordinates, coordinates.dropFirst()).reduce(0) { total, pair in
total + CLLocation(latitude: pair.0.latitude, longitude: pair.0.longitude)
.distance(from: CLLocation(latitude: pair.1.latitude, longitude: pair.1.longitude))
}
}
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
)
}
private enum CoordinateImportError: LocalizedError {
case emptyFile
case noCoordinates
var errorDescription: String? {
switch self {
case .emptyFile:
return "The selected file is empty."
case .noCoordinates:
return "No valid coordinates were found. Use GPX, GeoJSON, JSON, CSV, or plain text with latitude and longitude values."
}
}
}
private enum CoordinateImportParser {
static let supportedContentTypes: [UTType] = [
.plainText,
.commaSeparatedText,
.json,
.xml,
UTType(filenameExtension: "gpx", conformingTo: .xml) ?? .xml,
UTType(filenameExtension: "kml", conformingTo: .xml) ?? .xml,
UTType(filenameExtension: "geojson", conformingTo: .json) ?? .json
]
private enum CoordinateOrder {
case latitudeLongitude
case longitudeLatitude
}
static func parse(url: URL) throws -> [CLLocationCoordinate2D] {
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
let data = try Data(contentsOf: url)
guard !data.isEmpty else { throw CoordinateImportError.emptyFile }
let fileExtension = url.pathExtension.lowercased()
if fileExtension == "json" || fileExtension == "geojson" {
if let coordinates = try? parseJSONCoordinates(from: data),
!coordinates.isEmpty {
return coordinates
}
}
if fileExtension == "gpx" || fileExtension == "kml" || fileExtension == "xml" {
let coordinates = parseXMLCoordinates(from: data)
if !coordinates.isEmpty {
return coordinates
}
}
if let text = decodedText(from: data) {
let coordinates = parseInline(text)
if !coordinates.isEmpty {
return coordinates
}
}
if let coordinates = try? parseJSONCoordinates(from: data),
!coordinates.isEmpty {
return coordinates
}
let coordinates = parseXMLCoordinates(from: data)
if !coordinates.isEmpty {
return coordinates
}
throw CoordinateImportError.noCoordinates
}
static func parseInline(_ text: String) -> [CLLocationCoordinate2D] {
sanitized(parseTextCoordinates(from: text))
}
private static func decodedText(from data: Data) -> String? {
String(data: data, encoding: .utf8)
?? String(data: data, encoding: .utf16)
?? String(data: data, encoding: .ascii)
}
private static func sanitized(_ coordinates: [CLLocationCoordinate2D]) -> [CLLocationCoordinate2D] {
var result: [CLLocationCoordinate2D] = []
for coordinate in coordinates where CLLocationCoordinate2DIsValid(coordinate) {
if result.last.map(CoordinateSnapshot.init) == CoordinateSnapshot(coordinate) {
continue
}
result.append(coordinate)
}
return result
}
private static func coordinate(
first: Double,
second: Double,
order: CoordinateOrder
) -> CLLocationCoordinate2D? {
let preferred: CLLocationCoordinate2D
let fallback: CLLocationCoordinate2D
switch order {
case .latitudeLongitude:
preferred = CLLocationCoordinate2D(latitude: first, longitude: second)
fallback = CLLocationCoordinate2D(latitude: second, longitude: first)
case .longitudeLatitude:
preferred = CLLocationCoordinate2D(latitude: second, longitude: first)
fallback = CLLocationCoordinate2D(latitude: first, longitude: second)
}
if CLLocationCoordinate2DIsValid(preferred) {
return preferred
}
if CLLocationCoordinate2DIsValid(fallback) {
return fallback
}
return nil
}
private static func parseJSONCoordinates(from data: Data) throws -> [CLLocationCoordinate2D] {
let object = try JSONSerialization.jsonObject(with: data)
return sanitized(coordinates(fromJSONObject: object, order: .latitudeLongitude))
}
private static func coordinates(
fromJSONObject object: Any,
order: CoordinateOrder
) -> [CLLocationCoordinate2D] {
if let dictionary = object as? [String: Any] {
if let latitude = numberValue(forAnyKey: ["latitude", "lat"], in: dictionary),
let longitude = numberValue(forAnyKey: ["longitude", "lon", "lng"], in: dictionary),
let coordinate = coordinate(first: latitude, second: longitude, order: .latitudeLongitude) {
return [coordinate]
}
if let geometry = dictionary["geometry"] {
return coordinates(fromJSONObject: geometry, order: order)
}
if let type = dictionary["type"] as? String {
let loweredType = type.lowercased()
if loweredType == "featurecollection",
let features = dictionary["features"] as? [Any] {
return features.flatMap { coordinates(fromJSONObject: $0, order: .longitudeLatitude) }
}
if loweredType == "geometrycollection",
let geometries = dictionary["geometries"] as? [Any] {
return geometries.flatMap { coordinates(fromJSONObject: $0, order: .longitudeLatitude) }
}
if let coordinateObject = dictionary["coordinates"] {
return coordinates(fromJSONObject: coordinateObject, order: .longitudeLatitude)
}
}
return dictionary.values.flatMap { coordinates(fromJSONObject: $0, order: order) }
}
if let array = object as? [Any] {
if array.count >= 2,
let first = numericValue(array[0]),
let second = numericValue(array[1]),
let coordinate = coordinate(first: first, second: second, order: order) {
return [coordinate]
}
return array.flatMap { coordinates(fromJSONObject: $0, order: order) }
}
return []
}
private static func numericValue(_ value: Any) -> Double? {
if let number = value as? NSNumber {
return number.doubleValue
}
if let string = value as? String {
return Double(string.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
private static func numberValue(forAnyKey keys: [String], in dictionary: [String: Any]) -> Double? {
let keyedValues = Dictionary(uniqueKeysWithValues: dictionary.map { ($0.key.lowercased(), $0.value) })
for key in keys {
if let value = keyedValues[key],
let number = numericValue(value) {
return number
}
}
return nil
}
private static func parseXMLCoordinates(from data: Data) -> [CLLocationCoordinate2D] {
let collector = XMLCoordinateCollector()
let parser = XMLParser(data: data)
parser.delegate = collector
guard parser.parse() else { return [] }
return sanitized(collector.coordinates)
}
private final class XMLCoordinateCollector: NSObject, XMLParserDelegate {
var coordinates: [CLLocationCoordinate2D] = []
private var isCollectingKMLCoordinates = false
private var kmlCoordinateBuffer = ""
func parser(
_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String: String] = [:]
) {
let name = elementName.lowercased()
if ["wpt", "trkpt", "rtept"].contains(name),
let latitude = Double(attributeDict["lat"] ?? ""),
let longitude = Double(attributeDict["lon"] ?? ""),
let coordinate = CoordinateImportParser.coordinate(
first: latitude,
second: longitude,
order: .latitudeLongitude
) {
coordinates.append(coordinate)
} else if name == "coordinates" {
isCollectingKMLCoordinates = true
kmlCoordinateBuffer = ""
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
if isCollectingKMLCoordinates {
kmlCoordinateBuffer += string
}
}
func parser(
_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?
) {
guard elementName.lowercased() == "coordinates" else { return }
coordinates.append(contentsOf: CoordinateImportParser.parseKMLCoordinateText(kmlCoordinateBuffer))
isCollectingKMLCoordinates = false
kmlCoordinateBuffer = ""
}
}
private static func parseKMLCoordinateText(_ text: String) -> [CLLocationCoordinate2D] {
text
.split(whereSeparator: { $0.isWhitespace })
.compactMap { token -> CLLocationCoordinate2D? in
let values = token
.split(separator: ",")
.compactMap { Double($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
guard values.count >= 2 else { return nil }
return coordinate(first: values[0], second: values[1], order: .longitudeLatitude)
}
}
private static func parseTextCoordinates(from text: String) -> [CLLocationCoordinate2D] {
var coordinates: [CLLocationCoordinate2D] = []
var headerIndices: (latitude: Int, longitude: Int)?
for line in text.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let fields = splitFields(trimmed)
if headerIndices == nil,
let detectedHeader = detectHeader(in: fields) {
headerIndices = detectedHeader
continue
}
if let headerIndices,
fields.indices.contains(headerIndices.latitude),
fields.indices.contains(headerIndices.longitude),
let latitude = numbers(in: fields[headerIndices.latitude]).first,
let longitude = numbers(in: fields[headerIndices.longitude]).first,
let coordinate = coordinate(first: latitude, second: longitude, order: .latitudeLongitude) {
coordinates.append(coordinate)
continue
}
let values = numbers(in: trimmed)
if values.count >= 2,
let coordinate = coordinate(first: values[0], second: values[1], order: .latitudeLongitude) {
coordinates.append(coordinate)
}
}
return coordinates
}
private static func splitFields(_ line: String) -> [String] {
line
.split { character in
character == "," ||
character == ";" ||
character == "\t"
}
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
}
private static func detectHeader(in fields: [String]) -> (latitude: Int, longitude: Int)? {
let lowered = fields.map { $0.lowercased() }
guard let latitude = lowered.firstIndex(where: { $0 == "lat" || $0 == "latitude" }),
let longitude = lowered.firstIndex(where: { $0 == "lon" || $0 == "lng" || $0 == "long" || $0 == "longitude" }) else {
return nil
}
return (latitude, longitude)
}
private static func numbers(in text: String) -> [Double] {
let pattern = #"[-+]?(?:\d+(?:\.\d*)?|\.\d+)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return regex.matches(in: text, range: range).compactMap { match in
guard let matchRange = Range(match.range, in: text) else { return nil }
return Double(text[matchRange])
}
}
}
// 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 {
@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 isImportingCoordinates = 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 showCoordinateImporter = false
@State private var showRouteSearch = false
@State private var routeStartSelection: RouteSearchSelection?
@State private var routeEndSelection: RouteSearchSelection?
@State private var routePlan: RouteSimulationPlan?
@State private var routePolyline: MKPolyline?
@State private var routePlaybackSamples: [RoutePlaybackSample] = []
@State private var routePlaybackCoordinate: CLLocationCoordinate2D?
@State private var simulatedCoordinate: CLLocationCoordinate2D?
@State private var routeRequestID = UUID()
private static let routeDurationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute]
formatter.unitsStyle = .abbreviated
formatter.zeroFormattingBehavior = .dropAll
return formatter
}()
// 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 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 durationText = Self.routeDurationFormatter.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 isImportingCoordinates {
ProgressView("Importing coordinates…")
.font(.footnote)
}
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)
Button {
showCoordinateImporter = true
} label: {
Image(systemName: "square.and.arrow.down")
}
.disabled(isBusy || isRouteRunning || isImportingCoordinates)
.accessibilityLabel("Import Coordinates")
}
ToolbarItem(placement: .topBarTrailing) {
TextField("Search location...", text: $searchText)
.padding(.leading, 6)
.autocorrectionDisabled()
.submitLabel(.go)
.onChange(of: searchText) { _, newValue in
searchCompleter.update(query: newValue)
}
.onSubmit {
applyCoordinatesFromSearchText()
}
}
}
.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()
}
}
.fileImporter(
isPresented: $showCoordinateImporter,
allowedContentTypes: CoordinateImportParser.supportedContentTypes,
allowsMultipleSelection: false
) { result in
importCoordinates(result)
}
.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 = ""
}
private func setRoutePlan(_ plan: RouteSimulationPlan?) {
routePlan = plan
routePolyline = plan.flatMap { makeRoutePolyline(for: $0.displayCoordinates) }
}
private func makeRoutePolyline(for coordinates: [CLLocationCoordinate2D]) -> MKPolyline? {
guard coordinates.count > 1 else { return nil }
return coordinates.withUnsafeBufferPointer { buffer in
guard let baseAddress = buffer.baseAddress else { return nil }
return MKPolyline(coordinates: baseAddress, count: buffer.count)
}
}
// 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)
}
}
}
private func applyCoordinatesFromSearchText() {
let importedCoordinates = CoordinateImportParser.parseInline(searchText)
guard !importedCoordinates.isEmpty else { return }
searchText = ""
searchCompleter.results = []
applyImportedCoordinates(importedCoordinates, sourceName: "Imported")
}
private func importCoordinates(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
let sourceName = url.deletingPathExtension().lastPathComponent
isImportingCoordinates = true
Task {
do {
let coordinates = try await Task.detached(priority: .userInitiated) {
try CoordinateImportParser.parse(url: url)
}.value
await MainActor.run {
isImportingCoordinates = false
applyImportedCoordinates(
coordinates,
sourceName: sourceName.isEmpty ? "Imported" : sourceName
)
}
} catch {
await MainActor.run {
isImportingCoordinates = false
showImportError(error)
}
}
}
case .failure(let error):
showImportError(error)
}
}
private func applyImportedCoordinates(
_ importedCoordinates: [CLLocationCoordinate2D],
sourceName: String
) {
guard !isRouteRunning else { return }
let coordinates = CoordinateTransform.wgs84ToGCJ02(
importedCoordinates.filter(CLLocationCoordinate2DIsValid)
)
guard let firstCoordinate = coordinates.first else {
showImportError(CoordinateImportError.noCoordinates)
return
}
if coordinates.count == 1 {
applySelection(firstCoordinate)
return
}
routeLoadTask?.cancel()
routeLoadTask = nil
routeSpeedPrefetchTask?.cancel()
routeSpeedPrefetchTask = nil
routeRequestID = UUID()
setRoutePlan(nil)
routePlaybackSamples = []
routePlaybackCoordinate = nil
isLoadingRoute = false
isPrefetchingRouteSpeeds = false
coordinate = nil
let displayCoordinates = sampledRouteCoordinates(
from: coordinates,
targetDistance: RouteSimulationDefaults.pathSamplingDistance
)
guard displayCoordinates.count > 1,
let lastCoordinate = displayCoordinates.last else {
applySelection(firstCoordinate)
return
}
let distance = distanceAlong(displayCoordinates)
let fallbackSpeed = RouteSimulationDefaults.importedRouteFallbackSpeedMetersPerSecond
routeStartSelection = RouteSearchSelection(title: "\(sourceName) Start", coordinate: firstCoordinate)
routeEndSelection = RouteSearchSelection(title: "\(sourceName) End", coordinate: lastCoordinate)
setRoutePlan(RouteSimulationPlan(
displayCoordinates: displayCoordinates,
distance: distance,
expectedTravelTime: distance / fallbackSpeed
))
if let routePolyline {
position = .rect(routePolyline.boundingMapRect)
}
let requestID = UUID()
routeRequestID = requestID
isPrefetchingRouteSpeeds = true
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
}
}
}
private func showImportError(_ error: Error) {
alertTitle = "Import Failed"
alertMessage = error.localizedDescription
showAlert = true
}
@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
LocationSimulationCommandQueue.shared.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 }
LocationSimulationCommandQueue.shared.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()
setRoutePlan(nil)
routeStartSelection = nil
routeEndSelection = nil
routePlaybackSamples = []
routePlaybackCoordinate = nil
isLoadingRoute = false
isPrefetchingRouteSpeeds = false
}
private func refreshRoute() {
routeLoadTask?.cancel()
routeSpeedPrefetchTask?.cancel()
setRoutePlan(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.setRoutePlan(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
LocationSimulationCommandQueue.shared.async {
continuation.resume(returning: locationUpdateCode(for: coordinate))
}
}
}
private func locationUpdateCode(for coordinate: CLLocationCoordinate2D) -> Int32 {
let wgs84 = CoordinateTransform.gcj02ToWGS84(coordinate)
return simulate_location(deviceIP, wgs84.latitude, wgs84.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()
}
}
}
}
}