import SwiftUI import PhotosUI /// Zwischenspeicher für Artikelbilder – im Speicher **und auf der Platte**, damit /// die Bilder einen App-Neustart überleben und in Listen sofort da sind. Zu jedem /// Bild wird die Version (`image_version` des Produkts) gemerkt; so lädt `preload` /// nur fehlende oder geänderte Bilder neu. „Kein Bild" wird ebenfalls gemerkt. final class ProductImageCache { static let shared = ProductImageCache() private struct Index: Codable { var versions: [Int: Int] = [:]; var empty: [Int] = [] } private let memory = NSCache() private let lock = NSLock() private let dir: URL private var index = Index() private init() { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] dir = base.appendingPathComponent("product-images", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) if let data = try? Data(contentsOf: indexURL), let decoded = try? JSONDecoder().decode(Index.self, from: data) { index = decoded } } private var indexURL: URL { dir.appendingPathComponent("index.json") } private func fileURL(_ id: Int) -> URL { dir.appendingPathComponent("\(id).img") } private func saveIndex() { if let data = try? JSONEncoder().encode(index) { try? data.write(to: indexURL, options: .atomic) } } /// Bild aus dem Speicher, sonst von der Platte (überlebt Neustart). func image(for id: Int) -> UIImage? { if let ui = memory.object(forKey: NSNumber(value: id)) { return ui } guard let data = try? Data(contentsOf: fileURL(id)), let ui = UIImage(data: data) else { return nil } memory.setObject(ui, forKey: NSNumber(value: id)) return ui } func isKnownEmpty(_ id: Int) -> Bool { lock.lock(); defer { lock.unlock() } return index.empty.contains(id) } /// Bild speichern. `version` = `image_version` des Produkts (nil, wenn unbekannt, /// z.B. beim Lazy-Nachladen einzelner Thumbnails). func store(_ image: UIImage, data: Data, version: Int?, for id: Int) { memory.setObject(image, forKey: NSNumber(value: id)) try? data.write(to: fileURL(id), options: .atomic) lock.lock() index.empty.removeAll { $0 == id } if let version { index.versions[id] = version } saveIndex() lock.unlock() } func markEmpty(_ id: Int) { memory.removeObject(forKey: NSNumber(value: id)) try? FileManager.default.removeItem(at: fileURL(id)) lock.lock() index.versions[id] = nil if !index.empty.contains(id) { index.empty.append(id) } saveIndex() lock.unlock() } private func isFresh(_ id: Int, _ serverVersion: Int) -> Bool { lock.lock(); let known = index.versions[id]; lock.unlock() return known == serverVersion && FileManager.default.fileExists(atPath: fileURL(id).path) } /// Alle Bilder einer Produktliste sicherstellen – nur fehlende/geänderte werden geladen. /// Beim Öffnen der Produkt-/Einzelstück-Liste aufrufen. func preload(_ products: [Product]) async { for p in products { guard let sv = p.imageVersion else { markEmpty(p.id); continue } if isFresh(p.id, sv) { continue } if let data = try? await APIClient.shared.productImage(id: p.id), let ui = UIImage(data: data) { store(ui, data: data, version: sv, for: p.id) } else { markEmpty(p.id) } } } } /// 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, data: data, version: nil, 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, data: imageData, version: nil, for: productId) } else { ProductImageCache.shared.markEmpty(productId) } loaded = true } private func upload(_ image: UIImage) async { guard let data = productImageJPEG(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, data: data, version: nil, 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. /// Frei (nicht an eine View gebunden), damit auch das Anlege-Formular es nutzt. func productImageJPEG(_ 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 } } }