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>
275 lines
9.5 KiB
Swift
275 lines
9.5 KiB
Swift
import SwiftUI
|
||
|
||
/// Produkt ansehen und ändern, dazu die Chargen korrigieren oder löschen.
|
||
struct ProductDetailView: View {
|
||
let product: Product
|
||
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var current: Product
|
||
@State private var lots: [Lot] = []
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
@State private var status: String?
|
||
|
||
// Bearbeitbare Felder
|
||
@State private var name = ""
|
||
@State private var brand = ""
|
||
@State private var packageSize = ""
|
||
@State private var packageLabel = ""
|
||
@State private var datePrecision = "day"
|
||
@State private var groupId: Int?
|
||
@State private var categories: [GroupItem] = []
|
||
|
||
@State private var editLot: Lot?
|
||
|
||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||
|
||
init(product: Product) {
|
||
self.product = product
|
||
_current = State(initialValue: product)
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
if let status {
|
||
Section { Text(status).foregroundStyle(.green).font(.callout) }
|
||
}
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
|
||
Section("Bestand") {
|
||
LabeledContent("Vorrat",
|
||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
||
if current.expiredCount > 0 {
|
||
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
|
||
// Beschriftungen links, Werte rechts: Ohne sie las man nur noch
|
||
// "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang.
|
||
Section("Artikel") {
|
||
LabeledField(label: "Name", text: $name)
|
||
LabeledField(label: "Marke", text: $brand)
|
||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
||
suffix: display.baseUnitLabel(current.baseUnit))
|
||
Picker("Bezeichnung", selection: $packageLabel) {
|
||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||
}
|
||
Picker("Kategorie", selection: $groupId) {
|
||
Text("– keine –").tag(Int?.none)
|
||
ForEach(categories) { kategorie in
|
||
Text(kategorie.name).tag(Int?.some(kategorie.id))
|
||
}
|
||
}
|
||
Picker("MHD-Angabe", selection: $datePrecision) {
|
||
Text("Tagesdatum").tag("day")
|
||
Text("nur Monat/Jahr").tag("month")
|
||
}
|
||
}
|
||
|
||
Section("Erkennung") {
|
||
LabeledContent("Barcode", value: current.barcode ?? "–")
|
||
if !current.barcodes.isEmpty {
|
||
LabeledContent("Weitere Codes", value: "\(current.barcodes.count)")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } }
|
||
.disabled(busy || name.isEmpty)
|
||
}
|
||
|
||
Section("Chargen") {
|
||
ForEach(lots) { lot in
|
||
Button {
|
||
editLot = lot
|
||
} label: {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
||
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
.foregroundStyle(.primary)
|
||
}
|
||
.onDelete { indexSet in
|
||
Task { await deleteLots(at: indexSet) }
|
||
}
|
||
if lots.isEmpty {
|
||
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(current.name)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.sheet(item: $editLot) { lot in
|
||
NavigationStack {
|
||
LotEditView(lot: lot, product: current) {
|
||
Task { await reload() }
|
||
}
|
||
}
|
||
}
|
||
.task {
|
||
fillForm()
|
||
await reload()
|
||
}
|
||
}
|
||
|
||
private func fillForm() {
|
||
name = current.name
|
||
brand = current.brand ?? ""
|
||
packageSize = current.packageSize.map { formatAmount($0) } ?? ""
|
||
packageLabel = current.packageLabel ?? ""
|
||
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
||
groupId = current.groupId
|
||
}
|
||
|
||
private func reload() async {
|
||
categories = (try? await APIClient.shared.groups()) ?? []
|
||
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
|
||
if let frisch = try? await APIClient.shared.product(id: current.id) {
|
||
current = frisch
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
error = nil
|
||
status = nil
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
current = try await APIClient.shared.updateProduct(
|
||
id: current.id,
|
||
ProductUpdateRequest(
|
||
name: name,
|
||
brand: brand.isEmpty ? nil : brand,
|
||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||
datePrecision: datePrecision,
|
||
groupId: groupId
|
||
)
|
||
)
|
||
status = "Gespeichert."
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func deleteLots(at indexSet: IndexSet) async {
|
||
error = nil
|
||
for index in indexSet {
|
||
do {
|
||
try await APIClient.shared.deleteLot(id: lots[index].id)
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
await reload()
|
||
}
|
||
}
|
||
|
||
/// Menge und MHD einer Charge korrigieren.
|
||
struct LotEditView: View {
|
||
let lot: Lot
|
||
let product: Product
|
||
var onSaved: () -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var quantity = ""
|
||
@State private var hasDate = false
|
||
@State private var bestBefore = Date()
|
||
@State private var precision = "day"
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("Menge") {
|
||
QuantityField(label: "Menge", text: $quantity,
|
||
suffix: product.articleUnitLabel)
|
||
}
|
||
|
||
Section("MHD") {
|
||
Toggle("MHD angeben", isOn: $hasDate)
|
||
if hasDate {
|
||
Picker("Angabe", selection: $precision) {
|
||
Text("Tagesdatum").tag("day")
|
||
Text("nur Monat/Jahr").tag("month")
|
||
}
|
||
if precision == "month" {
|
||
MonthYearPicker(date: $bestBefore)
|
||
} else {
|
||
DatePicker("MHD", selection: $bestBefore, displayedComponents: .date)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
|
||
Section {
|
||
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
|
||
.disabled(busy)
|
||
}
|
||
}
|
||
.navigationTitle("Charge ändern")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
}
|
||
.onAppear(perform: fill)
|
||
}
|
||
|
||
private func fill() {
|
||
quantity = formatAmount(lot.quantity / product.articleUnitFactor)
|
||
precision = lot.bestBeforePrecision == "month" ? "month" : "day"
|
||
if let raw = lot.bestBefore {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
if let datum = formatter.date(from: raw) {
|
||
bestBefore = datum
|
||
hasDate = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
error = nil
|
||
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
|
||
error = "Bitte eine Menge größer 0 angeben."
|
||
return
|
||
}
|
||
busy = true
|
||
defer { busy = false }
|
||
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
let datum = precision == "month" ? bestBefore.startOfMonth : bestBefore
|
||
|
||
do {
|
||
// Das Backend rechnet Chargenmengen in Basiseinheiten.
|
||
_ = try await APIClient.shared.updateLot(
|
||
id: lot.id,
|
||
LotUpdateRequest(
|
||
quantity: menge * product.articleUnitFactor,
|
||
bestBefore: hasDate ? formatter.string(from: datum) : nil,
|
||
bestBeforePrecision: precision
|
||
)
|
||
)
|
||
onSaved()
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|