ProductImageCache haelt Bilder jetzt zusaetzlich auf der Platte (Caches-Verzeichnis) mit gemerkter image_version - so ueberleben Thumbnails den App-Neustart und sind in Listen sofort da, statt bei drei Vierteln zu fehlen. Beim Laden der Produktliste werden alle Bilder im Hintergrund sichergestellt; per image_version (aus einem Listen-Request) werden nur fehlende oder geaenderte Bilder geladen. Product-Model um imageVersion ergaenzt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
279 lines
11 KiB
Swift
279 lines
11 KiB
Swift
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<NSNumber, UIImage>()
|
||
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 = 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, 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.
|
||
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
|
||
}
|
||
}
|
||
}
|