From 89d8ba4effd762eeb2e4a1b0bfc6e4aba9206df2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 14:42:29 +0200 Subject: [PATCH] iOS: dauerhafter Bild-Cache (Platte) + Vorladen, nur geaenderte Bilder neu 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 --- ios/Sources/ListViews.swift | 6 +- ios/Sources/Models.swift | 3 + ios/Sources/ProductPhotoView.swift | 93 +++++++++++++++++++++++++----- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 98d9114..179903c 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -308,7 +308,11 @@ struct ProductListView: View { private func load() async { busy = true defer { busy = false } - products = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? [] + let geladen = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? [] + products = geladen + // Bilder im Hintergrund sicherstellen – nur fehlende/geänderte werden geholt, + // damit die Thumbnails auch nach App-Neustart sofort aus dem Cache kommen. + Task { await ProductImageCache.shared.preload(geladen) } } } diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 5ca353a..bdf4839 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -54,6 +54,8 @@ struct Product: Codable, Identifiable, Hashable { let minStock: Double? let stock: Double let expiredCount: Int + /// Epoch der letzten Bildänderung (nil = kein Bild) – steuert den Bild-Cache. + let imageVersion: Int? let kind: String let unitName: String let unitFactor: Double @@ -94,6 +96,7 @@ struct Product: Codable, Identifiable, Hashable { case groupId = "group_id" case minStock = "min_stock" case expiredCount = "expired_count" + case imageVersion = "image_version" case unitName = "unit_name" case unitFactor = "unit_factor" case shopId = "shop_id" diff --git a/ios/Sources/ProductPhotoView.swift b/ios/Sources/ProductPhotoView.swift index 60ff647..822eb92 100644 --- a/ios/Sources/ProductPhotoView.swift +++ b/ios/Sources/ProductPhotoView.swift @@ -1,30 +1,91 @@ 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. +/// 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 let cache = NSCache() - private var empty: Set = [] - private let lock = NSLock() - func image(for id: Int) -> UIImage? { cache.object(forKey: NSNumber(value: id)) } + 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 empty.contains(id) + return index.empty.contains(id) } - func store(_ image: UIImage, for id: Int) { - cache.setObject(image, forKey: NSNumber(value: id)) - lock.lock(); empty.remove(id); lock.unlock() + /// 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) { - cache.removeObject(forKey: NSNumber(value: id)) - lock.lock(); empty.insert(id); lock.unlock() + 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) + } + } } } @@ -63,7 +124,7 @@ struct ProductThumb: View { ProductImageCache.shared.markEmpty(productId) return } - ProductImageCache.shared.store(ui, for: productId) + ProductImageCache.shared.store(ui, data: data, version: nil, for: productId) image = ui } } @@ -127,7 +188,7 @@ struct ProductPhotoSection: View { 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) + ProductImageCache.shared.store(ui, data: imageData, version: nil, for: productId) } else { ProductImageCache.shared.markEmpty(productId) } @@ -143,7 +204,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) } + if let ui = UIImage(data: data) { ProductImageCache.shared.store(ui, data: data, version: nil, for: productId) } } catch { self.error = error.localizedDescription }