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.
This commit is contained in:
parent
e27fa39620
commit
5473bbf41f
9 changed files with 445 additions and 61 deletions
|
|
@ -1,12 +1,22 @@
|
|||
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 showFinishSheet = false
|
||||
@State private var pendingPhotos: [Data] = []
|
||||
|
||||
private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
@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 {
|
||||
|
|
@ -20,16 +30,38 @@ struct TrackingView: View {
|
|||
.navigationTitle("Aufnehmen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.onReceive(ticker) { now = $0 }
|
||||
.onReceive(clockTicker) { now = $0 }
|
||||
.onReceive(persistTicker) { _ in persistActive() }
|
||||
.onAppear { offerResumeIfNeeded() }
|
||||
.sheet(isPresented: $showFinishSheet) {
|
||||
FinishWalkSheet(
|
||||
points: tracker.points,
|
||||
durationSeconds: durationSeconds,
|
||||
durationSeconds: tracker.effectiveElapsedSeconds,
|
||||
distanceMeters: tracker.totalDistanceMeters,
|
||||
onDiscard: { resetTracker() },
|
||||
onSaved: { resetTracker() }
|
||||
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
|
||||
|
|
@ -52,23 +84,43 @@ struct TrackingView: View {
|
|||
VStack {
|
||||
statsCard
|
||||
Spacer()
|
||||
stopButton
|
||||
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")
|
||||
stat(
|
||||
value: String(format: "%.2f", tracker.totalDistanceMeters / 1000),
|
||||
unit: "km",
|
||||
label: "Distanz"
|
||||
)
|
||||
divider
|
||||
stat(value: formatDuration(durationSeconds), unit: "", label: "Dauer")
|
||||
stat(value: formatDuration(tracker.effectiveElapsedSeconds), unit: "", label: "Dauer")
|
||||
divider
|
||||
stat(value: "\(tracker.points.count)", unit: "", label: "Punkte")
|
||||
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 {
|
||||
|
|
@ -88,19 +140,54 @@ struct TrackingView: View {
|
|||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var stopButton: some View {
|
||||
Button {
|
||||
tracker.stop()
|
||||
showFinishSheet = true
|
||||
} label: {
|
||||
private var cameraOverlayButton: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Image(systemName: "stop.circle.fill")
|
||||
Text("Aufnahme stoppen").bold()
|
||||
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
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
}
|
||||
.background(.red, in: Capsule())
|
||||
.foregroundStyle(.white)
|
||||
.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
|
||||
|
|
@ -125,7 +212,7 @@ struct TrackingView: View {
|
|||
}
|
||||
|
||||
Button {
|
||||
tracker.startOrRequest()
|
||||
startFresh()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
|
|
@ -154,13 +241,79 @@ struct TrackingView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
// MARK: - Walk lifecycle
|
||||
|
||||
private var durationSeconds: Int {
|
||||
guard let startedAt = tracker.startedAt else { return 0 }
|
||||
return Int(now.timeIntervalSince(startedAt))
|
||||
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
|
||||
|
|
@ -169,8 +322,16 @@ struct TrackingView: View {
|
|||
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()
|
||||
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)))"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue