banyaro-ios/BanYaroGo/Views/GPXImportSheet.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

254 lines
8.7 KiB
Swift

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