diff --git a/BanYaroGo-Info.plist b/BanYaroGo-Info.plist
index d6e4fb7..bd382e9 100644
--- a/BanYaroGo-Info.plist
+++ b/BanYaroGo-Info.plist
@@ -60,5 +60,49 @@
NSSupportsLiveActivities
+ LSSupportsOpeningDocumentsInPlace
+
+ UTImportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ com.topografix.gpx
+ UTTypeDescription
+ GPX-Track
+ UTTypeConformsTo
+
+ public.xml
+ public.data
+ public.content
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ gpx
+
+ public.mime-type
+
+ application/gpx+xml
+ application/xml
+
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ GPX-Track
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ com.topografix.gpx
+
+
+
diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift
index 70070cf..1225152 100644
--- a/BanYaroGo/BanYaroGoApp.swift
+++ b/BanYaroGo/BanYaroGoApp.swift
@@ -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
}
diff --git a/BanYaroGo/Support/GPXParser.swift b/BanYaroGo/Support/GPXParser.swift
new file mode 100644
index 0000000..8cb89aa
--- /dev/null
+++ b/BanYaroGo/Support/GPXParser.swift
@@ -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.. 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 , und als Punkte;
+/// Track-Name aus innerhalb des ersten .
+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 = ""
+ }
+}
diff --git a/BanYaroGo/Views/GPXImportSheet.swift b/BanYaroGo/Views/GPXImportSheet.swift
new file mode 100644
index 0000000..fe546bc
--- /dev/null
+++ b/BanYaroGo/Views/GPXImportSheet.swift
@@ -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 = []
+ @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
+
+ 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)
+ }
+ }
+ }
+}