Files
Vorrania/ios/Sources/ProductPhotoView.swift
T
ScarriffleandClaude Opus 5 ba84e4ea01 Ort zuordnen: Karte zeigt, was man da in der Hand haelt
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>
2026-09-12 14:05:47 +02:00

285 lines
11 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
/// 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
}
}
}