Inline-Anlage (Kategorien/Felder), Artikelfoto und CSV-Spalte "art"
- Web: Kategorien/Unterkategorien und eigene Felder direkt im Artikelformular
anlegen (ohne Umweg über die Kategorien-Seite).
- Artikelfoto per Kamera oder Galerie hochladen – Backend-Endpunkt
(PUT/DELETE /products/{id}/image), Web (Foto machen / Galerie) und iOS
(Kamera + PhotosPicker, inline in der Produktansicht angezeigt).
- CSV-Import: neue Spalte "art" (Gegenstand/Lebensmittel) steuert den Modus
automatisch angelegter Kategorien; Export schreibt sie mit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
139
ios/Sources/ProductPhotoView.swift
Normal file
139
ios/Sources/ProductPhotoView.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
/// 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) }
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker { 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)
|
||||
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
|
||||
} 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
|
||||
} 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 {
|
||||
var onImage: (UIImage) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
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) }
|
||||
|
||||
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.dismiss()
|
||||
}
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
parent.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user