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
254 lines
8.7 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|