Vier Bruchstellen im taeglichen Gebrauch, alle an derselben Wurzel: Nach dem Anlegen endete der Weg, und was danach kam, musste man sich selbst suchen. NFC beim Anlegen: createItems liefert die angelegten Stuecke samt UID laengst zurueck, ItemAddSheet warf sie nur weg. Bei genau EINEM Stueck geht es jetzt direkt zum Tag-Blatt. Bei mehreren bleibt es wie bisher - die App koennte nicht wissen, welches der fuenf Stuecke gerade am Tag haengt. Ohne NFC im Geraet (iPad) ebenso unveraendert. Menge beim Anlegen: Ein Gegenstand, der nur als Menge je Lagerort gezaehlt wird, landete nach dem Anlegen im Nichts. Das Blatt dafuer gab es schon (ObjectAddSheet), nur fuehrte kein Weg dorthin. Jetzt derselbe Uebergang wie bei Einzelstuecken, an beiden Einstiegen. Es holt sich die Lagerorte selbst - ObjectAddSheet nimmt sie als Konstante und laedt sie nicht nach. Loeschen: Der Knopf sitzt unten in der Artikelansicht, nur fuer Admins - die Route verlangt das, ohne Riegel gaebe es nur ein wortkarges 403. Lehnt der Server ab (noch Einzelstuecke oder Bestand), bleibt die Ansicht stehen und seine Begruendung steht unveraendert in der Fehlerzeile. NFC beim Ein- und Auslagern: Ein Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er zur Stueck-Ansicht - beim Auslagern ist "Entfernen (mit Grund)" genau die passende Aktion. Bewusst NICHT ueber resolve() abgekuerzt: das faellt bei unbekannter UID auf lookup(barcode:) durch und boete an, einen Artikel mit einer URL als Barcode anzulegen. Beim Nachschlagen gab es NFC schon, nur unscheinbar unter der Kamerakarte - Knopf abgesetzt und in der Bildunterschrift erwaehnt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
797 lines
33 KiB
Swift
797 lines
33 KiB
Swift
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")
|
||
}
|
||
|
||
// Eingenaehte Tags an Kleidung lassen sich nicht scannen. Ein
|
||
// Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er
|
||
// dorthin und nicht in die Artikelsuche.
|
||
NFCLesenButton(titel: "NFC-Tag lesen") { uid in
|
||
Task {
|
||
error = nil; status = nil
|
||
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||
scannedItem = found
|
||
} else {
|
||
error = "Kein Einzelstück mit der UID \(uid)."
|
||
}
|
||
}
|
||
} onFehler: { meldung in
|
||
error = meldung
|
||
}
|
||
|
||
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 uid = einzelstueckCode(aus: code),
|
||
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.marke, lineWidth: 2)
|
||
.background(Color.marke.opacity(0.15))
|
||
.frame(width: rect.width, height: rect.height)
|
||
.offset(x: rect.minX, y: rect.minY)
|
||
.gesture(zieh(resize: false))
|
||
Rectangle().fill(Color.marke)
|
||
.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
|
||
Section {
|
||
zeile($z)
|
||
} header: {
|
||
Text(z.text)
|
||
.textCase(nil).font(.footnote).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
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.")
|
||
}
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
}
|
||
}
|
||
.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 ?? "") }
|
||
}
|
||
|
||
/// Drei Zeilen (Rows) je Artikel: Auswahl, Menge, Lagerort. In einer eigenen
|
||
/// Section gruppiert – so ist klar, was zusammengehört, und nichts ist gequetscht.
|
||
@ViewBuilder private func zeile(_ z: Binding<KassenzettelZeile>) -> some View {
|
||
let zeile = z.wrappedValue
|
||
// Artikel wählen
|
||
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)
|
||
}
|
||
}
|
||
// Menge
|
||
Stepper("Menge: \(Int(zeile.menge))", value: z.menge, in: 1...99)
|
||
// Lagerort + QR-Scan
|
||
HStack {
|
||
Menu {
|
||
Button("– ohne –") { z.wrappedValue.ortId = nil }
|
||
// Voller Pfad statt nur des Namens: In dieser engen Zeile gibt es
|
||
// keine Einrueckung, und "Fach A" allein ist nicht eindeutig.
|
||
ForEach(locations) { loc in
|
||
Button(loc.path(in: locations)) { z.wrappedValue.ortId = loc.id }
|
||
}
|
||
} label: {
|
||
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
|
||
}
|
||
Spacer()
|
||
Button { scanZiel = ZeilenRef(id: zeile.id) } label: {
|
||
Label("QR", systemImage: "qrcode.viewfinder")
|
||
}.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
|
||
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)) } }
|
||
}
|
||
}
|
||
}
|
||
}
|