banyaro-ios/BanYaroGo/Support/GPXParser.swift
rene 7848817cbe GPX-Import via Teilen-Menü und 'Öffnen mit'
Andere Apps können jetzt GPX-Tracks zu Ban Yaro Go schicken (Komoot,
Outdooractive, GPSies, AllTrails, Files-App, Mail-Anhänge, AirDrop).

- Info.plist:
  - UTImportedTypeDeclarations: com.topografix.gpx (conforms to
    public.xml/data/content, ext gpx, MIME application/gpx+xml)
  - CFBundleDocumentTypes registriert die UTI als Viewer (LSHandlerRank
    Alternate, damit wir nicht die Default-App werden)
  - LSSupportsOpeningDocumentsInPlace=true
- Support/GPXParser.swift: schlanker XMLParser/SAX-Reader für
  <trkpt>/<wpt>/<rtept>, Track-Name aus <trk><name>, ele + ISO8601 time
- Views/GPXImportSheet.swift: Sheet mit Map(MapPolyline)+Start/Ziel-Pins,
  Distanz/Punkte/Dauer-Karte, zwei Aktionen:
    1. 'Als Tour übernehmen' — Name editierbar, Hunde-Picker (FlowDogs),
       öffentlich-Toggle → POST /api/routes
    2. 'Nur ansehen' — Startpunkt in Apple Maps
- BanYaroGoApp.swift: .onOpenURL prüft .gpx, security-scoped resource,
  parst und triggert das Sheet via TrackBox-Wrapper
2026-05-30 14:34:40 +02:00

138 lines
4.4 KiB
Swift

import Foundation
import CoreLocation
struct GPXTrack {
let name: String?
let points: [GPXPoint]
var coordinates: [CLLocationCoordinate2D] {
points.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }
}
/// Total length in meters along the track using great-circle distance.
var distanceMeters: Double {
guard points.count > 1 else { return 0 }
var sum: Double = 0
for i in 1..<points.count {
let a = CLLocation(latitude: points[i-1].lat, longitude: points[i-1].lon)
let b = CLLocation(latitude: points[i].lat, longitude: points[i].lon)
sum += b.distance(from: a)
}
return sum
}
/// Duration in seconds derived from first/last <time> if available.
var durationSeconds: TimeInterval? {
guard let first = points.first?.time, let last = points.last?.time else { return nil }
let delta = last.timeIntervalSince(first)
return delta > 0 ? delta : nil
}
}
struct GPXPoint {
let lat: Double
let lon: Double
let ele: Double?
let time: Date?
}
enum GPXParseError: Error, LocalizedError {
case noTrack
case invalid(String)
var errorDescription: String? {
switch self {
case .noTrack: return "Keine Trackpunkte gefunden."
case .invalid(let why): return "GPX ungültig: \(why)"
}
}
}
/// Minimaler SAX-Parser für GPX. Liest <trkpt>, <wpt> und <rtept> als Punkte;
/// Track-Name aus <name> innerhalb des ersten <trk>.
final class GPXParser: NSObject, XMLParserDelegate {
private var points: [GPXPoint] = []
private var trackName: String?
private var currentLat: Double?
private var currentLon: Double?
private var currentEle: Double?
private var currentTime: Date?
private var currentElement: String?
private var inTrk = false
private var trkNameCaptured = false
private var textBuffer = ""
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
static func parse(data: Data) throws -> GPXTrack {
let parser = GPXParser()
let xml = XMLParser(data: data)
xml.delegate = parser
if !xml.parse() {
if let err = xml.parserError {
throw GPXParseError.invalid(err.localizedDescription)
}
}
guard !parser.points.isEmpty else { throw GPXParseError.noTrack }
return GPXTrack(name: parser.trackName, points: parser.points)
}
func parser(_ parser: XMLParser, didStartElement elementName: String,
namespaceURI: String?, qualifiedName qName: String?,
attributes attributeDict: [String : String] = [:]) {
currentElement = elementName
textBuffer = ""
switch elementName {
case "trk":
inTrk = true
trkNameCaptured = false
case "trkpt", "wpt", "rtept":
currentLat = attributeDict["lat"].flatMap { Double($0) }
currentLon = attributeDict["lon"].flatMap { Double($0) }
currentEle = nil
currentTime = nil
default:
break
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
textBuffer += string
}
func parser(_ parser: XMLParser, didEndElement elementName: String,
namespaceURI: String?, qualifiedName qName: String?) {
let text = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
switch elementName {
case "ele":
currentEle = Double(text)
case "time":
currentTime = Self.isoFormatter.date(from: text)
?? Self.isoFormatterNoFraction.date(from: text)
case "name":
if inTrk && !trkNameCaptured {
trackName = text
trkNameCaptured = true
}
case "trkpt", "wpt", "rtept":
if let lat = currentLat, let lon = currentLon {
points.append(GPXPoint(lat: lat, lon: lon, ele: currentEle, time: currentTime))
}
case "trk":
inTrk = false
default:
break
}
textBuffer = ""
}
}