Files
Vorrania/ios/Sources/CheckInView.swift
Scarriffle 8dcc17b22f EAN-Codes einer Kategorie nachvollziehbar gemacht
Eine Kategorie mit Artikeln zeigte "0 EANs", und der Versuch, den Code eines
eigenen Artikels einzutragen, scheiterte mit "Dieser Code ist bereits vergeben".
Beides war fachlich richtig, aber nirgends erklaert.

Hintergrund: Die Code-Liste einer Kategorie ist eine Vorratsliste fuer Artikel,
die es noch nicht gibt. Sie greift nur, wenn beim Einlagern ein unbekannter Code
gescannt wird - dann landet der neu angelegte Artikel in dieser Kategorie
(routers/products.lookup). Haengt ein Code bereits an einem Artikel, findet die
Suche immer zuerst den Artikel; ein gleichlautender Kategorie-Eintrag koennte
nie wirken. Die Spalte zaehlte bisher ausschliesslich diese Vorratscodes, nie
die Codes der enthaltenen Artikel - daher die irritierende 0.

Die Kategorie liefert jetzt zusaetzlich die Codes ihrer Artikel mit
(product_barcodes, rein informativ). Beide Herkuenfte stehen in einer Liste:
Artikel-Codes sind als solche gekennzeichnet, nennen den Artikel und lassen
sich hier nicht loeschen, weil sie am Artikel haengen. Bewusst ohne Dublette in
der Datenbank - ein zweiter Datensatz koennte nie greifen und beim Loeschen des
Artikels verwaisen. Die Spalte zaehlt beide Herkuenfte.

Die Fehlermeldung sagt jetzt, wem ein Code gehoert: beim Artikel mit Namen und
dem Hinweis, dass Artikel-Codes hier nicht eingetragen werden muessen; bei einer
anderen Kategorie mit deren Namen.

Signal beim Einlagern: Wird ein Code erkannt, der einer Kategorie zugeordnet
ist, steht jetzt deutlich sichtbar "Wird automatisch der Kategorie X zugeordnet"
samt Begruendung - sowohl im Web als auch in der App, und in beiden Faellen
(Open-Food-Facts-Treffer und voellig unbekannter Code). Nach dem Anlegen meldet
die Web-Oberflaeche zurueck, welche Kategorie es geworden ist. Vorher stand die
Zuordnung nur als Nebensatz in grauer Kleinschrift.

Getestet: Gegen die laufende API geprueft, dass eine Kategorie die Codes ihrer
Artikel meldet (Haupt-Barcode und zusaetzliche Alias-Codes), dass das Eintragen
eines Artikel-Codes und eines fremden Kategorie-Codes mit der jeweils richtigen
Begruendung abgewiesen wird und dass ein echter Vorratscode weiterhin angelegt
werden kann. iOS-Geraetebuild und Web-Build fehlerfrei, 40 pytest-Tests gruen.

Dabei zwei Uebersetzungsfehler durch deutsche Anfuehrungszeichen gefunden: Das
schliessende Zeichen war ein gerades ", das den String vorzeitig beendete.
Korrigiert und die uebrigen Vorkommen im Projekt gleich mit vereinheitlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:52:34 +02:00

207 lines
7.5 KiB
Swift

import SwiftUI
struct CheckInView: View {
@Environment(\.dismiss) private var dismiss
@State private var paused = false
@State private var torchOn = false
@State private var status: String?
@State private var error: String?
@State private var product: Product?
@State private var suggestion: LookupResult.Suggestion?
@State private var suggestedGroupId: Int?
@State private var suggestedGroupName: String?
@State private var unknownCode: String?
@State private var manualCodeShown = false
@State private var manualCode = ""
@State private var units: [Unit] = []
@State private var locations: [StorageLocation] = []
var body: some View {
// Kamera bewusst nur als Feld oben statt bildschirmfuellend - der
// Sucher ueber die ganze Flaeche wirkte erschlagend.
ScrollView {
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode vor die Kamera halten")
.font(.caption).foregroundStyle(.secondary)
if let status { banner(status, color: .green) }
if let error { banner(error, color: .red) }
if let suggestion {
suggestionCard(suggestion)
} else if let unknownCode {
unknownCard(unknownCode)
}
VStack(spacing: 10) {
Button {
paused = true
manualCodeShown = true
} label: {
Label("EAN manuell", systemImage: "keyboard")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
NavigationLink {
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
product = created
}
} label: {
Label("Artikel anlegen", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Einlagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Fertig") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
}
.task {
units = (try? await APIClient.shared.units()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
}
.alert("EAN eingeben", isPresented: $manualCodeShown) {
TextField("z.B. 8076809572569", text: $manualCode)
.keyboardType(.numberPad)
Button("Suchen") {
let code = manualCode
manualCode = ""
Task { await resolve(code) }
}
Button("Abbrechen", role: .cancel) { paused = false }
}
.sheet(item: $product) { item in
NavigationStack {
CheckInFormView(product: item, units: units, locations: locations) { message in
status = message
product = nil
paused = false
}
}
}
}
// MARK: - Bausteine
private func banner(_ text: String, color: Color) -> some View {
Text(text)
.font(.callout)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(color.opacity(0.9))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
}
/// Sagt vor dem Anlegen, welche Kategorie der Artikel bekommt und warum.
@ViewBuilder
private func categoryNote() -> some View {
if let name = suggestedGroupName {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "tag")
VStack(alignment: .leading, spacing: 1) {
Text("Wird der Kategorie „\(name)“ zugeordnet").font(.caption).bold()
Text("Dieser EAN-Code ist dort hinterlegt.")
.font(.caption2).foregroundStyle(.secondary)
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(item.name).font(.headline)
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
.font(.caption).foregroundStyle(.secondary)
Text("Bei Open Food Facts gefunden, noch nicht im Katalog.")
.font(.caption2).foregroundStyle(.secondary)
categoryNote()
NavigationLink {
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
suggestion: item) { created in
suggestion = nil
product = created
}
} label: {
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.padding()
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
private func unknownCard(_ code: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Unbekannter Code \(code)").font(.headline)
Text("Weder im Katalog noch bei Open Food Facts.")
.font(.caption).foregroundStyle(.secondary)
categoryNote()
NavigationLink {
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in
unknownCode = nil
product = created
}
} label: {
Label("Artikel anlegen", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.padding()
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
// MARK: - Logik
private func resolve(_ code: String) async {
paused = true
error = nil
status = nil
suggestion = nil
unknownCode = nil
do {
let result = try await APIClient.shared.lookup(barcode: code)
suggestedGroupId = result.groupId
suggestedGroupName = result.groupName
if let existing = result.existingProduct {
product = existing
} else if let hint = result.suggestion {
suggestion = hint
} else {
unknownCode = code
}
} catch {
self.error = error.localizedDescription
paused = false
}
}
}