import SwiftUI import PhotosUI /// Gemeinsamer Zwischenspeicher für Artikelbilder, damit Listen beim Scrollen /// nicht dieselben Bilder immer wieder laden. „Kein Bild" wird ebenfalls /// gemerkt, damit erfolglose Abfragen sich nicht wiederholen. final class ProductImageCache { static let shared = ProductImageCache() private let cache = NSCache() private var empty: Set = [] private let lock = NSLock() func image(for id: Int) -> UIImage? { cache.object(forKey: NSNumber(value: id)) } func isKnownEmpty(_ id: Int) -> Bool { lock.lock(); defer { lock.unlock() } return empty.contains(id) } func store(_ image: UIImage, for id: Int) { cache.setObject(image, forKey: NSNumber(value: id)) lock.lock(); empty.remove(id); lock.unlock() } func markEmpty(_ id: Int) { cache.removeObject(forKey: NSNumber(value: id)) lock.lock(); empty.insert(id); lock.unlock() } } /// Kleines Vorschaubild für Listen: zeigt das Artikelbild, sonst das neutrale /// Kästchen-Symbol wie bisher. Ergebnisse kommen aus dem gemeinsamen Cache. struct ProductThumb: View { let productId: Int @State private var image: UIImage? var body: some View { content .frame(width: 34, height: 34) .task(id: productId) { await load() } } @ViewBuilder private var content: some View { if let image { Image(uiImage: image) .resizable().scaledToFill() .frame(width: 34, height: 34) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { Image(systemName: "shippingbox") .font(.body).foregroundStyle(.secondary) } } private func load() async { if let cached = ProductImageCache.shared.image(for: productId) { image = cached return } if ProductImageCache.shared.isKnownEmpty(productId) { return } guard let data = try? await APIClient.shared.productImage(id: productId), let ui = UIImage(data: data) else { ProductImageCache.shared.markEmpty(productId) return } ProductImageCache.shared.store(ui, for: productId) image = ui } } /// 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) // Listen-Cache mit der Wahrheit abgleichen. if let imageData, let ui = UIImage(data: imageData) { ProductImageCache.shared.store(ui, for: productId) } else { ProductImageCache.shared.markEmpty(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 if let ui = UIImage(data: data) { ProductImageCache.shared.store(ui, for: productId) } } 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 ProductImageCache.shared.markEmpty(productId) } 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 } } }