- Zwei-Finger-Pinch zoomt im Kamerabild naeher an einen Code heran (digital, auf Faktor 6 begrenzt). - Blitz geht nach dem ersten erfolgreichen Scan wieder aus. Per Langdruck auf den Blitz-Knopf bleibt er dauerhaft an (gesperrt, gelb); Tippen schaltet aus und hebt die Sperre auf. Gilt fuer alle Scan-Bildschirme. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
9.0 KiB
Swift
237 lines
9.0 KiB
Swift
import SwiftUI
|
||
|
||
struct CheckInView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@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 = ""
|
||
@State private var units: [Unit] = []
|
||
@State private var locations: [StorageLocation] = []
|
||
|
||
var body: some View {
|
||
// Kamera bewusst nur als Feld oben statt bildschirmfuellend - der
|
||
// Sucher ueber die ganze Flaeche wirkte erschlagend.
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
.padding(.horizontal)
|
||
|
||
Text("Barcode vor die Kamera halten")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
|
||
if let status { banner(status, color: .green) }
|
||
if let error { banner(error, color: .red) }
|
||
|
||
if let suggestion {
|
||
suggestionCard(suggestion)
|
||
} else if let unknownCode {
|
||
unknownCard(unknownCode)
|
||
}
|
||
|
||
VStack(spacing: 10) {
|
||
Button {
|
||
paused = true
|
||
manualCodeShown = true
|
||
} label: {
|
||
Label("EAN manuell", systemImage: "keyboard")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
NavigationLink {
|
||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||
product = created
|
||
}
|
||
} label: {
|
||
Label("Artikel anlegen", systemImage: "plus")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
.padding(.vertical)
|
||
}
|
||
.navigationTitle("Einlagern")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Fertig") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||
}
|
||
.task {
|
||
units = (try? await APIClient.shared.units()) ?? []
|
||
locations = (try? await APIClient.shared.locations()) ?? []
|
||
}
|
||
.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) { paused = false }
|
||
}
|
||
.sheet(item: $product) { item in
|
||
NavigationStack {
|
||
CheckInFormView(product: item, units: units, locations: locations) { message in
|
||
status = message
|
||
product = nil
|
||
paused = false
|
||
}
|
||
}
|
||
}
|
||
.sheet(item: $scannedItem, onDismiss: { paused = false }) { it in
|
||
NavigationStack { ItemEditView(item: it) }
|
||
}
|
||
}
|
||
|
||
// MARK: - Bausteine
|
||
|
||
private func banner(_ text: String, color: Color) -> some View {
|
||
Text(text)
|
||
.font(.callout)
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(color.opacity(0.9))
|
||
.foregroundStyle(.white)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
.padding(8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color.accentColor.opacity(0.15))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
||
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)
|
||
}
|
||
.padding()
|
||
.background(.thinMaterial)
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
private func unknownCard(_ code: String) -> some View {
|
||
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)
|
||
}
|
||
.padding()
|
||
.background(.thinMaterial)
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
// MARK: - Logik
|
||
|
||
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
|
||
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
|
||
}
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
paused = false
|
||
}
|
||
}
|
||
}
|