Die Auswahl war nicht flach, weil die Beschriftung gefehlt haette - der volle Pfad stand laengst drin. Sie kam vom Server nach dem BLATTnamen sortiert, und so standen "Garage -> Fach A" und "Keller -> Fach A" irgendwo zwischen allem anderen. Geschwister und Kinder fanden nie zusammen. Neu ist baum() in Baum.swift: bringt eine flache Eltern-Kind-Liste in Baumreihenfolge und rueckt je Ebene ein. Waisen gelten als oberste Ebene, damit nichts lautlos aus der Auswahl faellt. Derselbe Lauf lag bisher dreimal im Projekt (CategoryPicker, TreeMasterView, LocationEditor). Alle drei zeigen jetzt auf den einen - unterm Strich verschwinden mehr Zeilen als dazukommen. Zwei der Kopien waren auch nicht gleichwertig: LocationEditor.pfad hatte den Zyklusschutz von path(in:) nicht und waere bei einer ringfoermigen Elternkette endlos gelaufen. Umgestellt sind neun Auswahlfelder. Die Lagerortzeile beim Kassenzettel zeigte nur den nackten Namen und bekommt den vollen Pfad - dort ist kein Platz zum Einruecken. MARKETING_VERSION stand auf 1.2 in der project.pbxproj, aber auf 1.1 in der project.yml, aus der sie erzeugt wird. Die Quelle nachgezogen, sonst haette das naechste "xcodegen generate" die Anhebung zurueckgedreht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
781 lines
32 KiB
Swift
781 lines
32 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")
|
||
}
|
||
|
||
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)) } }
|
||
}
|
||
}
|
||
}
|
||
}
|