- BanYaroGo-Info.plist (explizit, statt INFOPLIST_KEY_*): UIBackgroundModes location, NSLocationWhenInUseUsageDescription, NSLocationAlwaysAndWhenInUseUsageDescription - LocationTracker: CLLocationManager-Wrapper (@Observable @MainActor), Distanz via CLLocation.distance, Permission-Handling, Background-Updates - RouteCreateBody + Encoder mit convertToSnakeCase für POST /api/routes - TrackingView: Start-Hero-Screen + Live-Karte mit MapPolyline + Stats-Karte - FinishWalkSheet: Name + Hunde-Multiselect + POST /api/routes - MainTabView: neuer Aufnehmen-Tab zwischen Touren und Hunde - AppIcon: neues Hund-mit-GPS-Pin (vom User bereitgestellt, weiße Ränder weggeschnitten + Ecken mit Hintergrundfarbe gefüllt)
176 lines
5.7 KiB
Swift
176 lines
5.7 KiB
Swift
import SwiftUI
|
|
import MapKit
|
|
|
|
struct TrackingView: View {
|
|
@State private var tracker = LocationTracker()
|
|
@State private var now: Date = .now
|
|
@State private var showFinishSheet = false
|
|
|
|
private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if tracker.isTracking {
|
|
activeTracking
|
|
} else {
|
|
startScreen
|
|
}
|
|
}
|
|
.navigationTitle("Aufnehmen")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.onReceive(ticker) { now = $0 }
|
|
.sheet(isPresented: $showFinishSheet) {
|
|
FinishWalkSheet(
|
|
points: tracker.points,
|
|
durationSeconds: durationSeconds,
|
|
distanceMeters: tracker.totalDistanceMeters,
|
|
onDiscard: { resetTracker() },
|
|
onSaved: { resetTracker() }
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Active tracking
|
|
|
|
private var activeTracking: some View {
|
|
ZStack(alignment: .top) {
|
|
Map {
|
|
UserAnnotation()
|
|
if tracker.points.count >= 2 {
|
|
MapPolyline(coordinates: tracker.points.map {
|
|
CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon)
|
|
})
|
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineJoin: .round))
|
|
}
|
|
}
|
|
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
|
|
.mapControlVisibility(.hidden)
|
|
.ignoresSafeArea(edges: .bottom)
|
|
|
|
VStack {
|
|
statsCard
|
|
Spacer()
|
|
stopButton
|
|
.padding(.bottom, 12)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
private var statsCard: some View {
|
|
HStack(spacing: 0) {
|
|
stat(value: String(format: "%.2f", tracker.totalDistanceMeters / 1000), unit: "km", label: "Distanz")
|
|
divider
|
|
stat(value: formatDuration(durationSeconds), unit: "", label: "Dauer")
|
|
divider
|
|
stat(value: "\(tracker.points.count)", unit: "", label: "Punkte")
|
|
}
|
|
.padding()
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
private var divider: some View {
|
|
Divider().frame(height: 36)
|
|
}
|
|
|
|
private func stat(value: String, unit: String, label: String) -> some View {
|
|
VStack(spacing: 4) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
|
Text(value).font(.title2.bold().monospacedDigit())
|
|
if !unit.isEmpty {
|
|
Text(unit).font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var stopButton: some View {
|
|
Button {
|
|
tracker.stop()
|
|
showFinishSheet = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "stop.circle.fill")
|
|
Text("Aufnahme stoppen").bold()
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 56)
|
|
}
|
|
.background(.red, in: Capsule())
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
// MARK: - Start screen
|
|
|
|
private var startScreen: some View {
|
|
VStack(spacing: 28) {
|
|
Spacer()
|
|
Image(systemName: "figure.walk.circle.fill")
|
|
.font(.system(size: 96))
|
|
.foregroundStyle(Color.accentColor)
|
|
VStack(spacing: 6) {
|
|
Text("Bereit für die nächste Gassi?")
|
|
.font(.title3.bold())
|
|
Text("Tippe auf Start, um deine Tour aufzuzeichnen — auch wenn dein iPhone gesperrt ist.")
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
if tracker.permissionDenied {
|
|
permissionWarning
|
|
}
|
|
|
|
Button {
|
|
tracker.startOrRequest()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "play.fill")
|
|
Text("Aufnahme starten").bold()
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 56)
|
|
}
|
|
.background(Color.accentColor, in: Capsule())
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var permissionWarning: some View {
|
|
VStack(spacing: 6) {
|
|
Label("Standortzugriff fehlt", systemImage: "location.slash.fill")
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(.red)
|
|
Text("Bitte erlaube den Standortzugriff in den iOS-Einstellungen unter Datenschutz → Ortungsdienste → Ban Yaro Go.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var durationSeconds: Int {
|
|
guard let startedAt = tracker.startedAt else { return 0 }
|
|
return Int(now.timeIntervalSince(startedAt))
|
|
}
|
|
|
|
private func formatDuration(_ seconds: Int) -> String {
|
|
let h = seconds / 3600
|
|
let m = (seconds % 3600) / 60
|
|
let s = seconds % 60
|
|
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
|
|
return String(format: "%d:%02d", m, s)
|
|
}
|
|
|
|
private func resetTracker() {
|
|
// Fresh tracker for the next walk; old `points` arrays are released with it.
|
|
tracker = LocationTracker()
|
|
}
|
|
}
|