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
This commit is contained in:
rene 2026-05-30 14:34:40 +02:00
parent 3d229d42ce
commit 7848817cbe
4 changed files with 470 additions and 0 deletions

View file

@ -5,13 +5,47 @@ import SwiftData
struct BanYaroGoApp: App {
@State private var auth = AuthSession()
@State private var activeDog = ActiveDogStore()
@State private var pendingGPX: GPXTrack?
var body: some Scene {
WindowGroup {
RootView()
.environment(auth)
.environment(activeDog)
.onOpenURL { url in
handleIncoming(url: url)
}
.sheet(item: Binding(
get: { pendingGPX.map { TrackBox(track: $0) } },
set: { pendingGPX = $0?.track }
)) { box in
GPXImportSheet(track: box.track) {
pendingGPX = nil
}
}
}
.modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
}
private func handleIncoming(url: URL) {
// Nur GPX akzeptieren andere URLs (Deep-Links) sind anderweitig verdrahtet.
guard url.pathExtension.lowercased() == "gpx" else { return }
let didAccess = url.startAccessingSecurityScopedResource()
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
do {
let data = try Data(contentsOf: url)
let track = try GPXParser.parse(data: data)
pendingGPX = track
} catch {
print("GPX-Import fehlgeschlagen: \(error)")
}
}
}
/// Hilfs-Wrapper, damit GPXTrack als `Identifiable` in `.sheet(item:)` taugt.
private struct TrackBox: Identifiable {
let id = UUID()
let track: GPXTrack
}

View file

@ -0,0 +1,138 @@
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 = ""
}
}

View file

@ -0,0 +1,254 @@
import SwiftUI
import MapKit
import CoreLocation
struct GPXImportSheet: View {
let track: GPXTrack
let onDismiss: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var dogs: [Dog] = []
@State private var selectedDogIds: Set<Int> = []
@State private var isPublic: Bool = false
@State private var isSaving = false
@State private var errorMessage: String?
@State private var saved: Bool = false
init(track: GPXTrack, onDismiss: @escaping () -> Void) {
self.track = track
self.onDismiss = onDismiss
_name = State(initialValue: track.name?.isEmpty == false
? (track.name ?? "Importierte Tour")
: "Importierte Tour")
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
mapView
summaryCard
importCard
viewOnlyCard
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding()
}
.navigationTitle("GPX importieren")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Schließen") {
dismiss()
onDismiss()
}
}
}
.task {
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
}
}
}
// MARK: - Map
private var mapView: some View {
Map {
MapPolyline(coordinates: track.coordinates)
.stroke(Color.accentColor, lineWidth: 4)
if let first = track.coordinates.first {
Annotation("Start", coordinate: first) {
Image(systemName: "flag.circle.fill")
.font(.title2)
.foregroundStyle(.white, .green)
.background(.white, in: Circle())
}
}
if let last = track.coordinates.last, track.coordinates.count > 1 {
Annotation("Ziel", coordinate: last) {
Image(systemName: "flag.checkered.circle.fill")
.font(.title2)
.foregroundStyle(.white, .red)
.background(.white, in: Circle())
}
}
}
.mapStyle(.standard(elevation: .realistic))
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 16))
.allowsHitTesting(false)
}
// MARK: - Summary
private var summaryCard: some View {
HStack(spacing: 16) {
stat("Distanz", value: distanceLabel, icon: "ruler")
Divider()
stat("Punkte", value: "\(track.points.count)", icon: "point.3.connected.trianglepath.dotted")
if let dur = durationLabel {
Divider()
stat("Dauer", value: dur, icon: "clock")
}
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
private func stat(_ title: String, value: String, icon: String) -> some View {
VStack(spacing: 4) {
Image(systemName: icon).foregroundStyle(Color.accentColor)
Text(value).font(.headline.monospacedDigit())
Text(title).font(.caption2).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
// MARK: - Import (save as route)
private var importCard: some View {
VStack(alignment: .leading, spacing: 12) {
Label("Als Tour übernehmen", systemImage: "square.and.arrow.down.fill")
.font(.headline)
TextField("Name", text: $name)
.textFieldStyle(.roundedBorder)
if !dogs.isEmpty {
Text("Hunde").font(.caption).foregroundStyle(.secondary)
FlowDogs(dogs: dogs, selected: $selectedDogIds)
}
Toggle(isOn: $isPublic) {
Label("Öffentlich teilen", systemImage: "globe")
}
.font(.subheadline)
Button {
Task { await saveAsRoute() }
} label: {
HStack {
if isSaving { ProgressView() }
Image(systemName: saved ? "checkmark.circle.fill" : "square.and.arrow.down")
Text(saved ? "Gespeichert" : "Speichern")
.bold()
}
.frame(maxWidth: .infinity, minHeight: 44)
}
.background((saved ? Color.green : Color.accentColor).opacity(isSaving ? 0.6 : 1), in: Capsule())
.foregroundStyle(.white)
.disabled(isSaving || saved || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - View only
private var viewOnlyCard: some View {
VStack(alignment: .leading, spacing: 12) {
Label("Nur ansehen", systemImage: "eye")
.font(.headline)
Text("Den Track in Apple Maps öffnen und dort z. B. eine Route zum Startpunkt planen.")
.font(.caption)
.foregroundStyle(.secondary)
Button {
openStartInMaps()
} label: {
HStack {
Image(systemName: "arrow.triangle.turn.up.right.diamond.fill")
Text("Startpunkt in Apple Maps").bold()
}
.frame(maxWidth: .infinity, minHeight: 44)
}
.background(.thinMaterial, in: Capsule())
.foregroundStyle(.primary)
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Actions
private func saveAsRoute() async {
isSaving = true
errorMessage = nil
defer { isSaving = false }
let dist = track.distanceMeters / 1000.0
let durMin: Int = {
if let s = track.durationSeconds { return Int(s / 60.0) }
return 0
}()
let body = RouteCreateBody(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
gpsTrack: track.points.map { GPSPoint(lat: $0.lat, lon: $0.lon, alt: $0.ele) },
distanzKm: dist,
dauerMin: durMin,
dogIds: Array(selectedDogIds),
isPublic: isPublic
)
do {
let _: RouteDetail = try await APIClient.shared.post("/api/routes", body: body)
saved = true
} catch {
errorMessage = error.localizedDescription
}
}
private func openStartInMaps() {
guard let start = track.coordinates.first else { return }
let item = MKMapItem(placemark: MKPlacemark(coordinate: start))
item.name = name
item.openInMaps(launchOptions: nil)
}
// MARK: - Labels
private var distanceLabel: String {
let km = track.distanceMeters / 1000.0
return km < 1 ? "\(Int(track.distanceMeters)) m" : String(format: "%.1f km", km)
}
private var durationLabel: String? {
guard let s = track.durationSeconds else { return nil }
let h = Int(s) / 3600
let m = (Int(s) % 3600) / 60
if h > 0 { return "\(h):\(String(format: "%02d", m)) h" }
return "\(m) min"
}
}
private struct FlowDogs: View {
let dogs: [Dog]
@Binding var selected: Set<Int>
var body: some View {
let cols = [GridItem(.adaptive(minimum: 110))]
LazyVGrid(columns: cols, alignment: .leading, spacing: 8) {
ForEach(dogs) { d in
let on = selected.contains(d.id)
Button {
if on { selected.remove(d.id) } else { selected.insert(d.id) }
} label: {
HStack(spacing: 6) {
Image(systemName: on ? "checkmark.circle.fill" : "circle")
Text(d.name).lineLimit(1)
}
.font(.caption.bold())
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(on ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12),
in: Capsule())
.foregroundStyle(on ? Color.accentColor : .primary)
}
.buttonStyle(.plain)
}
}
}
}