Bisher stand dort nur "Einzelstück R9D55SDC". Die UID sagt aber nichts darueber, welches Stueck das ist - gerade bei Kleidung, wo das NFC-Tag eingenaeht und unsichtbar ist. Unter dem Sucher lag dafuer die halbe Seite ungenutzt. Die Karte zeigt jetzt Vorschaubild, Artikelname, Marke und die Notiz, darunter als beschriftete Zeilen die Einzelstuecknummer und den aktuellen Lagerort. Der Ort ist beim Zuordnen die nuetzlichste Angabe ueberhaupt: man sieht sofort, ob das Stueck schon irgendwo liegt. Der Block war mit 50 Zeilen zu gross fuer den ohnehin langen body und steht jetzt als EinzelstueckKarte fuer sich. ProductThumb bekommt dafuer einen Groessenparameter (Vorgabe 34 wie bisher, also keine Aenderung an den Listen) und bei grossen Bildern einen passenderen Eckenradius und ein groesseres Ersatzsymbol. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
11 KiB
Swift
285 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, thumb: true),
|
||
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
|
||
/// Kantenlaenge. Listen lassen sie weg; wo mehr Platz ist (Ort zuordnen),
|
||
/// hilft ein groesseres Bild beim Wiedererkennen.
|
||
var groesse: CGFloat = 34
|
||
@State private var image: UIImage?
|
||
|
||
var body: some View {
|
||
content
|
||
.frame(width: groesse, height: groesse)
|
||
.task(id: productId) { await load() }
|
||
}
|
||
|
||
@ViewBuilder private var content: some View {
|
||
if let image {
|
||
Image(uiImage: image)
|
||
.resizable().scaledToFill()
|
||
.frame(width: groesse, height: groesse)
|
||
.clipShape(RoundedRectangle(cornerRadius: groesse > 48 ? 10 : 6))
|
||
} else {
|
||
Image(systemName: "shippingbox")
|
||
.font(groesse > 48 ? .largeTitle : .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, thumb: true),
|
||
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
|
||
}
|
||
}
|
||
}
|