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,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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue