import SwiftUI import SwiftData import MapKit struct TrackingView: View { @Environment(\.modelContext) private var modelContext @Query private var activeWalks: [ActiveWalk] @State private var tracker = LocationTracker() @State private var now: Date = .now @State private var pendingPhotos: [CapturedPhoto] = [] @State private var showFinishSheet = false @State private var showCamera = false @State private var showResumeDialog = false @State private var didCheckResume = false private let launcher = WalkLauncher.shared private let clockTicker = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let persistTicker = Timer.publish(every: 5, on: .main, in: .common).autoconnect() var body: some View { NavigationStack { Group { if tracker.isTracking { activeTracking } else { startScreen } } .navigationTitle("Aufnehmen") .navigationBarTitleDisplayMode(.inline) } .onReceive(clockTicker) { now = $0 } .onReceive(persistTicker) { _ in tracker.checkAutoPause() persistActive() updateLiveActivity() } .onChange(of: tracker.isTracking) { _, isTracking in if isTracking { startLiveActivity() } else { WalkActivityController.end() } } .onChange(of: tracker.isPaused) { _, _ in updateLiveActivity() } .onChange(of: tracker.isAutoPaused) { _, _ in updateLiveActivity() } .onAppear { offerResumeIfNeeded() startIfRequested() } .onChange(of: launcher.pendingStart) { _, pending in if pending { startIfRequested() } } .sheet(isPresented: $showFinishSheet) { FinishWalkSheet( points: tracker.points, durationSeconds: tracker.effectiveElapsedSeconds, distanceMeters: tracker.totalDistanceMeters, initialPhotos: pendingPhotos, onDiscard: { discardCurrentWalk() }, onSaved: { discardCurrentWalk() } ) } .fullScreenCover(isPresented: $showCamera) { CameraPicker { data in let location = tracker.points.last pendingPhotos.append(CapturedPhoto(data: data, location: location)) } .ignoresSafeArea() } .confirmationDialog( resumeDialogTitle, isPresented: $showResumeDialog, titleVisibility: .visible ) { Button("Tour fortsetzen") { resumeStoredWalk() } Button("Jetzt speichern") { loadStoredWalkIntoTracker(thenFinish: true) } Button("Verwerfen", role: .destructive) { deleteStoredWalk() } } message: { if let stored = activeWalks.first { Text("\(stored.points.count) Punkte aufgezeichnet, gestartet um \(formatTime(stored.startedAt)).") } } } // 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() bottomControls .padding(.bottom, 12) } .padding(.horizontal) cameraOverlayButton } } private var statsCard: some View { HStack(spacing: 0) { stat( value: String(format: "%.2f", tracker.totalDistanceMeters / 1000), unit: "km", label: "Distanz" ) divider stat(value: formatDuration(tracker.effectiveElapsedSeconds), unit: "", label: "Dauer") divider stat(value: "\(pendingPhotos.count)", unit: "", label: "Fotos") } .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .overlay(alignment: .topLeading) { if tracker.isPaused { badge("Pause", icon: "pause.circle.fill", color: .orange).padding(8) } else if tracker.isAutoPaused { badge("Auto-Pause", icon: "pause.circle", color: .gray).padding(8) } } } private func badge(_ text: String, icon: String, color: Color) -> some View { Label(text, systemImage: icon) .font(.caption.bold()) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(color, in: Capsule()) } 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 cameraOverlayButton: some View { VStack { Spacer() HStack { Spacer() Button { showCamera = true } label: { Image(systemName: "camera.fill") .font(.title2) .foregroundStyle(.white) .frame(width: 56, height: 56) .background(Color.accentColor, in: Circle()) .shadow(radius: 4) } .padding(.trailing, 20) .padding(.bottom, 100) // sit above the bottom controls } } .allowsHitTesting(true) } private var bottomControls: some View { HStack(spacing: 12) { Button { if tracker.isPaused { tracker.resume() } else { tracker.pause() } persistActive() } label: { Image(systemName: tracker.isPaused ? "play.fill" : "pause.fill") .font(.title3.bold()) .frame(width: 56, height: 56) .foregroundStyle(.white) .background(tracker.isPaused ? Color.accentColor : Color.orange, in: Circle()) } Button { tracker.stop() showFinishSheet = true } label: { HStack { Image(systemName: "stop.circle.fill") Text("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 { startFresh() } 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: - Walk lifecycle private func startFresh() { // Discard any existing stale ActiveWalk before starting a new one for w in activeWalks { modelContext.delete(w) } let walk = ActiveWalk(startedAt: .now) modelContext.insert(walk) try? modelContext.save() pendingPhotos = [] tracker.startOrRequest() } /// Vom Siri-Kurzbefehl „Gassi gehen" angestoßen: Aufnahme starten, sofern /// nicht schon eine läuft. private func startIfRequested() { guard launcher.pendingStart, !tracker.isTracking else { return } launcher.pendingStart = false startFresh() } private func offerResumeIfNeeded() { guard !didCheckResume else { return } didCheckResume = true if !activeWalks.isEmpty, !tracker.isTracking { showResumeDialog = true } } private func resumeStoredWalk() { guard let walk = activeWalks.first else { return } tracker.restore( startedAt: walk.startedAt, points: walk.points, accumulatedPausedSeconds: walk.accumulatedPausedSeconds, lastUpdate: walk.lastUpdate, wasPaused: walk.isPaused ) pendingPhotos = [] } private func loadStoredWalkIntoTracker(thenFinish: Bool) { guard let walk = activeWalks.first else { return } // Mirror walk state into tracker without actually starting GPS — we // just need the data for FinishWalkSheet. tracker.restore( startedAt: walk.startedAt, points: walk.points, accumulatedPausedSeconds: walk.accumulatedPausedSeconds, lastUpdate: walk.lastUpdate, wasPaused: true // prevent automatic GPS restart ) tracker.stop() pendingPhotos = [] if thenFinish { showFinishSheet = true } } private func deleteStoredWalk() { for w in activeWalks { modelContext.delete(w) } try? modelContext.save() } private func discardCurrentWalk() { // Called after FinishWalkSheet onSaved or onDiscard. Resets state. deleteStoredWalk() tracker = LocationTracker() pendingPhotos = [] } private func persistActive() { guard tracker.isTracking, let walk = activeWalks.first else { return } walk.points = tracker.points walk.accumulatedPausedSeconds = tracker.accumulatedPausedSeconds walk.pausedAt = tracker.isPaused ? (tracker.pausedAt ?? .now) : nil walk.lastUpdate = .now try? modelContext.save() } // MARK: - Helpers private func startLiveActivity() { guard let startedAt = tracker.startedAt else { return } WalkActivityController.start( startedAt: startedAt, initialState: liveActivityState() ) } private func updateLiveActivity() { guard tracker.isTracking else { return } WalkActivityController.update(liveActivityState()) } private func liveActivityState() -> WalkActivityAttributes.WalkActivityState { WalkActivityAttributes.WalkActivityState( distanceMeters: tracker.totalDistanceMeters, elapsedSeconds: tracker.effectiveElapsedSeconds, pointCount: tracker.points.count, isPaused: tracker.isPaused, isAutoPaused: tracker.isAutoPaused ) } 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 formatTime(_ date: Date) -> String { let f = DateFormatter() f.locale = Locale(identifier: "de_DE") f.dateStyle = .short f.timeStyle = .short return f.string(from: date) } private var resumeDialogTitle: String { guard let stored = activeWalks.first else { return "" } return "Tour nicht gespeichert (\(formatTime(stored.startedAt)))" } }