Files
Vorrania/ios/Sources/CheckInView.swift
Scarriffle 761c131286 iOS: Scanner – Pinch-Zoom und schlaueres Blitzlicht
- 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>
2026-07-26 22:42:12 +02:00

237 lines
9.0 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
@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
}
}
}