banyaro-ios/BanYaroGo/Views/FinishWalkSheet.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

293 lines
9.6 KiB
Swift

import SwiftUI
import PhotosUI
struct FinishWalkSheet: View {
let points: [GPSPoint]
let durationSeconds: Int
let distanceMeters: Double
let initialPhotos: [Data]
let onDiscard: () -> Void
let onSaved: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var selectedDogIds: Set<Int> = []
@State private var dogs: [Dog] = []
@State private var isLoadingDogs = false
@State private var photoSelection: [PhotosPickerItem] = []
@State private var photoData: [Data] = []
@State private var loadingPhotos = false
@State private var saveState: SaveState = .idle
@State private var errorMessage: String?
private enum SaveState: Equatable {
case idle
case savingRoute
case uploadingPhotos(done: Int, total: Int)
}
private let shortDistanceThreshold: Double = 50 // meters
init(
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
let formatter = DateFormatter()
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 {
NavigationStack {
Form {
if distanceMeters < shortDistanceThreshold {
shortDistanceWarning
}
Section("Stats") {
LabeledContent("Distanz", value: String(format: "%.2f km", distanceMeters / 1000))
LabeledContent("Dauer", value: durationLabel)
LabeledContent("Punkte", value: "\(points.count)")
}
Section("Name") {
TextField("Name der Tour", text: $name)
}
Section("Hunde") {
if isLoadingDogs && dogs.isEmpty {
HStack { ProgressView(); Text("Lade Hunde…") }
} else if dogs.isEmpty {
Text("Keine Hunde gefunden. Wird gespeichert ohne Hund-Zuordnung.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(dogs) { dog in
dogRow(dog)
}
}
}
Section {
PhotosPicker(
selection: $photoSelection,
maxSelectionCount: 10,
matching: .images
) {
Label(
photoData.isEmpty ? "Fotos hinzufügen" : "Fotos ändern",
systemImage: "photo.badge.plus"
)
}
if loadingPhotos {
HStack { ProgressView(); Text("Lade Fotos…").font(.caption).foregroundStyle(.secondary) }
}
if !photoData.isEmpty {
photoStrip
}
} header: {
Text(photoData.isEmpty ? "Fotos" : "Fotos (\(photoData.count))")
}
if let errorMessage {
Section {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
}
.navigationTitle("Tour speichern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Verwerfen", role: .destructive) {
onDiscard()
dismiss()
}
.disabled(saveState != .idle)
}
ToolbarItem(placement: .confirmationAction) {
saveToolbarItem
}
}
.task { await loadDogs() }
.onChange(of: photoSelection) { _, newItems in
Task { await loadPhotos(from: newItems) }
}
.interactiveDismissDisabled(saveState != .idle)
}
}
@ViewBuilder
private var saveToolbarItem: some View {
switch saveState {
case .idle:
Button("Speichern") {
Task { await save() }
}
.disabled(canSave == false)
case .savingRoute:
ProgressView()
case .uploadingPhotos(let done, let total):
Text("\(done)/\(total)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
private var shortDistanceWarning: some View {
Section {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Sehr kurze Tour (\(Int(distanceMeters)) m). Du kannst trotzdem speichern.")
.font(.footnote)
}
}
}
private var photoStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(photoData.enumerated()), id: \.offset) { _, data in
if let img = UIImage(data: data) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
private func dogRow(_ dog: Dog) -> some View {
let selected = selectedDogIds.contains(dog.id)
return HStack {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected ? Color.accentColor : .secondary)
VStack(alignment: .leading) {
Text(dog.name)
if let rasse = dog.rasse, !rasse.isEmpty {
Text(rasse).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
if selected {
selectedDogIds.remove(dog.id)
} else {
selectedDogIds.insert(dog.id)
}
}
}
private var durationLabel: String {
let mins = durationSeconds / 60
let secs = durationSeconds % 60
if mins >= 60 {
return "\(mins / 60) h \(mins % 60) min"
}
return "\(mins) min \(secs) s"
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& points.count >= 2
&& saveState == .idle
}
private func loadDogs() async {
isLoadingDogs = true
defer { isLoadingDogs = false }
do {
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
self.dogs = fetched
if fetched.count == 1 {
selectedDogIds = [fetched[0].id]
}
} catch {
print("FinishWalkSheet loadDogs failed: \(error)")
}
}
private func loadPhotos(from items: [PhotosPickerItem]) async {
loadingPhotos = true
defer { loadingPhotos = false }
var loaded: [Data] = []
for item in items {
if let data = try? await item.loadTransferable(type: Data.self) {
loaded.append(data)
}
}
photoData = loaded
}
private func save() async {
errorMessage = nil
saveState = .savingRoute
let body = RouteCreateBody(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
gpsTrack: points,
distanzKm: distanceMeters / 1000,
dauerMin: max(1, durationSeconds / 60),
dogIds: Array(selectedDogIds),
isPublic: false
)
let route: RouteDetail
do {
route = try await APIClient.shared.post("/api/routes", body: body)
} catch {
errorMessage = error.localizedDescription
saveState = .idle
return
}
if !photoData.isEmpty {
for (index, raw) in photoData.enumerated() {
saveState = .uploadingPhotos(done: index, total: photoData.count)
let resized = ImageResize.resizedJPEG(from: raw)
do {
try await APIClient.shared.uploadFile(
"/api/routes/\(route.id)/photo",
filename: "photo_\(index + 1).jpg",
data: resized
)
} catch {
errorMessage = "Tour ist gespeichert, aber Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
saveState = .idle
onSaved()
return
}
}
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
}
onSaved()
dismiss()
}
}