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

@ -31,6 +31,43 @@ final class APIClient {
try await perform(method: "POST", path: path, body: nil)
}
/// Multipart-File-Upload. Server-Endpunkte wie POST /api/routes/{id}/photo
/// erwarten Feld `file` mit JPEG-Bytes.
@discardableResult
func uploadFile(
_ path: String,
fieldName: String = "file",
filename: String,
data: Data,
mimeType: String = "image/jpeg"
) async throws -> Data {
let boundary = "Boundary-\(UUID().uuidString)"
let url = baseURL.appending(path: path)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Accept")
if let token {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
let (responseData, response) = try await session.upload(for: req, from: body)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.server(status: http.statusCode, message: Self.parseErrorDetail(from: responseData))
}
return responseData
}
private func perform<T: Decodable>(method: String, path: String, body: Data?) async throws -> T {
let url = baseURL.appending(path: path)
var req = URLRequest(url: url)

View file

@ -0,0 +1,31 @@
import UIKit
enum ImageResize {
/// Resizes a JPEG/PNG so its longest edge is at most `maxDimension`,
/// then re-encodes as JPEG with the given quality. Returns the original
/// data if it can't be decoded.
static func resizedJPEG(
from data: Data,
maxDimension: CGFloat = 2048,
quality: CGFloat = 0.8
) -> Data {
guard let image = UIImage(data: data) else { return data }
let longest = max(image.size.width, image.size.height)
guard longest > maxDimension else {
// already small enough just re-encode to JPEG
return image.jpegData(compressionQuality: quality) ?? data
}
let scale = maxDimension / longest
let target = CGSize(width: image.size.width * scale, height: image.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: target, format: {
let f = UIGraphicsImageRendererFormat.default()
f.scale = 1
f.opaque = true
return f
}())
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
return resized.jpegData(compressionQuality: quality) ?? data
}
}

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()
}
}