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) + } + } + } +}