Files
Vorrania/ios/Sources/CheckInFormView.swift
Scarriffle 7c359a8f5d iOS: Eingabefelder linksbuendig - getippte Leerzeichen sind sofort sichtbar
LabeledField war rechtsbuendig; iOS rendert bei rechtsbuendigen Feldern ein
getipptes Leerzeichen am Ende nicht, bis ein weiteres Zeichen folgt (wirkt, als
reagiere die Eingabe nicht). Linksbuendig behebt das und liest sich bei Name/
Marke ohnehin natuerlicher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 17:44:22 +02:00

417 lines
16 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
/// Verweis auf die Charge, deren MHD gerade gescannt wird. Eigener Typ, damit
/// Foundations UUID nicht nachtraeglich Identifiable zugeschrieben bekommt.
struct LineRef: Identifiable {
let id: UUID
}
extension View {
/// Dezente Umrandung fuer Eingabefelder in einem Formular.
///
/// `.textFieldStyle(.roundedBorder)` zeichnet in der dunklen Darstellung
/// einen fast schwarzen Kasten, der sich hart von der Zeile abhebt. Eine
/// Systemfuellung passt sich Hell und Dunkel an und bleibt zurueckhaltend,
/// macht das Feld aber weiterhin als Eingabe erkennbar.
func fieldBox(width: CGFloat) -> some View {
self
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: width)
.background(Color(.tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
/// Textzeile mit Beschriftung links und sichtbarem Rahmen.
struct LabeledField: View {
let label: String
@Binding var text: String
var keyboard: UIKeyboardType = .default
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
TextField(label, text: $text)
.keyboardType(keyboard)
// Linksbündig: bei rechtsbündigen Feldern rendert iOS ein
// getipptes Leerzeichen am Ende nicht, bis ein weiteres Zeichen
// folgt das wirkt, als würde die Eingabe nicht reagieren.
.multilineTextAlignment(.leading)
.fieldBox(width: 190)
}
}
}
/// Zahleneingabe mit Beschriftung links und sichtbarem Rahmen.
///
/// Ein rechtsbuendiges Textfeld ohne Rahmen liest sich wie fester Text - beim
/// Testen war nicht erkennbar, dass sich die Menge ueberhaupt aendern laesst.
struct QuantityField: View {
let label: String
@Binding var text: String
var suffix: String?
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
TextField(label, text: $text)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.fieldBox(width: 110)
if let suffix {
Text(suffix)
.foregroundStyle(.secondary)
.frame(minWidth: 60, alignment: .leading)
}
}
}
}
extension Date {
/// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt.
var startOfMonth: Date {
let calendar = Calendar.current
let parts = calendar.dateComponents([.year, .month], from: self)
return calendar.date(from: parts) ?? self
}
}
/// Monat und Jahr auswaehlen. SwiftUI kennt keinen DatePicker ohne Tag,
/// deshalb zwei schmale Auswahlfelder nebeneinander.
struct MonthYearPicker: View {
@Binding var date: Date
// Fest deutsch, damit die Monatsnamen auch dann stimmen, wenn das Geraet
// auf einer anderen Sprache steht.
private var calendar: Calendar {
var kalender = Calendar(identifier: .gregorian)
kalender.locale = Locale(identifier: "de_DE")
return kalender
}
private var monthNames: [String] { calendar.monthSymbols }
private var years: [Int] {
let current = calendar.component(.year, from: Date())
// Rueckwaerts fuer bereits abgelaufene Ware, vorwaerts fuer Konserven.
return Array((current - 2)...(current + 15))
}
private var month: Binding<Int> {
Binding(
get: { calendar.component(.month, from: date) },
set: { date = replacing(month: $0, year: calendar.component(.year, from: date)) }
)
}
private var year: Binding<Int> {
Binding(
get: { calendar.component(.year, from: date) },
set: { date = replacing(month: calendar.component(.month, from: date), year: $0) }
)
}
private func replacing(month: Int, year: Int) -> Date {
calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? date
}
var body: some View {
HStack {
Text("MHD")
Spacer()
Picker("Monat", selection: month) {
ForEach(1...12, id: \.self) { Text(monthNames[$0 - 1]).tag($0) }
}
.labelsHidden()
Picker("Jahr", selection: year) {
ForEach(years, id: \.self) { Text(String($0)).tag($0) }
}
.labelsHidden()
}
}
}
/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD.
struct CheckInFormView: View {
let product: Product
let units: [Unit]
let locations: [StorageLocation]
var onDone: (String) -> Void
@Environment(\.dismiss) private var dismiss
struct Line: Identifiable {
let id = UUID()
var quantity: String = "1"
var hasDate: Bool = false
var bestBefore: Date = Date()
}
@State private var lines: [Line] = [Line()]
/// Gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder ein
/// Tagesdatum oder nur Monat/Jahr - nicht beides gemischt.
@State private var precision: String = "day"
/// Charge, fuer die gerade das MHD abgescannt wird.
@State private var scanLineId: LineRef?
@State private var unit: String = ""
@State private var locationId: String?
@State private var busy = false
@State private var error: String?
// Lagerort per QR (/l/<Code>) setzen, statt in der Liste zu suchen.
@State private var locScanShown = false
@State private var locScanPaused = false
@State private var locTorchOn = false
@State private var locTorchLocked = false
@State private var locScanError: String?
@State private var locScanHinweis: String?
/// Einheiten der passenden Art plus das Gebinde des Artikels.
private var unitOptions: [UnitOption] {
var options = units
.filter { $0.kind == product.kind }
.map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
options.append(UnitOption(value: UnitOption.packageValue,
label: product.packageLabel ?? "Packung"))
}
return options
}
private var hasPackage: Bool { (product.packageSize ?? 0) > 0 }
var body: some View {
Form {
Section {
HStack {
VStack(alignment: .leading) {
Text(product.name).font(.headline)
Text("Bestand: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
}
Section("Einheit") {
Picker("Einheit", selection: $unit) {
ForEach(unitOptions) { Text($0.label).tag($0.value) }
}
}
if !locations.isEmpty {
Section {
Picker("Lagerort", selection: $locationId) {
Text(" keiner ").tag(String?.none)
ForEach(locations) { location in
Text(location.path(in: locations)).tag(String?.some(location.id))
}
}
Button {
locScanError = nil
locScanPaused = false
locScanShown = true
} label: {
Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder")
}
if let locScanHinweis {
Label(locScanHinweis, systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
}
} header: {
Text("Lagerort")
} footer: {
Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.")
}
}
Section("MHD") {
Picker("Angabe", selection: $precision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
}
// Jede Charge bekommt einen eigenen Abschnitt. Vorher standen alle
// Zeilen gestapelt in einer einzigen Formularzeile und wirkten
// dadurch gequetscht.
ForEach(Array($lines.enumerated()), id: \.element.id) { index, $line in
Section {
QuantityField(label: "Menge", text: $line.quantity)
Toggle("MHD angeben", isOn: $line.hasDate)
if line.hasDate {
if precision == "month" {
MonthYearPicker(date: $line.bestBefore)
} else {
DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date)
}
if DateScanView.isSupported {
Button {
scanLineId = LineRef(id: line.id)
} label: {
Label("MHD scannen", systemImage: "text.viewfinder")
}
}
}
} header: {
HStack {
Text(lines.count > 1 ? "Charge \(index + 1)" : "Charge")
Spacer()
if lines.count > 1 {
Button(role: .destructive) {
lines.removeAll { $0.id == line.id }
} label: {
Label("Entfernen", systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
}
}
}
}
Section {
Button {
lines.append(Line())
} label: {
Label("Weitere Charge (anderes MHD)", systemImage: "plus")
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Button(busy ? "Speichern…" : "Einlagern") { Task { await submit() } }
.disabled(busy)
}
}
.navigationTitle("Einlagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
.sheet(item: $scanLineId) { ziel in
NavigationStack {
DateScanView { datum, erkanntePraezision in
// Nennt der Aufdruck keinen Tag, stellt sich die Eingabe
// sichtbar auf Monat/Jahr um.
if erkanntePraezision == "month" { precision = "month" }
if let index = lines.firstIndex(where: { $0.id == ziel.id }) {
lines[index].bestBefore = datum
lines[index].hasDate = true
}
}
}
}
.fullScreenCover(isPresented: $locScanShown) { locationScannerCover }
.onAppear {
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
unit = hasPackage ? UnitOption.packageValue : product.unitName
// Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr steht.
precision = product.datePrecision == "month" ? "month" : "day"
}
}
// MARK: - Lagerort scannen
private var locationScannerCover: some View {
NavigationStack {
ScannerView(onCode: { code in Task { await handleLocationScan(code) } },
isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
VStack(spacing: 6) {
if let locScanError {
Text(locScanError)
.font(.callout).foregroundStyle(.white)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.red, in: Capsule())
}
Text("QR am Regal/Fach vor die Kamera halten")
.font(.callout)
.padding(10)
.background(.ultraThinMaterial, in: Capsule())
}
.padding(.bottom, 24)
}
.navigationTitle("Lagerort scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { locScanShown = false }
}
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $locTorchOn, locked: $locTorchLocked)
}
}
}
.onAppear { locScanPaused = false; locScanError = nil }
}
/// Aus einem gescannten Lagerort-QR (/l/<Code>) den Zielort setzen.
private func handleLocationScan(_ code: String) async {
guard let r = code.range(of: "/l/") else {
locScanError = "Das ist kein Lagerort-QR."
return
}
let ziel = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if let treffer = locations.first(where: { $0.id == ziel }) {
locationId = treffer.id
locScanHinweis = treffer.name
locScanShown = false
} else {
locScanError = "Dieser Lagerort ist hier nicht bekannt."
}
}
private func format(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
}
private func submit() async {
error = nil
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let payloadLines: [CheckInLine] = lines.compactMap { line in
guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")),
quantity > 0 else { return nil }
// Bei Monatsangabe genuegt der Monatserste - der Server legt daraus
// den Monatsletzten und merkt sich die Genauigkeit.
let date = precision == "month" ? line.bestBefore.startOfMonth : line.bestBefore
return CheckInLine(
quantity: quantity,
bestBefore: line.hasDate ? formatter.string(from: date) : nil,
bestBeforePrecision: precision,
locationId: locationId
)
}
guard !payloadLines.isEmpty else {
error = "Bitte mindestens eine Menge angeben."
return
}
busy = true
defer { busy = false }
do {
let response = try await APIClient.shared.checkInBatch(
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
)
let total = response.productStock / product.articleUnitFactor
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}