- Kassenzettel-Kachel vom Listen- in den Scannen-Tab verschoben (passt thematisch). - App-Akzent auf Markenblau (#3F51B5/#7C8AE8, wie Web/App-Icon) via .tint; alle Color.accentColor -> Color.marke. System-Blau verschwindet. - Kachel-Buttons/Links auf .buttonStyle(.plain): weisse Schrift/Symbole statt getoentem Blau. - Kassenzettel-Pruefen-Liste: je Artikel eine eigene Section (insetGrouped) mit Artikel/Menge/Lagerort in eigenen Zeilen - klar gruppiert, nicht mehr gequetscht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
6.3 KiB
Swift
149 lines
6.3 KiB
Swift
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)
|
||
// Für Stücke ohne eigenen QR (z.B. Kleidung): 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 {
|
||
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
|
||
locationId: locCode, shopId: it.shopId,
|
||
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note))
|
||
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 r = code.range(of: "/i/") {
|
||
let uid = String(code[r.upperBound...])
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
|
||
if !uid.isEmpty, let it = try? await APIClient.shared.itemByUid(uid: uid) {
|
||
pending = it; status = nil; error = nil
|
||
return
|
||
}
|
||
}
|
||
error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen."
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|
||
}
|