Files
Vorrania/ios/Sources/ProductPhotoView.swift
Scarriffle 3f47e273ad iOS: Produktliste zeigt Artikelbild statt Kästchen-Symbol
Hat ein Artikel ein Bild, zeigt die Produktliste jetzt ein Vorschaubild statt
des neutralen shippingbox-Symbols; ohne Bild bleibt das Symbol. Ein gemeinsamer
ProductImageCache verhindert, dass beim Scrollen dieselben Bilder immer wieder
geladen werden, und merkt sich auch "kein Bild". ProductPhotoSection haelt den
Cache bei Laden/Upload/Entfernen synchron.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 14:40:51 +02:00

218 lines
8.0 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
/// 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<NSNumber, UIImage>()
private var empty: Set<Int> = []
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
}
}
}