Phase 3: Foto-Upload + Mindeststrecken-Warnung

- APIClient.uploadFile: multipart POST mit Bearer-Token, generischer
  field/filename/mime
- ImageResize: längste Kante max 2048px, JPEG q=0.8 — iPhone-Fotos sonst
  5-10MB pro Stück
- FinishWalkSheet:
  - PhotosPicker (iOS 16+, kein NSPhotoLibraryUsageDescription nötig)
  - Thumbnail-Strip der gewählten Fotos
  - Sequentieller Upload nach POST /api/routes, Toolbar zeigt "N/M"
  - Bei < 50m: orangene Warnung "Sehr kurze Tour — du kannst trotzdem speichern"
  - Save-Button blockt korrekt während Upload, Verwerfen auch
This commit is contained in:
rene 2026-05-30 10:18:08 +02:00
parent 0b95e3e6d1
commit e27fa39620
3 changed files with 203 additions and 18 deletions

View file

@ -1,4 +1,5 @@
import SwiftUI
import PhotosUI
struct FinishWalkSheet: View {
let points: [GPSPoint]
@ -13,9 +14,22 @@ struct FinishWalkSheet: View {
@State private var selectedDogIds: Set<Int> = []
@State private var dogs: [Dog] = []
@State private var isLoadingDogs = false
@State private var isSaving = 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,
@ -38,6 +52,10 @@ struct FinishWalkSheet: View {
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)
@ -62,6 +80,29 @@ struct FinishWalkSheet: View {
}
}
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)
@ -78,20 +119,61 @@ struct FinishWalkSheet: View {
onDiscard()
dismiss()
}
.disabled(saveState != .idle)
}
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView()
} else {
Button("Speichern") {
Task { await save() }
}
.disabled(canSave == false)
}
saveToolbarItem
}
}
.task { await loadDogs() }
.interactiveDismissDisabled(isSaving)
.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))
}
}
}
}
}
@ -130,7 +212,7 @@ struct FinishWalkSheet: View {
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& points.count >= 2
&& !isSaving
&& saveState == .idle
}
private func loadDogs() async {
@ -139,20 +221,30 @@ struct FinishWalkSheet: View {
do {
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
self.dogs = fetched
// If there's exactly one dog, pre-select it saves a tap.
if fetched.count == 1 {
selectedDogIds = [fetched[0].id]
}
} catch {
// Fall back gracefully user can still save without dogs.
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 {
isSaving = true
errorMessage = nil
defer { isSaving = false }
saveState = .savingRoute
let body = RouteCreateBody(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
gpsTrack: points,
@ -161,12 +253,37 @@ struct FinishWalkSheet: View {
dogIds: Array(selectedDogIds),
isPublic: false
)
let route: RouteDetail
do {
let _: RouteDetail = try await APIClient.shared.post("/api/routes", body: body)
onSaved()
dismiss()
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()
}
}