Files
Vorrania/ios/Sources/ProductPhotoView.swift
Scarriffle c67998d78b iOS: Foto schon beim Anlegen eines Produkts hinterlegen
Anlege-Formular bekommt eine Foto-Sektion (Kamera/Galerie) mit Vorschau. Das
Bild wird nach dem Anlegen hochgeladen (dann gibt es eine Produkt-ID). Die
JPEG-Aufbereitung ist jetzt eine freie Funktion (productImageJPEG), von der
Detail- und Anlege-Ansicht gemeinsam genutzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:32:49 +02:00

281 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),
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 = 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
}
}
}