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
|
|
@ -48,6 +48,8 @@
|
||||||
<string>Wir brauchen deinen Standort, um deine Gassi-Tour als Karte aufzuzeichnen.</string>
|
<string>Wir brauchen deinen Standort, um deine Gassi-Tour als Karte aufzuzeichnen.</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.</string>
|
<string>Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Für Fotos während deiner Gassi-Tour brauchen wir Zugriff auf die Kamera.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BanYaroGoApp: App {
|
struct BanYaroGoApp: App {
|
||||||
|
|
@ -9,5 +10,6 @@ struct BanYaroGoApp: App {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(auth)
|
.environment(auth)
|
||||||
}
|
}
|
||||||
|
.modelContainer(for: ActiveWalk.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
BanYaroGo/Tracking/ActiveWalk.swift
Normal file
33
BanYaroGo/Tracking/ActiveWalk.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Persisted state of an in-progress walk. Lives in SwiftData so a walk
|
||||||
|
/// survives an app crash or kill — on next launch the TrackingView offers
|
||||||
|
/// the user to resume, save, or discard.
|
||||||
|
@Model
|
||||||
|
final class ActiveWalk {
|
||||||
|
var startedAt: Date
|
||||||
|
var lastUpdate: Date
|
||||||
|
var pausedAt: Date?
|
||||||
|
var accumulatedPausedSeconds: Int
|
||||||
|
private var pointsData: Data
|
||||||
|
|
||||||
|
init(startedAt: Date = .now) {
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.lastUpdate = startedAt
|
||||||
|
self.pausedAt = nil
|
||||||
|
self.accumulatedPausedSeconds = 0
|
||||||
|
self.pointsData = Data("[]".utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
var points: [GPSPoint] {
|
||||||
|
get {
|
||||||
|
(try? JSONDecoder().decode([GPSPoint].self, from: pointsData)) ?? []
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
pointsData = (try? JSONEncoder().encode(newValue)) ?? Data("[]".utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPaused: Bool { pausedAt != nil }
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,6 @@ import Observation
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
/// Wraps CLLocationManager for live Gassi-Tracking with background updates.
|
/// Wraps CLLocationManager for live Gassi-Tracking with background updates.
|
||||||
///
|
|
||||||
/// Usage:
|
|
||||||
/// 1. `startOrRequest()` — handles permission flow + starts recording when granted.
|
|
||||||
/// 2. Watch `isTracking`, `points`, `totalDistanceMeters`, `startedAt`.
|
|
||||||
/// 3. `stop()` to end the recording and inspect the final `points`.
|
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
@ -15,12 +10,13 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
var points: [GPSPoint] = []
|
var points: [GPSPoint] = []
|
||||||
var isTracking: Bool = false
|
var isTracking: Bool = false
|
||||||
|
var isPaused: Bool = false
|
||||||
var startedAt: Date?
|
var startedAt: Date?
|
||||||
|
var pausedAt: Date?
|
||||||
|
var accumulatedPausedSeconds: Int = 0
|
||||||
var authorizationStatus: CLAuthorizationStatus
|
var authorizationStatus: CLAuthorizationStatus
|
||||||
var permissionDenied: Bool = false
|
var permissionDenied: Bool = false
|
||||||
|
|
||||||
/// Set when the user asked to start but we're still waiting for the system
|
|
||||||
/// permission prompt. The delegate auto-starts as soon as access is granted.
|
|
||||||
private var pendingStart: Bool = false
|
private var pendingStart: Bool = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
|
|
@ -28,12 +24,12 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
super.init()
|
super.init()
|
||||||
manager.delegate = self
|
manager.delegate = self
|
||||||
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
|
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
|
||||||
manager.distanceFilter = 5 // meters
|
manager.distanceFilter = 5
|
||||||
manager.activityType = .fitness
|
manager.activityType = .fitness
|
||||||
manager.pausesLocationUpdatesAutomatically = false
|
manager.pausesLocationUpdatesAutomatically = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func startOrRequest() {
|
func startOrRequest() {
|
||||||
permissionDenied = false
|
permissionDenied = false
|
||||||
|
|
@ -44,19 +40,79 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
case .denied, .restricted:
|
case .denied, .restricted:
|
||||||
permissionDenied = true
|
permissionDenied = true
|
||||||
case .authorizedWhenInUse, .authorizedAlways:
|
case .authorizedWhenInUse, .authorizedAlways:
|
||||||
beginTracking()
|
beginFresh()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
permissionDenied = true
|
permissionDenied = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore an in-progress walk that was interrupted by an app restart.
|
||||||
|
/// Treats the elapsed offline time as pause time so the active-tracking
|
||||||
|
/// counter doesn't jump.
|
||||||
|
func restore(
|
||||||
|
startedAt: Date,
|
||||||
|
points: [GPSPoint],
|
||||||
|
accumulatedPausedSeconds: Int,
|
||||||
|
lastUpdate: Date,
|
||||||
|
wasPaused: Bool
|
||||||
|
) {
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.points = points
|
||||||
|
// The offline gap counts as paused time.
|
||||||
|
let offlineGap = max(0, Int(Date.now.timeIntervalSince(lastUpdate)))
|
||||||
|
self.accumulatedPausedSeconds = accumulatedPausedSeconds + offlineGap
|
||||||
|
self.isTracking = true
|
||||||
|
|
||||||
|
if wasPaused {
|
||||||
|
self.isPaused = true
|
||||||
|
self.pausedAt = nil // the previous pause is rolled into accumulated
|
||||||
|
} else {
|
||||||
|
self.isPaused = false
|
||||||
|
beginUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
guard isTracking, !isPaused else { return }
|
||||||
|
isPaused = true
|
||||||
|
pausedAt = .now
|
||||||
|
manager.stopUpdatingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
guard isPaused else { return }
|
||||||
|
if let pausedAt {
|
||||||
|
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
|
||||||
|
}
|
||||||
|
pausedAt = nil
|
||||||
|
isPaused = false
|
||||||
|
beginUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
if isPaused, let pausedAt {
|
||||||
|
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
|
||||||
|
}
|
||||||
|
isPaused = false
|
||||||
|
pausedAt = nil
|
||||||
manager.stopUpdatingLocation()
|
manager.stopUpdatingLocation()
|
||||||
manager.allowsBackgroundLocationUpdates = false
|
manager.allowsBackgroundLocationUpdates = false
|
||||||
isTracking = false
|
isTracking = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total distance walked so far, in meters.
|
// MARK: - Derived state
|
||||||
|
|
||||||
|
/// Active tracking time in seconds, with pauses already removed.
|
||||||
|
var effectiveElapsedSeconds: Int {
|
||||||
|
guard let startedAt else { return 0 }
|
||||||
|
let total = Int(Date.now.timeIntervalSince(startedAt))
|
||||||
|
var paused = accumulatedPausedSeconds
|
||||||
|
if isPaused, let pausedAt {
|
||||||
|
paused += Int(Date.now.timeIntervalSince(pausedAt))
|
||||||
|
}
|
||||||
|
return max(0, total - paused)
|
||||||
|
}
|
||||||
|
|
||||||
var totalDistanceMeters: Double {
|
var totalDistanceMeters: Double {
|
||||||
guard points.count >= 2 else { return 0 }
|
guard points.count >= 2 else { return 0 }
|
||||||
var total: Double = 0
|
var total: Double = 0
|
||||||
|
|
@ -70,21 +126,25 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
// MARK: - Internal
|
// MARK: - Internal
|
||||||
|
|
||||||
private func beginTracking() {
|
private func beginFresh() {
|
||||||
points = []
|
points = []
|
||||||
startedAt = .now
|
startedAt = .now
|
||||||
manager.allowsBackgroundLocationUpdates = true
|
pausedAt = nil
|
||||||
manager.showsBackgroundLocationIndicator = true
|
accumulatedPausedSeconds = 0
|
||||||
manager.startUpdatingLocation()
|
isPaused = false
|
||||||
isTracking = true
|
isTracking = true
|
||||||
|
beginUpdates()
|
||||||
// If we only have "when in use", silently try to escalate to "always".
|
|
||||||
// System will show the secondary prompt on its own schedule.
|
|
||||||
if authorizationStatus == .authorizedWhenInUse {
|
if authorizationStatus == .authorizedWhenInUse {
|
||||||
manager.requestAlwaysAuthorization()
|
manager.requestAlwaysAuthorization()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func beginUpdates() {
|
||||||
|
manager.allowsBackgroundLocationUpdates = true
|
||||||
|
manager.showsBackgroundLocationIndicator = true
|
||||||
|
manager.startUpdatingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - CLLocationManagerDelegate
|
// MARK: - CLLocationManagerDelegate
|
||||||
|
|
||||||
nonisolated func locationManager(
|
nonisolated func locationManager(
|
||||||
|
|
@ -96,6 +156,7 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
.map { GPSPoint(lat: $0.coordinate.latitude, lon: $0.coordinate.longitude, alt: $0.altitude) }
|
.map { GPSPoint(lat: $0.coordinate.latitude, lon: $0.coordinate.longitude, alt: $0.altitude) }
|
||||||
guard !newPoints.isEmpty else { return }
|
guard !newPoints.isEmpty else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard self.isTracking, !self.isPaused else { return }
|
||||||
self.points.append(contentsOf: newPoints)
|
self.points.append(contentsOf: newPoints)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +172,7 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
case .authorizedWhenInUse, .authorizedAlways:
|
case .authorizedWhenInUse, .authorizedAlways:
|
||||||
if self.pendingStart {
|
if self.pendingStart {
|
||||||
self.pendingStart = false
|
self.pendingStart = false
|
||||||
self.beginTracking()
|
self.beginFresh()
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|
@ -120,8 +181,6 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
// Transient errors (e.g. no fix yet) are common — we just keep listening.
|
|
||||||
// Log to console so we can see during development.
|
|
||||||
print("LocationTracker error: \(error)")
|
print("LocationTracker error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
BanYaroGo/Views/CameraPicker.swift
Normal file
46
BanYaroGo/Views/CameraPicker.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Native camera capture via UIImagePickerController, wrapped for SwiftUI.
|
||||||
|
/// Falls back to the photo library on the simulator where no camera exists.
|
||||||
|
struct CameraPicker: UIViewControllerRepresentable {
|
||||||
|
let onCapture: (Data) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||||
|
picker.sourceType = .camera
|
||||||
|
picker.cameraCaptureMode = .photo
|
||||||
|
} else {
|
||||||
|
// Simulator has no camera — let testing still work via library.
|
||||||
|
picker.sourceType = .photoLibrary
|
||||||
|
}
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let parent: CameraPicker
|
||||||
|
init(_ parent: CameraPicker) { self.parent = parent }
|
||||||
|
|
||||||
|
func imagePickerController(
|
||||||
|
_ picker: UIImagePickerController,
|
||||||
|
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
||||||
|
) {
|
||||||
|
if let image = info[.originalImage] as? UIImage,
|
||||||
|
let data = image.jpegData(compressionQuality: 0.9) {
|
||||||
|
parent.onCapture(data)
|
||||||
|
}
|
||||||
|
parent.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
parent.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ struct FinishWalkSheet: View {
|
||||||
let points: [GPSPoint]
|
let points: [GPSPoint]
|
||||||
let durationSeconds: Int
|
let durationSeconds: Int
|
||||||
let distanceMeters: Double
|
let distanceMeters: Double
|
||||||
|
let initialPhotos: [Data]
|
||||||
let onDiscard: () -> Void
|
let onDiscard: () -> Void
|
||||||
let onSaved: () -> Void
|
let onSaved: () -> Void
|
||||||
|
|
||||||
|
|
@ -34,12 +35,14 @@ struct FinishWalkSheet: View {
|
||||||
points: [GPSPoint],
|
points: [GPSPoint],
|
||||||
durationSeconds: Int,
|
durationSeconds: Int,
|
||||||
distanceMeters: Double,
|
distanceMeters: Double,
|
||||||
|
initialPhotos: [Data] = [],
|
||||||
onDiscard: @escaping () -> Void,
|
onDiscard: @escaping () -> Void,
|
||||||
onSaved: @escaping () -> Void
|
onSaved: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.points = points
|
self.points = points
|
||||||
self.durationSeconds = durationSeconds
|
self.durationSeconds = durationSeconds
|
||||||
self.distanceMeters = distanceMeters
|
self.distanceMeters = distanceMeters
|
||||||
|
self.initialPhotos = initialPhotos
|
||||||
self.onDiscard = onDiscard
|
self.onDiscard = onDiscard
|
||||||
self.onSaved = onSaved
|
self.onSaved = onSaved
|
||||||
|
|
||||||
|
|
@ -47,6 +50,7 @@ struct FinishWalkSheet: View {
|
||||||
formatter.locale = Locale(identifier: "de_DE")
|
formatter.locale = Locale(identifier: "de_DE")
|
||||||
formatter.dateStyle = .medium
|
formatter.dateStyle = .medium
|
||||||
_name = State(initialValue: "Gassi am \(formatter.string(from: .now))")
|
_name = State(initialValue: "Gassi am \(formatter.string(from: .now))")
|
||||||
|
_photoData = State(initialValue: initialPhotos)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
struct RouteDetailView: View {
|
struct RouteDetailView: View {
|
||||||
let routeId: Int
|
let routeId: Int
|
||||||
|
|
@ -8,6 +9,11 @@ struct RouteDetailView: View {
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
@State private var newPhotoSelection: [PhotosPickerItem] = []
|
||||||
|
@State private var isUploadingPhoto = false
|
||||||
|
@State private var photoUploadProgress: (done: Int, total: Int) = (0, 0)
|
||||||
|
@State private var photoErrorMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|
@ -30,19 +36,8 @@ struct RouteDetailView: View {
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let urls = detail.fotoUrls, !urls.isEmpty {
|
photosSection(for: detail)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Fotos").font(.headline)
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
ForEach(urls, id: \.self) { path in
|
|
||||||
photoThumb(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 24)
|
Spacer(minLength: 24)
|
||||||
} else if isLoading {
|
} else if isLoading {
|
||||||
|
|
@ -60,6 +55,60 @@ struct RouteDetailView: View {
|
||||||
.navigationTitle(detail?.name ?? fallbackName)
|
.navigationTitle(detail?.name ?? fallbackName)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
|
.onChange(of: newPhotoSelection) { _, items in
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
Task { await uploadSelected(items: items) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func photosSection(for detail: RouteDetail) -> some View {
|
||||||
|
let urls = detail.fotoUrls ?? []
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(urls.isEmpty ? "Fotos" : "Fotos (\(urls.count))")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
PhotosPicker(
|
||||||
|
selection: $newPhotoSelection,
|
||||||
|
maxSelectionCount: 5,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
|
Label("Foto hinzufügen", systemImage: "photo.badge.plus")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.disabled(isUploadingPhoto)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isUploadingPhoto {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Lade \(photoUploadProgress.done + 1)/\(photoUploadProgress.total)…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let photoErrorMessage {
|
||||||
|
Text(photoErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !urls.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(urls, id: \.self) { path in
|
||||||
|
photoThumb(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !isUploadingPhoto {
|
||||||
|
Text("Noch keine Fotos. Über den Button oben kannst du welche hinzufügen.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func photoThumb(_ path: String) -> some View {
|
private func photoThumb(_ path: String) -> some View {
|
||||||
|
|
@ -87,6 +136,34 @@ struct RouteDetailView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func uploadSelected(items: [PhotosPickerItem]) async {
|
||||||
|
isUploadingPhoto = true
|
||||||
|
photoErrorMessage = nil
|
||||||
|
photoUploadProgress = (0, items.count)
|
||||||
|
defer {
|
||||||
|
isUploadingPhoto = false
|
||||||
|
newPhotoSelection = []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, item) in items.enumerated() {
|
||||||
|
photoUploadProgress = (index, items.count)
|
||||||
|
guard let raw = try? await item.loadTransferable(type: Data.self) else { continue }
|
||||||
|
let resized = ImageResize.resizedJPEG(from: raw)
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.uploadFile(
|
||||||
|
"/api/routes/\(routeId)/photo",
|
||||||
|
filename: "photo_\(Int(Date.now.timeIntervalSince1970))_\(index + 1).jpg",
|
||||||
|
data: resized
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
photoErrorMessage = "Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Refresh detail to pick up the new foto_urls.
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
private func formatKm(_ km: Double?) -> String {
|
private func formatKm(_ km: Double?) -> String {
|
||||||
guard let km else { return "—" }
|
guard let km else { return "—" }
|
||||||
return String(format: "%.2f km", km)
|
return String(format: "%.2f km", km)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
import MapKit
|
import MapKit
|
||||||
|
|
||||||
struct TrackingView: View {
|
struct TrackingView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Query private var activeWalks: [ActiveWalk]
|
||||||
|
|
||||||
@State private var tracker = LocationTracker()
|
@State private var tracker = LocationTracker()
|
||||||
@State private var now: Date = .now
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -20,16 +30,38 @@ struct TrackingView: View {
|
||||||
.navigationTitle("Aufnehmen")
|
.navigationTitle("Aufnehmen")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
.onReceive(ticker) { now = $0 }
|
.onReceive(clockTicker) { now = $0 }
|
||||||
|
.onReceive(persistTicker) { _ in persistActive() }
|
||||||
|
.onAppear { offerResumeIfNeeded() }
|
||||||
.sheet(isPresented: $showFinishSheet) {
|
.sheet(isPresented: $showFinishSheet) {
|
||||||
FinishWalkSheet(
|
FinishWalkSheet(
|
||||||
points: tracker.points,
|
points: tracker.points,
|
||||||
durationSeconds: durationSeconds,
|
durationSeconds: tracker.effectiveElapsedSeconds,
|
||||||
distanceMeters: tracker.totalDistanceMeters,
|
distanceMeters: tracker.totalDistanceMeters,
|
||||||
onDiscard: { resetTracker() },
|
initialPhotos: pendingPhotos,
|
||||||
onSaved: { resetTracker() }
|
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
|
// MARK: - Active tracking
|
||||||
|
|
@ -52,23 +84,43 @@ struct TrackingView: View {
|
||||||
VStack {
|
VStack {
|
||||||
statsCard
|
statsCard
|
||||||
Spacer()
|
Spacer()
|
||||||
stopButton
|
bottomControls
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
cameraOverlayButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statsCard: some View {
|
private var statsCard: some View {
|
||||||
HStack(spacing: 0) {
|
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
|
divider
|
||||||
stat(value: formatDuration(durationSeconds), unit: "", label: "Dauer")
|
stat(value: formatDuration(tracker.effectiveElapsedSeconds), unit: "", label: "Dauer")
|
||||||
divider
|
divider
|
||||||
stat(value: "\(tracker.points.count)", unit: "", label: "Punkte")
|
stat(value: "\(pendingPhotos.count)", unit: "", label: "Fotos")
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
.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 {
|
private var divider: some View {
|
||||||
|
|
@ -88,20 +140,55 @@ struct TrackingView: View {
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var stopButton: some View {
|
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 {
|
Button {
|
||||||
tracker.stop()
|
tracker.stop()
|
||||||
showFinishSheet = true
|
showFinishSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "stop.circle.fill")
|
Image(systemName: "stop.circle.fill")
|
||||||
Text("Aufnahme stoppen").bold()
|
Text("Stoppen").bold()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 56)
|
.frame(maxWidth: .infinity, minHeight: 56)
|
||||||
}
|
}
|
||||||
.background(.red, in: Capsule())
|
.background(.red, in: Capsule())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Start screen
|
// MARK: - Start screen
|
||||||
|
|
||||||
|
|
@ -125,7 +212,7 @@ struct TrackingView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
tracker.startOrRequest()
|
startFresh()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "play.fill")
|
Image(systemName: "play.fill")
|
||||||
|
|
@ -154,13 +241,79 @@ struct TrackingView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Walk lifecycle
|
||||||
|
|
||||||
private var durationSeconds: Int {
|
private func startFresh() {
|
||||||
guard let startedAt = tracker.startedAt else { return 0 }
|
// Discard any existing stale ActiveWalk before starting a new one
|
||||||
return Int(now.timeIntervalSince(startedAt))
|
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 {
|
private func formatDuration(_ seconds: Int) -> String {
|
||||||
let h = seconds / 3600
|
let h = seconds / 3600
|
||||||
let m = (seconds % 3600) / 60
|
let m = (seconds % 3600) / 60
|
||||||
|
|
@ -169,8 +322,16 @@ struct TrackingView: View {
|
||||||
return String(format: "%d:%02d", m, s)
|
return String(format: "%d:%02d", m, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetTracker() {
|
private func formatTime(_ date: Date) -> String {
|
||||||
// Fresh tracker for the next walk; old `points` arrays are released with it.
|
let f = DateFormatter()
|
||||||
tracker = LocationTracker()
|
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)))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
icon/icon-full.png
Normal file
BIN
icon/icon-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Loading…
Add table
Add a link
Reference in a new issue