Files
Vorrania/ios/Sources/ProductViews.swift
Scarriffle 9ca7291c68 iOS: eigene Kategorie-Felder schon beim Anlegen ausfuellen
Beim Neuanlegen eines Gegenstands (z.B. T-Shirt) werden jetzt die eigenen Felder
der gewaehlten Kategorie (Groesse, Farbe, ...) geladen und angezeigt - vorher
tauchten sie erst in der Detailansicht nach dem Anlegen auf. NewProductRequest
traegt die Werte via field_values mit.

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

332 lines
14 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
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
struct ProductSearchView: View {
var onPick: (Product) -> Void
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var results: [Product] = []
@State private var busy = false
var body: some View {
List {
ForEach(results) { product in
Button {
onPick(product)
dismiss()
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(product.name)
Text("\(formatted(product.stockInArticleUnits)) \(product.articleUnitLabel)")
.font(.caption).foregroundStyle(.secondary)
}
}
}
if results.isEmpty && !busy {
Text("Keine Treffer").foregroundStyle(.secondary)
}
}
.searchable(text: $query, prompt: "Artikel suchen")
.onSubmit(of: .search) { Task { await search() } }
.task { await search() }
.onChange(of: query) { _ in Task { await search() } }
.navigationTitle("Artikel suchen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
}
private func formatted(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
}
private func search() async {
busy = true
defer { busy = false }
results = (try? await APIClient.shared.searchProducts(query)) ?? []
}
}
/// Neues Produkt anlegen bei Bedarf mit den Daten von Open Food Facts vorbefüllt.
struct ProductFormView: View {
let prefillBarcode: String?
let groupId: Int?
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
var categoryId: Int?
var suggestion: LookupResult.Suggestion?
/// Vorbelegter Typ ("food"/"object"), z.B. aus der Lebensmittel- oder
/// Gegenstände-Liste heraus. Ein Kategorie-Vorschlag hat weiter Vorrang.
var initialType: String?
var onCreated: (Product) -> Void
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var session: Session
@State private var barcode = ""
@State private var name = ""
@State private var brand = ""
@State private var baseUnit = "piece"
@State private var packageSize = ""
@State private var packageLabel = ""
@State private var datePrecision = "day"
@State private var selectedGroupId: Int?
@State private var selectedCategoryId: Int?
// Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl.
@State private var modus = "object"
// Verwaltungsart eines Gegenstands: "count" (Menge je Lagerort),
// "individual" (Einzelstücke) oder "bulk" (Verbrauchsgegenstand wie Lebensmittel).
@State private var objMode = "count"
@State private var groups: [GroupItem] = []
@State private var categories: [CategoryItem] = []
@State private var units: [Unit] = []
@State private var newGroupShown = false
@State private var busy = false
@State private var error: String?
// Foto schon beim Anlegen: erst nach dem Anlegen hochladen (ID nötig).
@State private var pendingImage: UIImage?
@State private var showCamera = false
@State private var pickerItem: PhotosPickerItem?
// Eigene Felder der (Gegenstands-)Kategorie schon beim Anlegen ausfüllbar.
@State private var fieldDefs: [FieldDefinition] = []
@State private var fieldValues: [String: String] = [:]
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
/// Nutzt die Lebensmittel-Pfade (Einheit, MHD, Charge): Lebensmittel ODER
/// Verbrauchsgegenstand.
private var foodLike: Bool { modus == "food" || objMode == "bulk" }
/// Gruppe anbieten: Lebensmittel, Verbrauchsgegenstand oder Menge je Lagerort
/// (nicht bei Einzelstücken).
private var showGroup: Bool { modus == "food" || (modus == "object" && objMode != "individual") }
var body: some View {
Form {
Section("Artikel") {
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
LabeledField(label: "Name", text: $name)
LabeledField(label: "Marke", text: $brand)
}
Section {
Picker("Typ", selection: $modus) {
Text("Lebensmittel").tag("food")
Text("Gegenstand").tag("object")
}
.pickerStyle(.segmented)
CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus)
if modus == "object" {
Picker("Verwaltung", selection: $objMode) {
Text("Menge je Lagerort").tag("count")
Text("Einzelstücke").tag("individual")
Text("Verbrauchsgegenstand").tag("bulk")
}
}
} footer: {
Text(modus == "object"
? (objMode == "individual"
? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie."
: objMode == "bulk"
? "Verbrauchsgegenstand: wie ein Lebensmittel als Charge mit Menge und Einheit (z.B. Sonnencreme in ml) nur ohne eindeutigen Code."
: "Gegenstand: Menge je Lagerort und eigene Felder.")
: "Lebensmittel: Chargen mit Mindesthaltbarkeit.")
}
.onChange(of: modus) { neu in
// Passt die gewählte Kategorie nicht mehr zum Typ, Auswahl leeren.
if let cid = selectedCategoryId,
categories.first(where: { $0.id == cid })?.tracking != neu {
selectedCategoryId = nil
}
}
// Gruppe: Lebensmittel, Verbrauchsgegenstand und Menge je Lagerort
// (nur Einzelstücke bleiben außen vor).
if showGroup {
Section {
Picker("Gruppe", selection: $selectedGroupId) {
Text(" keine ").tag(Int?.none)
ForEach(groups) { gruppe in
Text(gruppe.name).tag(Int?.some(gruppe.id))
}
}
if session.isAdmin {
Button {
newGroupShown = true
} label: {
Label("Neue Gruppe anlegen", systemImage: "plus")
}
}
} footer: {
Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code "
+ "dieses Artikels erscheint danach automatisch bei der Gruppe.")
}
}
// Einheit + MHD nur für Lebensmittel und Verbrauchsgegenstände (Charge).
if foodLike {
Section("Einheit") {
Picker("Basiseinheit", selection: $baseUnit) {
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
}
QuantityField(label: "Packungsgröße", text: $packageSize,
suffix: DisplaySettings.baseUnitLabel(baseUnit))
Picker("Bezeichnung", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
}
}
Section("MHD") {
Picker("Angabe", selection: $datePrecision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
}
}
// Eigene Felder der Gegenstands-Kategorie (z.B. Größe, Farbe beim
// T-Shirt) schon beim Anlegen ausfüllbar, nicht erst danach.
if !fieldDefs.isEmpty {
Section("Eigene Felder") {
ForEach(fieldDefs) { def in
ObjectFieldRow(def: def, value: fieldBinding(def))
}
}
}
// Foto direkt beim Anlegen z.B. wenn Open Food/Products Facts keins
// liefert. Wird nach dem Anlegen hochgeladen (dann gibt es eine ID).
Section("Foto") {
if let pendingImage {
Image(uiImage: pendingImage)
.resizable().scaledToFit()
.frame(maxWidth: .infinity).frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button { showCamera = true } label: { Label("Foto machen", systemImage: "camera") }
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Aus Galerie", systemImage: "photo.on.rectangle")
}
if pendingImage != nil {
Button(role: .destructive) { pendingImage = nil } label: {
Label("Foto entfernen", systemImage: "trash")
}
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Button(busy ? "Anlegen…" : "Anlegen & weiter") { Task { await create() } }
.disabled(busy || name.isEmpty)
}
}
.navigationTitle("Neuer Artikel")
.navigationBarTitleDisplayMode(.inline)
.fullScreenCover(isPresented: $showCamera) {
CameraPicker(isPresented: $showCamera) { image in pendingImage = 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) {
pendingImage = ui
}
pickerItem = nil
}
}
.onChange(of: selectedCategoryId) { cid in
// Bei einer Gegenstands-Kategorie deren (vererbte) Felder nachladen.
Task {
if let cid, categories.first(where: { $0.id == cid })?.isObject == true {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} else {
fieldDefs = []
}
}
}
.onAppear(perform: prefill)
.task {
groups = (try? await APIClient.shared.groups()) ?? []
categories = (try? await APIClient.shared.categories()) ?? []
units = (try? await APIClient.shared.units()) ?? []
// Kam eine Kategorie aus dem Barcode-Vorschlag, den Typ dazu setzen.
if let cid = selectedCategoryId,
let t = categories.first(where: { $0.id == cid })?.tracking {
modus = t
}
}
.sheet(isPresented: $newGroupShown) {
NavigationStack {
NewGroupSheet(units: units) { created in
groups.append(created)
selectedGroupId = created.id
}
}
}
}
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
let key = String(def.id)
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
}
/// Werte der geltenden eigenen Felder als {feld_id: Wert} nil, wenn keine.
private func eigeneFeldwerte() -> [String: String?]? {
guard !fieldDefs.isEmpty else { return nil }
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
return fv
}
private func prefill() {
if let initialType { modus = initialType }
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit
// "Anlegen & weiter".
selectedGroupId = groupId
selectedCategoryId = categoryId
if let suggestion {
name = suggestion.name
brand = suggestion.brand ?? ""
baseUnit = suggestion.baseUnit ?? "piece"
if let size = suggestion.packageSize { packageSize = String(size) }
}
}
private func create() async {
error = nil
busy = true
defer { busy = false }
do {
let product = try await APIClient.shared.createProduct(
NewProductRequest(
barcode: barcode.isEmpty ? nil : barcode,
name: name,
brand: brand.isEmpty ? nil : brand,
imageUrl: suggestion?.imageUrl,
baseUnit: baseUnit,
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision,
groupId: selectedGroupId,
categoryId: selectedCategoryId,
individual: modus == "object" ? (objMode == "individual") : nil,
bulk: modus == "object" ? (objMode == "bulk") : nil,
fieldValues: eigeneFeldwerte()
)
)
// Vorgemerktes Foto hochladen ein Fehler darf das Anlegen nicht
// zunichtemachen, der Artikel existiert bereits.
if let pendingImage, let data = productImageJPEG(pendingImage) {
_ = try? await APIClient.shared.uploadProductImage(
id: product.id, data: data, contentType: "image/jpeg")
}
onCreated(product)
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}