banyaro-ios/BanYaroGo/Views/TrackingView.swift
rene 5473bbf41f Phase 3.5: Pause/Resume, SwiftData-Persistenz, Kamera-Capture, Fotos zu bestehender Tour
LocationTracker:
- isPaused, pausedAt, accumulatedPausedSeconds
- pause()/resume()/restore() Methoden
- effectiveElapsedSeconds rechnet Pausen raus
- restore() für nach App-Crash: Offline-Lücke wird als Pause gezählt

ActiveWalk @Model (SwiftData):
- startedAt, lastUpdate, pausedAt, accumulatedPausedSeconds, pointsData
- Container in BanYaroGoApp registriert

TrackingView:
- Persistenz alle 5s via Timer
- confirmationDialog beim Erscheinen wenn ActiveWalk vorhanden:
  Fortsetzen / Jetzt speichern / Verwerfen
- Pause/Resume-Button + Stop-Button
- Floating Kamera-Button rechts unten
- Foto-Counter in der Stats-Karte
- Pause-Badge oben links bei Pause

CameraPicker: UIImagePickerController-Wrapper (Fallback auf Library im Simulator).

FinishWalkSheet: initialPhotos: [Data] für Kamera-Fotos während Tour.

RouteDetailView: PhotosPicker zum Hinzufügen von Fotos zu bestehender Tour,
sequentieller Upload mit Progress, Detail wird nach Upload refreshed.

NSCameraUsageDescription in BanYaroGo-Info.plist.
2026-05-30 10:52:15 +02:00

337 lines
11 KiB
Swift

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: [Data] = []
@State private var showFinishSheet = false
@State private var showCamera = false
@State private var showResumeDialog = false
@State private var didCheckResume = false
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 persistActive() }
.onAppear { offerResumeIfNeeded() }
.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
pendingPhotos.append(data)
}
.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 {
pausedBadge.padding(8)
}
}
}
private var pausedBadge: some View {
Label("Pause", systemImage: "pause.circle.fill")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.orange, 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()
}
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 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)))"
}
}