Files
Vorrania/ios/Sources/CheckInView.swift
Scarriffle 648b8294b8 iOS: Kassenzettel scannen -> Lebensmittel erkennen & einlagern
Neuer Flow (Kachel im Listen-Tab): Foto aufnehmen/waehlen, Artikelbereich per
Rechteck markieren, on-device OCR (Vision, deutsch; Bild bleibt auf dem Geraet),
Zeilen an /products/match schicken. Pruefen-Liste je Zeile: Artikel-Picker
(Auto-Treffer ueber Schwellwert + eigene Lebensmittel-Suche), Menge (aus Zeile
geparst, korrigierbar), Lagerort-Dropdown + QR-Scan. Einlagern per checkInBatch,
unbekannte Zeilen werden uebersprungen; keine Neuanlage.

Backend: MatchCandidate traegt package_size/base_unit mit, damit die App die
Einheit ohne Extra-Abruf kennt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:40:10 +02:00

771 lines
32 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 Vision
import PhotosUI
import UIKit
struct CheckInView: View {
@Environment(\.dismiss) private var dismiss
// Kamera bewusst nur auf Wunsch: Einlagern öffnet ein Formular, kein Sucher.
@State private var scanShown = false
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@State private var status: String?
@State private var error: String?
@State private var product: Product?
@State private var scannedItem: Item?
@State private var suggestion: LookupResult.Suggestion?
@State private var suggestedGroupId: Int?
@State private var suggestedGroupName: String?
@State private var suggestedCategoryId: Int?
@State private var suggestedCategoryName: String?
@State private var unknownCode: String?
@State private var manualCodeShown = false
@State private var manualCode = ""
// Manuelle Artikelsuche (statt Scanzwang).
@State private var query = ""
@State private var results: [Product] = []
@State private var searching = false
@State private var units: [Unit] = []
@State private var locations: [StorageLocation] = []
var body: some View {
Form {
if let status { Section { banner(status, color: .green) } }
if let error { Section { banner(error, color: .red) } }
Section {
Button {
error = nil; status = nil
scanShown = true
} label: {
Label("EAN scannen", systemImage: "barcode.viewfinder")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
manualCode = ""
manualCodeShown = true
} label: {
Label("EAN von Hand eingeben", systemImage: "keyboard")
}
NavigationLink {
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
product = created
}
} label: {
Label("Neuen Artikel anlegen", systemImage: "plus")
}
} header: {
Text("Neu erfassen")
} footer: {
Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ kein Zwang.")
}
if let suggestion {
suggestionSection(suggestion)
} else if let unknownCode {
unknownSection(unknownCode)
}
Section {
HStack {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Name oder Marke …", text: $query)
.autocorrectionDisabled()
if !query.isEmpty {
Button { query = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
if searching {
HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) }
}
ForEach(results) { p in
Button {
error = nil; status = nil
product = p
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(p.name).foregroundStyle(.primary)
let unter = [p.brand, "Bestand: \(bestandText(p))"]
.compactMap { $0 }.joined(separator: " · ")
Text(unter).font(.caption).foregroundStyle(.secondary)
}
}
}
if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching {
Text("Kein Artikel gefunden oben neu anlegen.")
.foregroundStyle(.secondary).font(.callout)
}
} header: {
Text("Vorhandenen Artikel wählen")
}
}
.navigationTitle("Einlagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
}
.task {
units = (try? await APIClient.shared.units()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
}
.task(id: query) { await search() }
.fullScreenCover(isPresented: $scanShown) { scannerCover }
.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) { }
}
.sheet(item: $product) { item in
NavigationStack {
if item.isIndividual {
// Einzelstücke werden nicht als Charge eingelagert, sondern als
// physische Exemplare (UID/QR) angelegt die zählen den Bestand.
ItemAddSheet(productId: item.id, onDone: {
status = "Eingelagert\n\(item.name): Einzelstück(e) angelegt."
})
} else if item.foodLike {
// Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD.
CheckInFormView(product: item, units: units, locations: locations) { message in
status = message
product = nil
}
} else {
// Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort.
ObjectAddSheet(product: item, locations: locations,
einheit: item.unitName.isEmpty ? "Stück" : item.unitName,
perform: { status = "Eingelagert\n\(item.name): Menge hinzugefügt." })
}
}
}
.sheet(item: $scannedItem) { it in
NavigationStack { ItemEditView(item: it) }
}
}
// MARK: - Scanner (nur auf Wunsch)
private var scannerCover: some View {
NavigationStack {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
Text("Barcode vor die Kamera halten")
.font(.callout)
.padding(10)
.background(.ultraThinMaterial, in: Capsule())
.padding(.bottom, 24)
}
.navigationTitle("EAN scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { scanShown = false }
}
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $torchOn, locked: $torchLocked)
}
}
}
.onAppear { paused = false }
}
// MARK: - Bausteine
private func banner(_ text: String, color: Color) -> some View {
// Erste Zeile = Titel (fett), Rest = Detail ergibt eine saubere,
// rundum eingerückte Melde-Pille statt eines randlosen Balkens.
let zeilen = text.components(separatedBy: "\n")
return VStack(alignment: .leading, spacing: 2) {
Text(zeilen.first ?? text).font(.headline)
if zeilen.count > 1 {
Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline)
}
}
.padding(.horizontal, 16).padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(.white)
.background(color, in: RoundedRectangle(cornerRadius: 14))
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.listRowBackground(Color.clear)
}
private func bestandText(_ p: Product) -> String {
let wert = p.stockInArticleUnits
let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert)
return "\(zahl) \(p.articleUnitLabel)"
}
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
@ViewBuilder
private func groupNote() -> some View {
if let kategorie = suggestedCategoryName {
noteRow(symbol: "square.grid.2x2",
title: "Wird der Kategorie „\(kategorie)“ zugeordnet",
detail: "Aus der Produkt-Datenbank vorgeschlagen im Formular änderbar.")
}
if let name = suggestedGroupName {
noteRow(symbol: "tag",
title: "Wird der Gruppe „\(name)“ zugeordnet",
detail: "Dieser EAN-Code ist dort hinterlegt.")
}
}
private func noteRow(symbol: String, title: String, detail: String) -> some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: symbol)
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.caption).bold()
Text(detail).font(.caption2).foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private func suggestionSection(_ item: LookupResult.Suggestion) -> some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(item.name).font(.headline)
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
.font(.caption).foregroundStyle(.secondary)
Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.")
.font(.caption2).foregroundStyle(.secondary)
groupNote()
NavigationLink {
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
categoryId: suggestedCategoryId, suggestion: item) { created in
suggestion = nil
product = created
}
} label: {
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
} header: {
Text("Vorschlag zum Scan")
}
}
@ViewBuilder
private func unknownSection(_ code: String) -> some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text("Unbekannter Code \(code)").font(.headline)
Text("Weder im Katalog noch in Open Food / Products Facts.")
.font(.caption).foregroundStyle(.secondary)
groupNote()
NavigationLink {
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId,
categoryId: suggestedCategoryId) { created in
unknownCode = nil
product = created
}
} label: {
Label("Artikel anlegen", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
} header: {
Text("Zum Scan")
}
}
// MARK: - Logik
private func search() async {
let q = query.trimmingCharacters(in: .whitespaces)
guard q.count >= 2 else { results = []; searching = false; return }
// Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird.
try? await Task.sleep(nanoseconds: 250_000_000)
if Task.isCancelled { return }
searching = true
defer { searching = false }
results = (try? await APIClient.shared.searchProducts(q)) ?? []
}
private func resolve(_ code: String) async {
paused = true
error = nil
status = nil
suggestion = nil
unknownCode = nil
do {
// Einzelstück-QR (/i/<UID>) direkt das Stück öffnen statt Barcode-Suche.
if let r = code.range(of: "/i/") {
let uid = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
scannedItem = item
scanShown = false
return
}
}
let result = try await APIClient.shared.lookup(barcode: code)
suggestedGroupId = result.groupId
suggestedGroupName = result.groupName
suggestedCategoryId = result.categoryId
suggestedCategoryName = result.categoryName
if let existing = result.existingProduct {
product = existing
} else if let hint = result.suggestion {
suggestion = hint
} else {
unknownCode = code
}
scanShown = false
} catch {
self.error = error.localizedDescription
scanShown = false
}
}
}
// MARK: - Kassenzettel scannen (nur Lebensmittel)
private extension UIImage {
/// Orientierung auf `.up` normalisieren, damit `cgImage`-Pixel und `size`-Punkte
/// zusammenpassen (Kamerafotos sind sonst gedreht).
func nachObenGedreht() -> UIImage {
guard imageOrientation != .up else { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
/// Auf ein Rechteck in Punkt-Koordinaten (size) zuschneiden.
func zugeschnitten(auf rect: CGRect) -> UIImage? {
let px = CGRect(x: rect.minX * scale, y: rect.minY * scale,
width: rect.width * scale, height: rect.height * scale)
guard let cg = cgImage?.cropping(to: px.integral) else { return nil }
return UIImage(cgImage: cg, scale: scale, orientation: .up)
}
}
/// OCR (on-device) auf einem Ausschnitt deutsche Texterkennung, zeilenweise von
/// oben nach unten. Das Bild verlässt das Gerät nicht; nur die Textzeilen gehen zum
/// Server für den Abgleich.
private func ocrZeilen(_ cg: CGImage) async -> [String] {
await withCheckedContinuation { fortsetzung in
DispatchQueue.global(qos: .userInitiated).async {
let anfrage = VNRecognizeTextRequest()
anfrage.recognitionLevel = .accurate
anfrage.usesLanguageCorrection = true
anfrage.recognitionLanguages = ["de-DE"]
try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([anfrage])
let treffer = (anfrage.results as? [VNRecognizedTextObservation]) ?? []
let vonOben = treffer.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY }
fortsetzung.resume(returning: vonOben.compactMap { $0.topCandidates(1).first?.string })
}
}
}
/// Offensichtliche Nicht-Artikel (Preise, Summen, Kopf/Fuß) grob aussortieren.
private func plausibleArtikelZeilen(_ zeilen: [String]) -> [String] {
let muell = ["summe", "gesamt", "zwischensumme", "mwst", "ust", "eur", "bar",
"rueckgeld", "rückgeld", "kartenzahlung", "betrag", "total",
"kassenbon", "beleg", "datum", "uhr", "filiale", "kunde", "steuer"]
return zeilen.compactMap { roh in
let s = roh.trimmingCharacters(in: .whitespacesAndNewlines)
guard s.filter({ $0.isLetter }).count >= 3 else { return nil }
let klein = s.lowercased()
return muell.contains(where: { klein.contains($0) }) ? nil : s
}
}
/// Menge aus einer Zeile lesen (2x", 2 Stk", führende Zahl), sonst 1.
private func mengeAusZeile(_ zeile: String) -> Double {
for muster in ["(\\d+)\\s*[xX]", "(\\d+)\\s*[sS][tT]", "^\\s*(\\d+)\\b"] {
if let r = zeile.range(of: muster, options: .regularExpression) {
let ziffern = zeile[r].filter { $0.isNumber }
if let n = Int(ziffern), n > 0, n < 100 { return Double(n) }
}
}
return 1
}
/// Einstieg: Foto Bereich markieren Zeilen prüfen einlagern.
struct KassenzettelView: View {
@Environment(\.dismiss) private var dismiss
@State private var bild: UIImage?
@State private var zeilen: [MatchLine]?
@State private var analysiere = false
@State private var kameraAn = false
@State private var galerie: PhotosPickerItem?
@State private var locations: [StorageLocation] = []
var body: some View {
NavigationStack {
inhalt
.navigationTitle("Kassenzettel")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Schließen") { dismiss() } } }
.task { locations = (try? await APIClient.shared.locations()) ?? [] }
.fullScreenCover(isPresented: $kameraAn) {
CameraPicker(isPresented: $kameraAn) { img in bild = img.nachObenGedreht(); zeilen = nil }
.ignoresSafeArea()
}
.onChange(of: galerie) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let ui = UIImage(data: data) { bild = ui.nachObenGedreht(); zeilen = nil }
galerie = nil
}
}
}
}
@ViewBuilder private var inhalt: some View {
if analysiere {
VStack(spacing: 12) { ProgressView(); Text("Lese Artikel…").foregroundStyle(.secondary) }
} else if let zeilen {
KassenzettelPruefenView(match: zeilen, locations: locations) { dismiss() }
} else if let bild {
KassenzettelMarkierenView(bild: bild, onWeiter: starteErkennung) { self.bild = nil }
} else {
start
}
}
private var start: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "doc.text.viewfinder").font(.system(size: 52)).foregroundStyle(.secondary)
Text("Kassenzettel fotografieren, den Artikelbereich markieren die App liest die Zeilen und schlägt Lebensmittel vor.")
.multilineTextAlignment(.center).foregroundStyle(.secondary).padding(.horizontal)
Button { kameraAn = true } label: {
Label("Foto aufnehmen", systemImage: "camera").frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent)
PhotosPicker(selection: $galerie, matching: .images) {
Label("Aus Galerie wählen", systemImage: "photo").frame(maxWidth: .infinity)
}.buttonStyle(.bordered)
Spacer()
}.padding()
}
private func starteErkennung(_ ausschnitt: UIImage) {
analysiere = true
Task {
let erg = await erkenne(ausschnitt)
await MainActor.run { zeilen = erg; analysiere = false }
}
}
private func erkenne(_ img: UIImage) async -> [MatchLine] {
guard let cg = img.cgImage else { return [] }
let roh = await ocrZeilen(cg)
let zeilen = plausibleArtikelZeilen(roh)
guard !zeilen.isEmpty else { return [] }
return (try? await APIClient.shared.matchReceipt(zeilen))
?? zeilen.map { MatchLine(text: $0, candidates: []) }
}
}
/// Rechteck über den Artikelblock ziehen; liefert den zugeschnittenen Bildausschnitt.
struct KassenzettelMarkierenView: View {
let bild: UIImage
var onWeiter: (UIImage) -> Void
var onNeu: () -> Void
@State private var rect: CGRect = .zero
@State private var startRect: CGRect?
@State private var fitRect: CGRect = .zero
var body: some View {
VStack(spacing: 12) {
Text("Ziehe das Rechteck über die Artikel. Am Griff unten rechts vergrößern.")
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center).padding(.horizontal)
GeometryReader { geo in
ZStack(alignment: .topLeading) {
Image(uiImage: bild).resizable().scaledToFit()
.frame(width: geo.size.width, height: geo.size.height)
Rectangle().stroke(Color.accentColor, lineWidth: 2)
.background(Color.accentColor.opacity(0.15))
.frame(width: rect.width, height: rect.height)
.offset(x: rect.minX, y: rect.minY)
.gesture(zieh(resize: false))
Rectangle().fill(Color.accentColor)
.frame(width: 26, height: 26)
.offset(x: rect.maxX - 13, y: rect.maxY - 13)
.gesture(zieh(resize: true))
}
.onAppear { setzeFit(geo.size) }
.onChange(of: geo.size) { setzeFit($0) }
}
HStack {
Button("Neues Foto", action: onNeu)
Spacer()
Button("Weiter") { weiter() }.buttonStyle(.borderedProminent)
}.padding(.horizontal)
}.padding(.vertical)
}
private func setzeFit(_ container: CGSize) {
let iw = bild.size.width, ih = bild.size.height
guard iw > 0, ih > 0, container.width > 0 else { return }
let s = min(container.width / iw, container.height / ih)
let w = iw * s, h = ih * s
fitRect = CGRect(x: (container.width - w) / 2, y: (container.height - h) / 2, width: w, height: h)
if rect == .zero { rect = fitRect.insetBy(dx: fitRect.width * 0.08, dy: fitRect.height * 0.12) }
}
private func zieh(resize: Bool) -> some Gesture {
DragGesture()
.onChanged { v in
if startRect == nil { startRect = rect }
guard let s = startRect else { return }
var r = s
if resize {
r.size.width = max(48, s.width + v.translation.width)
r.size.height = max(48, s.height + v.translation.height)
} else {
r.origin.x = s.origin.x + v.translation.width
r.origin.y = s.origin.y + v.translation.height
}
rect = klemme(r)
}
.onEnded { _ in startRect = nil }
}
private func klemme(_ r: CGRect) -> CGRect {
guard fitRect.width > 0 else { return r }
var o = r
o.size.width = min(o.size.width, fitRect.width)
o.size.height = min(o.size.height, fitRect.height)
o.origin.x = min(max(o.origin.x, fitRect.minX), fitRect.maxX - o.width)
o.origin.y = min(max(o.origin.y, fitRect.minY), fitRect.maxY - o.height)
return o
}
private func weiter() {
guard fitRect.width > 0 else { onWeiter(bild); return }
let s = bild.size.width / fitRect.width // Bildschirm-Einheiten Bildpunkte
let crop = CGRect(x: (rect.minX - fitRect.minX) * s, y: (rect.minY - fitRect.minY) * s,
width: rect.width * s, height: rect.height * s)
onWeiter(bild.zugeschnitten(auf: crop) ?? bild)
}
}
private struct KassenzettelArtikel: Hashable { let id: Int; let name: String; let unit: String }
private struct KassenzettelZeile: Identifiable {
let id = UUID()
let text: String
let kandidaten: [MatchCandidate]
var artikel: KassenzettelArtikel?
var menge: Double
var ortId: String?
}
private struct ZeilenRef: Identifiable { let id: UUID }
/// Prüfen-Liste: je Zeile Artikel (Auto-Treffer + eigene Suche), Menge, Lagerort.
struct KassenzettelPruefenView: View {
let match: [MatchLine]
let locations: [StorageLocation]
var onFertig: () -> Void
@State private var zeilen: [KassenzettelZeile] = []
@State private var sucheZiel: ZeilenRef?
@State private var scanZiel: ZeilenRef?
@State private var busy = false
@State private var meldung: String?
private var zugeordnet: Int { zeilen.filter { $0.artikel != nil }.count }
var body: some View {
Group {
if zeilen.isEmpty {
VStack(spacing: 10) {
Image(systemName: "doc.text.magnifyingglass").font(.system(size: 40)).foregroundStyle(.secondary)
Text("Keine Artikel erkannt.").font(.headline)
Text("Versuch es mit einem engeren Rechteck oder besserem Licht.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}.padding()
} else {
List {
ForEach($zeilen) { $z in zeile($z) }
Section {
Button { Task { await einlagern() } } label: {
HStack {
if busy { ProgressView().padding(.trailing, 4) }
Text("\(zugeordnet) Artikel einlagern")
}
}.disabled(busy || zugeordnet == 0)
} footer: {
Text("Nicht zugeordnete Zeilen werden übersprungen. Neue Artikel werden nicht angelegt.")
}
}
}
}
.onAppear(perform: aufbauen)
.sheet(item: $sucheZiel) { ref in
NavigationStack {
ArtikelSucheSheet { produkt in setzeArtikel(ref.id, produkt: produkt); sucheZiel = nil }
}
}
.sheet(item: $scanZiel) { ref in
LocationScannerView(locations: locations) { loc in setzeOrt(ref.id, loc.id); scanZiel = nil }
}
.alert("Fertig", isPresented: Binding(get: { meldung != nil },
set: { if !$0 { meldung = nil; onFertig() } })) {
Button("OK") { }
} message: { Text(meldung ?? "") }
}
@ViewBuilder private func zeile(_ z: Binding<KassenzettelZeile>) -> some View {
let zeile = z.wrappedValue
VStack(alignment: .leading, spacing: 8) {
Text(zeile.text).font(.caption).foregroundStyle(.secondary).lineLimit(1)
Menu {
ForEach(zeile.kandidaten) { k in
Button("\(k.name) · \(k.score) %") {
z.wrappedValue.artikel = KassenzettelArtikel(id: k.productId, name: k.name, unit: k.checkInUnit)
}
}
Divider()
Button { sucheZiel = ZeilenRef(id: zeile.id) } label: { Label("Suchen…", systemImage: "magnifyingglass") }
if zeile.artikel != nil {
Button(role: .destructive) { z.wrappedValue.artikel = nil } label: {
Label("Nicht zuordnen", systemImage: "xmark")
}
}
} label: {
HStack {
Image(systemName: zeile.artikel == nil ? "questionmark.circle" : "checkmark.circle.fill")
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.green)
Text(zeile.artikel?.name ?? " Artikel wählen ")
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
Stepper("Menge \(Int(zeile.menge))", value: z.menge, in: 1...99).fixedSize()
Spacer()
Menu {
Button(" ohne ") { z.wrappedValue.ortId = nil }
ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } }
} label: {
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse").font(.callout).lineLimit(1)
}
Button { scanZiel = ZeilenRef(id: zeile.id) } label: {
Image(systemName: "qrcode.viewfinder")
}.buttonStyle(.borderless)
}
}.padding(.vertical, 4)
}
private func ortName(_ id: String?) -> String {
guard let id else { return "ohne Ort" }
return locations.first { $0.id == id }?.name ?? "Ort"
}
private func aufbauen() {
guard zeilen.isEmpty else { return }
zeilen = match.map { m in
KassenzettelZeile(
text: m.text, kandidaten: m.candidates,
artikel: m.candidates.first.map {
KassenzettelArtikel(id: $0.productId, name: $0.name, unit: $0.checkInUnit)
},
menge: mengeAusZeile(m.text), ortId: nil,
)
}
}
private func setzeArtikel(_ id: UUID, produkt: Product) {
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
let unit = (produkt.packageSize ?? 0) > 0 ? "package" : produkt.baseUnit
zeilen[i].artikel = KassenzettelArtikel(id: produkt.id, name: produkt.name, unit: unit)
}
private func setzeOrt(_ id: UUID, _ ortId: String) {
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
zeilen[i].ortId = ortId
}
private func einlagern() async {
busy = true
var ein = 0, weg = 0
var fehler: String?
for z in zeilen {
guard let a = z.artikel else { weg += 1; continue }
let payload = BatchCheckInRequest(
productId: a.id, unit: a.unit,
lines: [CheckInLine(quantity: z.menge, bestBefore: nil,
bestBeforePrecision: "day", locationId: z.ortId)],
)
do { _ = try await APIClient.shared.checkInBatch(payload); ein += 1 }
catch { fehler = error.localizedDescription }
}
busy = false
meldung = "\(ein) eingelagert, \(weg) übersprungen."
+ (fehler.map { " Fehler: \($0)" } ?? "")
}
}
/// Freie Artikelsuche (nur Lebensmittel) als Rückfall, wenn kein Auto-Treffer passt.
struct ArtikelSucheSheet: View {
var onPick: (Product) -> Void
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var treffer: [Product] = []
@State private var suchlauf = UUID()
var body: some View {
List(treffer) { p in
Button {
onPick(p); dismiss()
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(p.name).foregroundStyle(.primary)
if let b = p.brand, !b.isEmpty {
Text(b).font(.caption).foregroundStyle(.secondary)
}
}
}
}
.listStyle(.plain)
.searchable(text: $text, prompt: "Lebensmittel suchen")
.navigationTitle("Artikel suchen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.onChange(of: text) { q in
let lauf = UUID(); suchlauf = lauf
Task {
try? await Task.sleep(nanoseconds: 250_000_000)
if suchlauf != lauf { return }
let liste = (try? await APIClient.shared.searchProducts(q)) ?? []
let food = liste.filter { $0.tracking != "object" }
await MainActor.run { if suchlauf == lauf { treffer = Array(food.prefix(25)) } }
}
}
}
}