Ein aufgeklebtes QR-Etikett uebersteht keine Waesche. Kleidungsstuecke bekommen deshalb eingenaehte NFC-Tags. Auf das Tag kommt genau das, was auch im QR steckt (vorrania://i/<UID>) - damit bleibt das Einlesen inhaltlich unveraendert, nur die Erfassungsart ist neu. Backend und Web brauchten nichts: die UID vergibt der Server, und GET /items/by-uid/<UID> gibt es laengst. Gelesen wird nur bei geoeffneter App. iOS startet Apps beim Antippen im Hintergrund ausschliesslich ueber https-Universal-Links auf einer beanspruchten Domain und ignoriert eigene Schemes dabei - eine feste Domain ist fuer einen selbstgehosteten Dienst aber gerade der falsche Weg (siehef981f41). Die /i/-Zerlegung lag dreifach kopiert in CheckInView, LookupScanView und AssignScanView und steckt jetzt in einzelstueckCode(aus:). Die nackte UID wird nur fuer NFC akzeptiert: eine achtstellige EAN-8 kann zufaellig genau wie eine UID aussehen, und die Kamera sieht fremde Barcodes - ein Tag haben wir selbst beschrieben. Zwei Altlasten in denselben Dateien mit erledigt: - project.yml deklarierte die applinks-Domain weiter, obwohlf981f41sie entfernt hat. XcodeGen erzeugt die Entitlements aus diesem Block und schrieb sie bei jedem "generate" zurueck; die Domain existiert laengst nicht mehr. - AssignScanView sendete ItemUpdateRequest ohne priceCents/currency. Da encode immer alle Felder schreibt (auch null), hat ein Zuordnen per Scan Kaufpreis und Waehrung stillschweigend geloescht. Ebenfalls raus: QRImage und qrLink(forUid:) in ItemViews - ungenutzt, und qrLink baute noch das alte https-Format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6.6 KiB
Swift
176 lines
6.6 KiB
Swift
import SwiftUI
|
||
|
||
/// Nur ansehen: Barcode → Artikel, Einzelstück-QR → genau dieses Stück,
|
||
/// Lagerort-QR → Liste aller Artikel an diesem Ort. Kein Ein-/Auslagern.
|
||
struct LookupScanView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var paused = false
|
||
@State private var torchOn = false
|
||
@State private var torchLocked = false
|
||
@State private var error: String?
|
||
@State private var item: Item?
|
||
@State private var product: Product?
|
||
@State private var contents: LocationContents?
|
||
|
||
var body: some View {
|
||
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, Einzelstück- oder Lagerort-QR scannen – nur ansehen")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
|
||
// Eingenähte Tags an Kleidung lassen sich nicht scannen.
|
||
NFCLesenButton { uid in
|
||
Task {
|
||
paused = true
|
||
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||
item = found
|
||
} else {
|
||
error = "Kein Einzelstück mit der UID \(uid)."
|
||
paused = false
|
||
}
|
||
}
|
||
} onFehler: { meldung in
|
||
error = meldung
|
||
}
|
||
|
||
if let error {
|
||
Text(error).font(.callout).foregroundStyle(.red)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal)
|
||
}
|
||
}
|
||
.padding(.vertical)
|
||
}
|
||
.navigationTitle("Nachschlagen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||
}
|
||
.sheet(item: $item, onDismiss: { paused = false }) { it in
|
||
NavigationStack { ItemEditView(item: it) }
|
||
}
|
||
.sheet(item: $product, onDismiss: { paused = false }) { p in
|
||
NavigationStack { ProductDetailView(product: p) }
|
||
.environmentObject(display)
|
||
}
|
||
.sheet(item: $contents, onDismiss: { paused = false }) { c in
|
||
NavigationStack { LocationContentsView(contents: c) }
|
||
.environmentObject(display)
|
||
}
|
||
}
|
||
|
||
private func resolve(_ code: String) async {
|
||
paused = true
|
||
error = nil
|
||
// Einzelstück-QR (…/i/<UID>)?
|
||
if let uid = einzelstueckCode(aus: code),
|
||
let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||
item = found
|
||
return
|
||
}
|
||
// Lagerort-QR (…/l/<Code>)? → alles zeigen, was dort (inkl. Unterorte) liegt.
|
||
if let r = code.range(of: "/l/") {
|
||
let loc = String(code[r.upperBound...])
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
|
||
if !loc.isEmpty {
|
||
do {
|
||
contents = try await APIClient.shared.locationContents(code: loc)
|
||
} catch {
|
||
self.error = "Lagerort nicht gefunden: \(loc)"
|
||
paused = false
|
||
}
|
||
return
|
||
}
|
||
}
|
||
// Sonst als Barcode auflösen.
|
||
do {
|
||
let result = try await APIClient.shared.lookup(barcode: code)
|
||
if let existing = result.existingProduct {
|
||
product = existing
|
||
} else {
|
||
error = "Nicht im Katalog: \(code)"
|
||
paused = false
|
||
}
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
paused = false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Liste aller Artikel eines gescannten Lagerorts (inkl. Unterorte).
|
||
struct LocationContentsView: View {
|
||
let contents: LocationContents
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
var body: some View {
|
||
List {
|
||
if contents.products.isEmpty {
|
||
Text("Hier ist nichts eingelagert.").foregroundStyle(.secondary)
|
||
} else {
|
||
Section {
|
||
ForEach(contents.products) { e in
|
||
NavigationLink {
|
||
ProductLoaderView(productId: e.productId).environmentObject(display)
|
||
} label: {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(e.name)
|
||
if let brand = e.brand, !brand.isEmpty {
|
||
Text(brand).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
Text("\(mengeText(e.stock)) \(e.unitLabel)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("Bestand inkl. der Unterorte. Tippen öffnet den Artikel.")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(contents.locationName)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
private func mengeText(_ v: Double) -> String {
|
||
v == v.rounded() ? String(Int(v)) : String(format: "%.2f", v)
|
||
}
|
||
}
|
||
|
||
/// Lädt einen Artikel per ID nach und zeigt dann seine Detailansicht.
|
||
struct ProductLoaderView: View {
|
||
let productId: Int
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
@State private var product: Product?
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let product {
|
||
ProductDetailView(product: product).environmentObject(display)
|
||
} else if let error {
|
||
Text(error).foregroundStyle(.red).padding()
|
||
} else {
|
||
ProgressView()
|
||
}
|
||
}
|
||
.task {
|
||
do { product = try await APIClient.shared.product(id: productId) }
|
||
catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|
||
}
|