Files
Vorrania/ios/Sources/CheckInFormView.swift
Scarriffle 47ca4802aa Darstellung der App nachgebessert, Gruppen heissen jetzt Kategorien
Die App zeigte an mehreren Stellen Rohwerte statt lesbarer Angaben, und die
Bedienelemente sahen nicht nach Bedienelementen aus.

Deutsche Lokalisierung: Im Chargen-Abschnitt stand "10. Dec 2027" - deutsches
Format mit englischem Monatsnamen. Die Ursache lag nicht in der Formatierung,
sondern darin, dass die App als englische App gebaut wurde
(CFBundleDevelopmentRegion = $(DEVELOPMENT_LANGUAGE), also "en"). Jetzt ist
Deutsch als Sprache gesetzt; damit zeichnet iOS DatePicker und Monatsnamen von
selbst richtig. Der MonthYearPicker legt seinen Kalender zusaetzlich fest auf
de_DE, damit die Monatsnamen auch bei anderssprachigem Geraet stimmen.

Datumsformat: Die Anzeige folgt jetzt der Servereinstellung (Einstellungen ->
Darstellung), wie die Web-Oberflaeche. Neu DisplaySettings mit den fuenf
Formaten, den deutschen Beschriftungen der Basiseinheiten und der Mengenangabe
in der Leitangabe. Die doppelt ausprogrammierte MHD-Formatierung in
ListViews und CheckOutView ist damit weg.

Chargen-Abschnitt: Jede Charge steht jetzt in einem eigenen Abschnitt mit
Kopfzeile statt gestapelt in einer einzigen Formularzeile - das war der
gequetschte Eindruck. Loeschen sitzt in der Kopfzeile.

Mengenfelder haben einen sichtbaren Rahmen. Ohne ihn las sich das
rechtsbuendige Feld wie fester Text, und es war nicht erkennbar, dass sich die
Menge ueberhaupt aendern laesst.

Kamera: Der Sucher fuellte per ZStack und ignoresSafeArea den ganzen Bildschirm
und wirkte dadurch erdrueckend. Er sitzt jetzt als 4:3-Feld oben, darunter
Hinweis, Meldungen und Knoepfe in gewohnter Form. Am ScannerViewController
musste nichts geaendert werden.

Einkaufsliste: Artikel und Kategorien tragen ein Symbol, Kategoriezeilen
zusaetzlich eine Kennzeichnung. Den Artikelzeilen fehlte bisher jede Einheit
("fehlt 2 - Bestand 7 von 10"); sie nennen jetzt Packungen und Basismenge.

Produktdetails: Die Felder waren blanke TextFields - sobald ein Wert drinstand,
verschwand der Platzhalter und man las nur noch "Bratbutter" / "M-Classic" /
"450". Jetzt mit Beschriftung links, Einheit hinter der Packungsgroesse (vorher
stand dort der rohe API-Wert "gram") und einem Abschnitt "Erkennung" mit
Barcode. Dasselbe im Formular zum Anlegen.

Gruppen heissen in der Oberflaeche jetzt Kategorien - in Web und App. Tabelle,
Feld group_id und die Endpunkte behalten ihren Namen, damit bestehende Zugriffe
wie Home Assistant weiterlaufen; der Unterschied ist rein sprachlich. Die
Kategorie laesst sich nun auch in der App zuweisen, was vorher gar nicht ging.

Dabei ein Fehler gefunden und behoben: Swift laesst nil-Optionals beim Kodieren
weg, das Backend wertet nur mitgeschickte Felder aus. "Keine Kategorie" oder
eine geloeschte Marke waeren damit stumm verpufft. ProductUpdateRequest kodiert
diese Felder jetzt ausdruecklich. Der Mindestbestand steht bewusst nicht mehr
darin, weil die App ihn nicht bearbeitet und ein mitgesendetes null ihn
geloescht haette.

Getestet: iOS-Geraetebuild fehler- und warnungsfrei, Web-Build laeuft durch,
40 pytest-Tests gruen. Die Datumsformatierung wurde eigenstaendig uebersetzt
und gegen elf Faelle geprueft (alle fuenf Formate, Monatsangabe, fehlendes und
unlesbares Datum) - alle korrekt. Im gebauten Bundle ist belegt, dass
CFBundleDevelopmentRegion auf "de" steht. Gegen die laufende API geprueft:
/settings und /groups liefern die erwarteten Felder, das Leeren der Kategorie
wirkt jetzt und der Mindestbestand bleibt dabei erhalten.
Die Oberflaeche selbst habe ich nicht auf dem Geraet bedient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:04:58 +02:00

318 lines
11 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
}
/// 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)
.multilineTextAlignment(.trailing)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 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)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 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: Int?
@State private var busy = false
@State private var error: 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 {
Picker("Lagerort", selection: $locationId) {
Text(" keiner ").tag(Int?.none)
ForEach(locations) { location in
Text(location.name).tag(Int?.some(location.id))
}
}
}
}
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
}
}
}
}
.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"
}
}
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
}
}
}