Files
Vorrania/ios/Sources/ProductPhotoView.swift
Scarriffle 5c8b46a0d5 Foto-Upload: 413 beheben und Kamera stabilisieren
- nginx client_max_body_size auf 12m (Standard 1 MB loeste beim Bild-Upload
  einen 413 aus, bevor das Backend den Upload sah).
- Backend-Bildlimit auf 8 MB angehoben, bleibt unter dem nginx-Limit, damit
  Uebergroesse eine klare 400-Meldung statt eines nackten 413 ergibt.
- Web: Foto vor dem Upload clientseitig verkleinern (max 1600px, JPEG) -
  schneller, kleiner in der DB, zuverlaessig unter dem Limit.
- iOS: Kamera ueber fullScreenCover statt sheet und Schliessen deterministisch
  ueber ein Binding - behebt das "Kamera geht direkt wieder zu"-Flackern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:11:59 +02:00

143 lines
5.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import PhotosUI
/// Artikelfoto ansehen, per Kamera aufnehmen oder aus der Galerie wählen und
/// hochladen. Wird als Abschnitt in die Produkt-Detailansicht eingebettet.
struct ProductPhotoSection: View {
let productId: Int
@State private var imageData: Data?
@State private var loaded = false
@State private var busy = false
@State private var error: String?
@State private var showCamera = false
@State private var pickerItem: PhotosPickerItem?
var body: some View {
Section("Foto") {
if let imageData, let ui = UIImage(data: imageData) {
Image(uiImage: ui)
.resizable().scaledToFit()
.frame(maxWidth: .infinity)
.frame(maxHeight: 240)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else if loaded {
Text("Noch kein Foto.").foregroundStyle(.secondary)
}
Button { showCamera = true } label: {
Label("Foto machen", systemImage: "camera")
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Aus Galerie", systemImage: "photo.on.rectangle")
}
if imageData != nil {
Button(role: .destructive) { Task { await removePhoto() } } label: {
Label("Foto entfernen", systemImage: "trash")
}
}
if busy { ProgressView() }
if let error { Text(error).foregroundStyle(.red).font(.callout) }
}
.fullScreenCover(isPresented: $showCamera) {
CameraPicker(isPresented: $showCamera) { image in Task { await upload(image) } }
.ignoresSafeArea()
}
.onChange(of: pickerItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let ui = UIImage(data: data) {
await upload(ui)
}
pickerItem = nil
}
}
.task { await load() }
}
private func load() async {
imageData = try? await APIClient.shared.productImage(id: productId)
loaded = true
}
private func upload(_ image: UIImage) async {
guard let data = Self.prepared(image) else { return }
busy = true
defer { busy = false }
error = nil
do {
_ = try await APIClient.shared.uploadProductImage(
id: productId, data: data, contentType: "image/jpeg")
imageData = data
} catch {
self.error = error.localizedDescription
}
}
private func removePhoto() async {
busy = true
defer { busy = false }
error = nil
do {
try await APIClient.shared.deleteProductImage(id: productId)
imageData = nil
} catch {
self.error = error.localizedDescription
}
}
/// Foto verkleinern und als JPEG unter die Server-Grenze (2 MB) drücken.
private static func prepared(_ image: UIImage, maxDim: CGFloat = 1600) -> Data? {
let size = image.size
let scale = min(1, maxDim / max(size.width, size.height))
let target = CGSize(width: size.width * scale, height: size.height * scale)
let scaled = UIGraphicsImageRenderer(size: target).image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
var quality: CGFloat = 0.8
var data = scaled.jpegData(compressionQuality: quality)
while let d = data, d.count > 1_800_000, quality > 0.3 {
quality -= 0.15
data = scaled.jpegData(compressionQuality: quality)
}
return data
}
}
/// Kamera über UIImagePickerController SwiftUI hat keine eigene Kamera-Ansicht.
/// Ohne Kamera (Simulator) fällt es auf die Fotoauswahl zurück.
struct CameraPicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
var onImage: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
// Schließen deterministisch über das Binding statt über die Umgebung
// das behebt das Flackern (Kamera geht direkt wieder zu), wenn die Ansicht
// während der Präsentation neu ausgewertet wird.
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: CameraPicker
init(_ parent: CameraPicker) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage { parent.onImage(image) }
parent.isPresented = false
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
}
}