diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index b2fbd64..c6bf8f6 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -298,8 +298,7 @@ struct ProductRow: View { var body: some View { HStack(spacing: 12) { - Image(systemName: "shippingbox") - .font(.body).foregroundStyle(.secondary).frame(width: 24) + ProductThumb(productId: product.id) VStack(alignment: .leading, spacing: 2) { Text(product.name) Text(bestand) diff --git a/ios/Sources/ProductPhotoView.swift b/ios/Sources/ProductPhotoView.swift index 63c1231..60ff647 100644 --- a/ios/Sources/ProductPhotoView.swift +++ b/ios/Sources/ProductPhotoView.swift @@ -1,6 +1,73 @@ 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 { @@ -58,6 +125,12 @@ struct ProductPhotoSection: View { 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 } @@ -70,6 +143,7 @@ struct ProductPhotoSection: View { _ = 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 } @@ -82,6 +156,7 @@ struct ProductPhotoSection: View { do { try await APIClient.shared.deleteProductImage(id: productId) imageData = nil + ProductImageCache.shared.markEmpty(productId) } catch { self.error = error.localizedDescription }