diff --git a/BanYaroGo/API/APIClient.swift b/BanYaroGo/API/APIClient.swift index ecc5995..434ee6a 100644 --- a/BanYaroGo/API/APIClient.swift +++ b/BanYaroGo/API/APIClient.swift @@ -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(method: String, path: String, body: Data?) async throws -> T { let url = baseURL.appending(path: path) var req = URLRequest(url: url) diff --git a/BanYaroGo/Support/ImageResize.swift b/BanYaroGo/Support/ImageResize.swift new file mode 100644 index 0000000..121373d --- /dev/null +++ b/BanYaroGo/Support/ImageResize.swift @@ -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 + } +} diff --git a/BanYaroGo/Views/FinishWalkSheet.swift b/BanYaroGo/Views/FinishWalkSheet.swift index 8ee069f..92d5e53 100644 --- a/BanYaroGo/Views/FinishWalkSheet.swift +++ b/BanYaroGo/Views/FinishWalkSheet.swift @@ -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 = [] @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() } }