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:
parent
0b95e3e6d1
commit
e27fa39620
3 changed files with 203 additions and 18 deletions
|
|
@ -31,6 +31,43 @@ final class APIClient {
|
||||||
try await perform(method: "POST", path: path, body: nil)
|
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 {
|
private func perform<T: Decodable>(method: String, path: String, body: Data?) async throws -> T {
|
||||||
let url = baseURL.appending(path: path)
|
let url = baseURL.appending(path: path)
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
|
|
|
||||||
31
BanYaroGo/Support/ImageResize.swift
Normal file
31
BanYaroGo/Support/ImageResize.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
struct FinishWalkSheet: View {
|
struct FinishWalkSheet: View {
|
||||||
let points: [GPSPoint]
|
let points: [GPSPoint]
|
||||||
|
|
@ -13,9 +14,22 @@ struct FinishWalkSheet: View {
|
||||||
@State private var selectedDogIds: Set<Int> = []
|
@State private var selectedDogIds: Set<Int> = []
|
||||||
@State private var dogs: [Dog] = []
|
@State private var dogs: [Dog] = []
|
||||||
@State private var isLoadingDogs = false
|
@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?
|
@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(
|
init(
|
||||||
points: [GPSPoint],
|
points: [GPSPoint],
|
||||||
durationSeconds: Int,
|
durationSeconds: Int,
|
||||||
|
|
@ -38,6 +52,10 @@ struct FinishWalkSheet: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
if distanceMeters < shortDistanceThreshold {
|
||||||
|
shortDistanceWarning
|
||||||
|
}
|
||||||
|
|
||||||
Section("Stats") {
|
Section("Stats") {
|
||||||
LabeledContent("Distanz", value: String(format: "%.2f km", distanceMeters / 1000))
|
LabeledContent("Distanz", value: String(format: "%.2f km", distanceMeters / 1000))
|
||||||
LabeledContent("Dauer", value: durationLabel)
|
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 {
|
if let errorMessage {
|
||||||
Section {
|
Section {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
|
|
@ -78,20 +119,61 @@ struct FinishWalkSheet: View {
|
||||||
onDiscard()
|
onDiscard()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.disabled(saveState != .idle)
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
if isSaving {
|
saveToolbarItem
|
||||||
ProgressView()
|
}
|
||||||
} else {
|
}
|
||||||
|
.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") {
|
Button("Speichern") {
|
||||||
Task { await save() }
|
Task { await save() }
|
||||||
}
|
}
|
||||||
.disabled(canSave == false)
|
.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await loadDogs() }
|
|
||||||
.interactiveDismissDisabled(isSaving)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +212,7 @@ struct FinishWalkSheet: View {
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
&& points.count >= 2
|
&& points.count >= 2
|
||||||
&& !isSaving
|
&& saveState == .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDogs() async {
|
private func loadDogs() async {
|
||||||
|
|
@ -139,20 +221,30 @@ struct FinishWalkSheet: View {
|
||||||
do {
|
do {
|
||||||
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
|
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
|
||||||
self.dogs = fetched
|
self.dogs = fetched
|
||||||
// If there's exactly one dog, pre-select it — saves a tap.
|
|
||||||
if fetched.count == 1 {
|
if fetched.count == 1 {
|
||||||
selectedDogIds = [fetched[0].id]
|
selectedDogIds = [fetched[0].id]
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back gracefully — user can still save without dogs.
|
|
||||||
print("FinishWalkSheet loadDogs failed: \(error)")
|
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 {
|
private func save() async {
|
||||||
isSaving = true
|
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
defer { isSaving = false }
|
saveState = .savingRoute
|
||||||
|
|
||||||
let body = RouteCreateBody(
|
let body = RouteCreateBody(
|
||||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
gpsTrack: points,
|
gpsTrack: points,
|
||||||
|
|
@ -161,12 +253,37 @@ struct FinishWalkSheet: View {
|
||||||
dogIds: Array(selectedDogIds),
|
dogIds: Array(selectedDogIds),
|
||||||
isPublic: false
|
isPublic: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let route: RouteDetail
|
||||||
do {
|
do {
|
||||||
let _: RouteDetail = try await APIClient.shared.post("/api/routes", body: body)
|
route = try await APIClient.shared.post("/api/routes", body: body)
|
||||||
onSaved()
|
|
||||||
dismiss()
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue