Files
Vorrania/ios/Sources/ProductViews.swift
Scarriffle e42e11bf23 iOS: Name/Marke per Text scannen + Frischedatum ohne Jahr erkennen
- Neuer TextScanView: beim Anlegen Name und Marke per Texterkennung einlesen
  (erkannte Zeilen antippen, wie beim MHD-Scan) - kein Abtippen mehr, wenn ein
  Produkt nicht in Open Food/Products Facts steht.
- BestBeforeText erkennt jetzt auch Tag.Monat ohne Jahr (z.B. "05.01" bei
  Frischware / "zu verbrauchen bis") und ergaenzt das aktuelle Jahr (bzw. das
  naechste, falls schon vergangen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:35:46 +02:00

353 lines
15 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] = [:]
// Text-Scan für Name/Marke (statt abtippen), wenn ein Produkt neu ist.
@State private var scanTarget: TextScanTarget?
enum TextScanTarget: Identifiable {
case name, brand
var id: Int { self == .name ? 0 : 1 }
var titel: String { self == .name ? "Name scannen" : "Marke scannen" }
}
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)
if TextScanView.isSupported {
Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") }
}
LabeledField(label: "Marke", text: $brand)
if TextScanView.isSupported {
Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") }
}
}
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()
}
.fullScreenCover(item: $scanTarget) { target in
NavigationStack {
TextScanView(titel: target.titel) { text in
if target == .name { name = text } else { brand = text }
}
}
}
.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
}
}
}