Files
Vorrania/ios/Sources/CheckInView.swift
Scarriffle 8250b5dc84 iOS: Benachrichtigungs-Kategorien laden + Ein-/Auslager-Meldung huebscher
1) In den Benachrichtigungs-Regeln liess sich keine Kategorie waehlen: der Abruf
   holte den ganzen Snapshot (Produkte + Ablauf + Kategorien) - schlug ein Teil
   fehl, kamen auch die Kategorien leer. snapshot ist jetzt widerstandsfaehig
   (Teilausfaelle egal), und die Regel-Bearbeitung holt nur noch die Kategorien.

2) Die gruene Erfolgsmeldung ist jetzt eine saubere, eingerueckte Pille mit Titel
   ("Eingelagert"/"Ausgelagert") und Detailzeile ("Neuer Bestand: 2 Packungen").
   Die Einheit steht in der Mehrzahl (Gebinde-Plural vom Server; Basiseinheiten
   wie Gramm bleiben unveraendert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:04:25 +02:00

340 lines
14 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
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
}
}
}