Files
Vorrania/ios/Sources/AssignScanView.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

173 lines
7.5 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
/// Zuordnen per Scan: erst ein Einzelstück (…/i/<UID>) scannen, dann einen
/// Lagerort (…/l/<ID>) – das Stück wird dem Ort zugewiesen. Danach gleich das
/// nächste Stück, ohne Tippen.
struct AssignScanView: View {
@Environment(\.dismiss) private var dismiss
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@State private var pending: Item? // gescanntes/gewähltes Stück, wartet auf Ort
@State private var status: String?
@State private var error: String?
@State private var showPicker = false
var body: some View {
ScrollView {
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await handle(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
if let pending {
VStack(spacing: 4) {
Text("Einzelstück \(pending.uid)").font(.headline)
Text("Jetzt den Lagerort-QR scannen")
.font(.caption).foregroundStyle(.secondary)
Button("Abbrechen") { self.pending = nil }
.font(.caption).padding(.top, 2)
}
.padding().frame(maxWidth: .infinity)
.background(Color.marke.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal)
} else {
VStack(spacing: 8) {
Text("Schritt 1: Einzelstück-QR scannen – oder ohne Code:")
.font(.caption).foregroundStyle(.secondary)
// Kleidung trägt keinen QR, aber ein eingenähtes NFC-Tag.
NFCLesenButton(titel: "Einzelstück-Tag lesen") { uid in
Task {
let gefunden = await waehle(uid: uid)
if !gefunden {
error = "Kein Einzelstück mit der UID \(uid)."
status = nil
}
}
} onFehler: { meldung in
error = meldung; status = nil
}
// Für Stücke ohne Code jeder Art: aus der Liste wählen.
Button { showPicker = true } label: {
Label("Einzelstück aus Liste wählen", systemImage: "list.bullet")
}
}
}
if let status {
Text(status).font(.callout).foregroundStyle(.green)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
}
if let error {
Text(error).font(.callout).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
}
}
.padding(.vertical)
}
.navigationTitle("Ort zuordnen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
}
.sheet(isPresented: $showPicker) {
ItemPickerSheet { it in pending = it; status = nil; error = nil }
}
}
private func handle(_ code: String) async {
// Lagerort-QR (…/l/<Code>)? Der Code ist eine 10-stellige Zeichenkette.
if let r = code.range(of: "/l/") {
let locCode = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if !locCode.isEmpty {
guard let it = pending else {
error = "Erst ein Einzelstück scannen."; status = nil; return
}
do {
// Alle Felder mitsenden: ItemUpdateRequest schreibt auch null
// mit, damit sich Felder leeren lassen. Kaufpreis und Waehrung
// fehlten hier – ein Zuordnen per Scan hat sie stillschweigend
// geloescht.
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
locationId: locCode, shopId: it.shopId,
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note,
priceCents: it.priceCents, currency: it.currency))
status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
error = nil
pending = nil
} catch { self.error = error.localizedDescription }
return
}
}
// Einzelstück-QR (…/i/<UID>)?
if let uid = einzelstueckCode(aus: code), await waehle(uid: uid) {
return
}
error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen."
}
/// Einzelstück als „wartet auf Lagerort" vormerken – aus Scan, NFC oder Liste.
/// Gibt zurueck, ob es gefunden wurde; der Aufrufer entscheidet ueber die
/// Fehlermeldung, weil ein Scan noch andere Deutungen zulaesst als ein Tag.
@discardableResult
private func waehle(uid: String) async -> Bool {
guard let it = try? await APIClient.shared.itemByUid(uid: uid) else { return false }
pending = it
status = nil
error = nil
return true
}
}
/// Einzelstück aus einer durchsuchbaren Liste wählen – für Stücke ohne eigenen QR.
struct ItemPickerSheet: View {
var onPick: (Item) -> Void
@Environment(\.dismiss) private var dismiss
@State private var items: [Item] = []
@State private var locations: [StorageLocation] = []
@State private var query = ""
@State private var busy = true
private var gefiltert: [Item] {
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
guard !q.isEmpty else { return items }
return items.filter {
[$0.productName, $0.uid, $0.productBrand, $0.note]
.compactMap { $0 }.joined(separator: " ").lowercased().contains(q)
}
}
var body: some View {
NavigationStack {
List {
if busy && items.isEmpty { ProgressView() }
ForEach(gefiltert) { it in
Button { onPick(it); dismiss() } label: {
ItemRow(item: it, locations: locations)
}
.buttonStyle(.plain)
}
if !busy && gefiltert.isEmpty {
Text("Nichts gefunden.").foregroundStyle(.secondary)
}
}
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
.navigationTitle("Einzelstück wählen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.task {
items = (try? await APIClient.shared.allItems()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
busy = false
}
}
}
}