Files
Vorrania/ios/Sources/LookupScanView.swift
T
ScarriffleandClaude Opus 5 09a763a823 NFC-Tags fuer Einzelstuecke: beschreiben und lesen
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 (siehe f981f41).

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, obwohl f981f41 sie
  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>
2026-09-12 13:37:24 +02:00

176 lines
6.6 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
/// 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 }
}
}
}