diff --git a/BanYaroGo-Info.plist b/BanYaroGo-Info.plist index 5fc0d71..c03d2c8 100644 --- a/BanYaroGo-Info.plist +++ b/BanYaroGo-Info.plist @@ -48,6 +48,8 @@ Wir brauchen deinen Standort, um deine Gassi-Tour als Karte aufzuzeichnen. NSLocationAlwaysAndWhenInUseUsageDescription Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund. + NSCameraUsageDescription + Für Fotos während deiner Gassi-Tour brauchen wir Zugriff auf die Kamera. UIBackgroundModes location diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift index 7423f0c..f4a9338 100644 --- a/BanYaroGo/BanYaroGoApp.swift +++ b/BanYaroGo/BanYaroGoApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData @main struct BanYaroGoApp: App { @@ -9,5 +10,6 @@ struct BanYaroGoApp: App { RootView() .environment(auth) } + .modelContainer(for: ActiveWalk.self) } } diff --git a/BanYaroGo/Tracking/ActiveWalk.swift b/BanYaroGo/Tracking/ActiveWalk.swift new file mode 100644 index 0000000..49fc78e --- /dev/null +++ b/BanYaroGo/Tracking/ActiveWalk.swift @@ -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 } +} diff --git a/BanYaroGo/Tracking/LocationTracker.swift b/BanYaroGo/Tracking/LocationTracker.swift index d19af16..b9f8407 100644 --- a/BanYaroGo/Tracking/LocationTracker.swift +++ b/BanYaroGo/Tracking/LocationTracker.swift @@ -3,11 +3,6 @@ import Observation import CoreLocation /// 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 @MainActor final class LocationTracker: NSObject, CLLocationManagerDelegate { @@ -15,12 +10,13 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate { var points: [GPSPoint] = [] var isTracking: Bool = false + var isPaused: Bool = false var startedAt: Date? + var pausedAt: Date? + var accumulatedPausedSeconds: Int = 0 var authorizationStatus: CLAuthorizationStatus 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 override init() { @@ -28,12 +24,12 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation - manager.distanceFilter = 5 // meters + manager.distanceFilter = 5 manager.activityType = .fitness manager.pausesLocationUpdatesAutomatically = false } - // MARK: - Public API + // MARK: - Lifecycle func startOrRequest() { permissionDenied = false @@ -44,19 +40,79 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate { case .denied, .restricted: permissionDenied = true case .authorizedWhenInUse, .authorizedAlways: - beginTracking() + beginFresh() @unknown default: 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() { + if isPaused, let pausedAt { + accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt)) + } + isPaused = false + pausedAt = nil manager.stopUpdatingLocation() manager.allowsBackgroundLocationUpdates = 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 { guard points.count >= 2 else { return 0 } var total: Double = 0 @@ -70,21 +126,25 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate { // MARK: - Internal - private func beginTracking() { + private func beginFresh() { points = [] startedAt = .now - manager.allowsBackgroundLocationUpdates = true - manager.showsBackgroundLocationIndicator = true - manager.startUpdatingLocation() + pausedAt = nil + accumulatedPausedSeconds = 0 + isPaused = false isTracking = true - - // If we only have "when in use", silently try to escalate to "always". - // System will show the secondary prompt on its own schedule. + beginUpdates() if authorizationStatus == .authorizedWhenInUse { manager.requestAlwaysAuthorization() } } + private func beginUpdates() { + manager.allowsBackgroundLocationUpdates = true + manager.showsBackgroundLocationIndicator = true + manager.startUpdatingLocation() + } + // MARK: - CLLocationManagerDelegate 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) } guard !newPoints.isEmpty else { return } Task { @MainActor in + guard self.isTracking, !self.isPaused else { return } self.points.append(contentsOf: newPoints) } } @@ -111,7 +172,7 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate { case .authorizedWhenInUse, .authorizedAlways: if self.pendingStart { self.pendingStart = false - self.beginTracking() + self.beginFresh() } default: break @@ -120,8 +181,6 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate { } 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)") } } diff --git a/BanYaroGo/Views/CameraPicker.swift b/BanYaroGo/Views/CameraPicker.swift new file mode 100644 index 0000000..99e6f29 --- /dev/null +++ b/BanYaroGo/Views/CameraPicker.swift @@ -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() + } + } +} diff --git a/BanYaroGo/Views/FinishWalkSheet.swift b/BanYaroGo/Views/FinishWalkSheet.swift index 92d5e53..a67ed3f 100644 --- a/BanYaroGo/Views/FinishWalkSheet.swift +++ b/BanYaroGo/Views/FinishWalkSheet.swift @@ -5,6 +5,7 @@ struct FinishWalkSheet: View { let points: [GPSPoint] let durationSeconds: Int let distanceMeters: Double + let initialPhotos: [Data] let onDiscard: () -> Void let onSaved: () -> Void @@ -34,12 +35,14 @@ struct FinishWalkSheet: View { points: [GPSPoint], durationSeconds: Int, distanceMeters: Double, + initialPhotos: [Data] = [], onDiscard: @escaping () -> Void, onSaved: @escaping () -> Void ) { self.points = points self.durationSeconds = durationSeconds self.distanceMeters = distanceMeters + self.initialPhotos = initialPhotos self.onDiscard = onDiscard self.onSaved = onSaved @@ -47,6 +50,7 @@ struct FinishWalkSheet: View { formatter.locale = Locale(identifier: "de_DE") formatter.dateStyle = .medium _name = State(initialValue: "Gassi am \(formatter.string(from: .now))") + _photoData = State(initialValue: initialPhotos) } var body: some View { diff --git a/BanYaroGo/Views/RouteDetailView.swift b/BanYaroGo/Views/RouteDetailView.swift index 12a9f54..b33b1a2 100644 --- a/BanYaroGo/Views/RouteDetailView.swift +++ b/BanYaroGo/Views/RouteDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import PhotosUI struct RouteDetailView: View { let routeId: Int @@ -8,6 +9,11 @@ struct RouteDetailView: View { @State private var isLoading = false @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 { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -30,19 +36,8 @@ struct RouteDetailView: View { .padding(.horizontal) } - if let urls = detail.fotoUrls, !urls.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Fotos").font(.headline) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(urls, id: \.self) { path in - photoThumb(path) - } - } - } - } + photosSection(for: detail) .padding(.horizontal) - } Spacer(minLength: 24) } else if isLoading { @@ -60,6 +55,60 @@ struct RouteDetailView: View { .navigationTitle(detail?.name ?? fallbackName) .navigationBarTitleDisplayMode(.inline) .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 { @@ -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 { guard let km else { return "—" } return String(format: "%.2f km", km) diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift index 46c0bb3..e764a71 100644 --- a/BanYaroGo/Views/TrackingView.swift +++ b/BanYaroGo/Views/TrackingView.swift @@ -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)))" } } diff --git a/icon/icon-full.png b/icon/icon-full.png new file mode 100644 index 0000000..cc36708 Binary files /dev/null and b/icon/icon-full.png differ