- Kassenzettel-Kachel vom Listen- in den Scannen-Tab verschoben (passt thematisch). - App-Akzent auf Markenblau (#3F51B5/#7C8AE8, wie Web/App-Icon) via .tint; alle Color.accentColor -> Color.marke. System-Blau verschwindet. - Kachel-Buttons/Links auf .buttonStyle(.plain): weisse Schrift/Symbole statt getoentem Blau. - Kassenzettel-Pruefen-Liste: je Artikel eine eigene Section (insetGrouped) mit Artikel/Menge/Lagerort in eigenen Zeilen - klar gruppiert, nicht mehr gequetscht. Co-Authored-By: Claude Opus 4.8 <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 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.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 }
|
||
ForEach(locations) { loc in Button(loc.name) { 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)) } }
|
||
}
|
||
}
|
||
}
|
||
}
|