From 47ca4802aa29e0bc385517f7d8218ea1e1dcb263 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 19:04:58 +0200 Subject: [PATCH] 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 --- docs/ROADMAP.md | 21 +++-- ios/README.md | 23 ++++-- ios/Sources/APIClient.swift | 8 ++ ios/Sources/CheckInFormView.swift | 124 ++++++++++++++++++++-------- ios/Sources/CheckInView.swift | 23 ++++-- ios/Sources/CheckOutView.swift | 41 ++++----- ios/Sources/DisplaySettings.swift | 88 ++++++++++++++++++++ ios/Sources/Info.plist | 8 +- ios/Sources/ListViews.swift | 81 ++++++++++++------ ios/Sources/Models.swift | 38 ++++++++- ios/Sources/ProductDetailView.swift | 43 ++++++---- ios/Sources/ProductViews.swift | 24 ++++-- ios/Sources/ProjectGoodApp.swift | 2 + ios/Sources/RootView.swift | 5 ++ ios/project.yml | 3 + web/src/App.jsx | 2 +- web/src/pages/CheckIn.jsx | 6 +- web/src/pages/Dashboard.jsx | 2 +- web/src/pages/Groups.jsx | 16 ++-- web/src/pages/ProductForm.jsx | 4 +- web/src/pages/ShoppingList.jsx | 4 +- web/src/pages/Transfer.jsx | 4 +- 22 files changed, 420 insertions(+), 150 deletions(-) create mode 100644 ios/Sources/DisplaySettings.swift diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f55dc41..7c1e04e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,13 +39,24 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. - **Migration:** schonendes Nachziehen neuer Spalten beim Start (`ADD COLUMN IF NOT EXISTS`), damit bestehende Installationen ihre Daten behalten. -## Schritt 2 – Native iOS-App (SwiftUI) – offen -- Login (Server-URL + Passwort, Token im Keychain). -- Barcode-Scan (VisionKit / AVFoundation). +## Schritt 2 – Native iOS-App (SwiftUI) ✅ +- Login (Server-URL + Passwort, Token im Keychain), „angemeldet bleiben“ abwählbar. +- Barcode-Scan (AVFoundation) mit Nahbereich-Autofokus, Licht und Tippen zum Scharfstellen. - Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD (mehrere Chargen). +- **MHD per Texterkennung** ablesbar (VisionKit); erkannte Daten werden vorgeschlagen, + nie ungefragt übernommen. - Auslagern-Flow: scannen → Menge/Einheit, FEFO oder Chargenauswahl. -- Einkaufsliste- und „bald ablaufend“-Ansicht. -- Produkte bei Bedarf direkt in der App anlegen. +- Einkaufsliste, „bald ablaufend“ und Produktliste mit Bearbeiten. +- Home-Screen-Shortcuts und URL-Schema `projectgood://checkin` / `checkout`. +- Anzeige folgt dem Datumsformat aus den Servereinstellungen. + +## Schritt 2b – MHD-Genauigkeit und Benennung ✅ +- **MHD wahlweise nur mit Monat und Jahr.** Gespeichert wird weiterhin ein echtes + Datum (Monatsletzter), damit FEFO unverändert arbeitet; die Genauigkeit steht + getrennt an Charge und Produkt. Export/Import bleiben verlustfrei. +- **Gruppen heißen in der Oberfläche jetzt Kategorien.** Tabelle, Feld `group_id` + und die Endpunkte behalten ihren Namen, damit bestehende Zugriffe weiterlaufen. +- **Eigenes Logo und Favicon** je Installation (Einstellungen → Darstellung). ## Schritt 3 – Weitere Ideen – offen - **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus). diff --git a/ios/README.md b/ios/README.md index 93c432e..38b854c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -3,10 +3,10 @@ Native SwiftUI-App zum Ein- und Auslagern per Barcode-Scan. Sie spricht dieselbe REST-API wie die Web-Oberfläche. -> **Stand:** Erste lauffähige Fassung (Login, Scanner, Einlagern mit mehreren -> Chargen/MHDs, Auslagern mit Chargenauswahl, Artikel anlegen aus Open Food Facts). -> Sie wurde bisher **nicht in Xcode kompiliert** – auf dem Entwicklungsrechner ist -> kein macOS/Xcode vorhanden. Rechne beim ersten Build mit Kleinigkeiten. +> **Stand:** Läuft auf dem Gerät. Login, Scanner, Einlagern mit mehreren +> Chargen/MHDs, MHD per Texterkennung, Auslagern mit Chargenauswahl, Artikel +> anlegen aus Open Food Facts, Einkaufsliste, Ablaufliste sowie Produkte +> ansehen und bearbeiten. ## Projekt in Xcode öffnen @@ -65,10 +65,17 @@ Beide Wege öffnen die App **direkt in der Kamera**. | `CheckInView.swift` / `CheckInFormView.swift` | Scan → Menge, mehrere Chargen mit MHD | | `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO | | `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) | +| `ProductDetailView.swift` | Produkt bearbeiten, Chargen korrigieren und löschen | +| `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste | +| `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen | +| `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server | + +## Kategorien +Was in der Datenbank und in der Schnittstelle `group` heißt, trägt in der +Oberfläche den Namen **Kategorie** („Molkereiprodukte", „Brot", …). Der Name in +API und Tabelle blieb bewusst unverändert, damit bestehende Zugriffe – etwa aus +Home Assistant – weiterlaufen. ## Noch offen -- Einkaufsliste und „bald ablaufend" in der App -- Gruppenauswahl beim Anlegen (aktuell wird die Gruppe aus dem gescannten - EAN-Code übernommen, falls hinterlegt) - Push-Benachrichtigungen bei ablaufenden Produkten -- App-Icon +- Lagerort je Charge beim Einlagern wählbar diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 2f53332..b2e37d2 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -113,6 +113,14 @@ actor APIClient { return try await send(request, as: Product.self) } + func groups() async throws -> [GroupItem] { + try await send(try makeRequest("/groups"), as: [GroupItem].self) + } + + func settings() async throws -> [SettingEntry] { + try await send(try makeRequest("/settings"), as: [SettingEntry].self) + } + // MARK: - Listen func shoppingList() async throws -> [ShoppingItem] { diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index 0a43082..10b4a68 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -6,6 +6,52 @@ 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 { @@ -20,7 +66,13 @@ extension Date { struct MonthYearPicker: View { @Binding var date: Date - private let calendar = Calendar.current + // 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] { @@ -139,45 +191,47 @@ struct CheckInFormView: View { } } - Section("Chargen") { - ForEach($lines) { $line in - VStack(alignment: .leading, spacing: 6) { - HStack { - // Ohne Beschriftung sah die vorbelegte 1 wie eine - // Nummerierung der Charge aus. - Text("Menge") - Spacer(minLength: 12) - TextField("Menge", text: $line.quantity) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(maxWidth: 110) - if lines.count > 1 { - Button(role: .destructive) { - lines.removeAll { $0.id == line.id } - } label: { Image(systemName: "trash") } - .buttonStyle(.borderless) - } + // 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) } - 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") - .font(.callout) - } - .buttonStyle(.borderless) + if DateScanView.isSupported { + Button { + scanLineId = LineRef(id: line.id) + } label: { + Label("MHD scannen", systemImage: "text.viewfinder") } } } - .padding(.vertical, 2) + } 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: { diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 6d33e29..7afb3dd 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -18,12 +18,19 @@ struct CheckInView: View { @State private var locations: [StorageLocation] = [] var body: some View { - ZStack(alignment: .bottom) { - ScannerView(onCode: { code in Task { await resolve(code) } }, - isPaused: $paused, torchOn: $torchOn) - .ignoresSafeArea() + // Kamera bewusst nur als Feld oben statt bildschirmfuellend - der + // Sucher ueber die ganze Flaeche wirkte erschlagend. + ScrollView { + VStack(spacing: 14) { + ScannerView(onCode: { code in Task { await resolve(code) } }, + isPaused: $paused, torchOn: $torchOn) + .aspectRatio(4.0 / 3.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal) + + Text("Barcode vor die Kamera halten") + .font(.caption).foregroundStyle(.secondary) - VStack(spacing: 10) { if let status { banner(status, color: .green) } if let error { banner(error, color: .red) } @@ -33,7 +40,7 @@ struct CheckInView: View { unknownCard(unknownCode) } - HStack(spacing: 10) { + VStack(spacing: 10) { Button { paused = true manualCodeShown = true @@ -54,10 +61,8 @@ struct CheckInView: View { .buttonStyle(.bordered) } .padding(.horizontal) - .padding(.bottom, 8) } - .padding(.bottom, 4) - .background(.ultraThinMaterial.opacity(0.001)) + .padding(.vertical) } .navigationTitle("Einlagern") .navigationBarTitleDisplayMode(.inline) diff --git a/ios/Sources/CheckOutView.swift b/ios/Sources/CheckOutView.swift index 5e4a4c5..dd4f8f7 100644 --- a/ios/Sources/CheckOutView.swift +++ b/ios/Sources/CheckOutView.swift @@ -14,16 +14,22 @@ struct CheckOutView: View { @State private var searchShown = false var body: some View { - ZStack(alignment: .bottom) { - ScannerView(onCode: { code in Task { await resolve(code) } }, - isPaused: $paused, torchOn: $torchOn) - .ignoresSafeArea() + // Kamera als Feld oben, siehe CheckInView. + ScrollView { + VStack(spacing: 14) { + ScannerView(onCode: { code in Task { await resolve(code) } }, + isPaused: $paused, torchOn: $torchOn) + .aspectRatio(4.0 / 3.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal) + + Text("Barcode vor die Kamera halten") + .font(.caption).foregroundStyle(.secondary) - VStack(spacing: 10) { if let status { banner(status, color: .green) } if let error { banner(error, color: .red) } - HStack(spacing: 10) { + VStack(spacing: 10) { Button { paused = true searchShown = true @@ -43,8 +49,8 @@ struct CheckOutView: View { .buttonStyle(.bordered) } .padding(.horizontal) - .padding(.bottom, 8) } + .padding(.vertical) } .navigationTitle("Auslagern") .navigationBarTitleDisplayMode(.inline) @@ -119,6 +125,7 @@ struct CheckOutFormView: View { var onDone: (String) -> Void @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var display: DisplaySettings @State private var quantity = "1" @State private var unit = "" @@ -159,14 +166,7 @@ struct CheckOutFormView: View { } Section("Menge") { - HStack { - Text("Menge") - Spacer(minLength: 12) - TextField("Menge", text: $quantity) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(maxWidth: 110) - } + QuantityField(label: "Menge", text: $quantity) Picker("Einheit", selection: $unit) { ForEach(unitOptions) { Text($0.label).tag($0.value) } } @@ -194,16 +194,7 @@ struct CheckOutFormView: View { private func label(for lot: Lot) -> String { let amount = format(lot.quantity / product.articleUnitFactor) - return "\(bestBeforeText(lot)) · \(amount) \(product.articleUnitLabel)" - } - - /// Monatsangaben ohne Tag zeigen: "09/2026" statt "2026-09-30". - private func bestBeforeText(_ lot: Lot) -> String { - guard let raw = lot.bestBefore else { return "ohne MHD" } - guard lot.bestBeforePrecision == "month" else { return raw } - let parts = raw.split(separator: "-") - guard parts.count >= 2 else { return raw } - return "\(parts[1])/\(parts[0])" + return "\(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision)) · \(amount) \(product.articleUnitLabel)" } private func format(_ value: Double) -> String { diff --git a/ios/Sources/DisplaySettings.swift b/ios/Sources/DisplaySettings.swift new file mode 100644 index 0000000..4a72fa5 --- /dev/null +++ b/ios/Sources/DisplaySettings.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Anzeigeeinstellungen, die am Server hinterlegt sind. +/// +/// Das Datumsformat ist in der Web-Oberflaeche unter Einstellungen -> +/// Darstellung waehlbar. Die App liest denselben Wert, damit beide Oberflaechen +/// gleich aussehen. +@MainActor +final class DisplaySettings: ObservableObject { + static let shared = DisplaySettings() + + static let dateFormatKey = "date_format" + static let fallbackFormat = "de" + + @Published private(set) var dateFormat = DisplaySettings.fallbackFormat + + private init() {} + + /// Nach der Anmeldung aufrufen. Schlaegt der Abruf fehl, bleibt es beim + /// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige + /// nicht scheitern. + func load() async { + guard let entries = try? await APIClient.shared.settings() else { return } + if let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }), + !entry.value.isEmpty { + dateFormat = entry.value + } + } + + // MARK: - Datum + + /// MHD lesbar machen. Monatsangaben immer ohne Tag ("09/2026"), sonst nach + /// dem eingestellten Format. + func formatBestBefore(_ raw: String?, precision: String?) -> String { + guard let raw, !raw.isEmpty else { return "ohne MHD" } + guard let date = DisplaySettings.isoParser.date(from: raw) else { return raw } + + let muster = precision == "month" ? "MM/yyyy" : DisplaySettings.pattern(for: dateFormat) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "de_DE") + formatter.dateFormat = muster + return formatter.string(from: date) + } + + /// Entspricht den Formaten aus web/src/units.js (DATE_FORMATS). + static func pattern(for format: String) -> String { + switch format { + case "de-short": return "dd.MM.yy" + case "long": return "d. MMMM yyyy" + case "iso": return "yyyy-MM-dd" + case "us": return "MM/dd/yyyy" + default: return "dd.MM.yyyy" // "de" + } + } + + private static let isoParser: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + // MARK: - Einheiten + + /// Kanonische Basiseinheit der API auf Deutsch. + func baseUnitLabel(_ raw: String?) -> String { + DisplaySettings.baseUnitLabel(raw) + } + + static func baseUnitLabel(_ raw: String?) -> String { + switch raw { + case "piece": return "Stück" + case "gram": return "Gramm" + case "milliliter": return "Milliliter" + default: return raw ?? "" + } + } + + /// Menge in der Leitangabe: Gibt es ein Gebinde, zaehlt das, die Basismenge + /// steht dahinter in Klammern (wie amountText in der Web-Oberflaeche). + func amountText(_ quantity: Double, packageSize: Double?, baseUnit: String?) -> String { + let einheit = DisplaySettings.baseUnitLabel(baseUnit) + if let size = packageSize, size > 0 { + return "\(formatAmount(quantity / size)) Pkg. (\(formatAmount(quantity)) \(einheit))" + } + return "\(formatAmount(quantity)) \(einheit)" + } +} diff --git a/ios/Sources/Info.plist b/ios/Sources/Info.plist index 13ef2b9..25a8c3a 100644 --- a/ios/Sources/Info.plist +++ b/ios/Sources/Info.plist @@ -3,8 +3,14 @@ + CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + de + CFBundleLocalizations + + de + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 9bf92ab..bab71c4 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -5,18 +5,49 @@ func formatAmount(_ value: Double) -> String { value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) } -/// MHD lesbar: Monatsangaben ohne Tag ("09/2026"). -func formatBestBefore(_ raw: String?, precision: String?) -> String { - guard let raw else { return "ohne MHD" } - guard precision == "month" else { return raw } - let teile = raw.split(separator: "-") - guard teile.count >= 2 else { return raw } - return "\(teile[1])/\(teile[0])" +/// Kennzeichnung "Kategorie" an einer Listenzeile. +struct CategoryBadge: View { + var body: some View { + Text("Kategorie") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + .foregroundStyle(.secondary) + } +} + +/// Zeile mit Symbol links, damit Artikel und Kategorie unterscheidbar sind. +struct ListRow: View { + let systemImage: String + let title: String + let subtitle: String + @ViewBuilder var trailing: Trailing + + var body: some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.body) + .foregroundStyle(.secondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(title) + trailing + } + Text(subtitle) + .font(.caption).foregroundStyle(.secondary) + } + } + } } // MARK: - Einkaufsliste struct ShoppingListView: View { + @EnvironmentObject private var display: DisplaySettings + @State private var items: [ShoppingItem] = [] @State private var groups: [GroupShoppingItem] = [] @State private var busy = true @@ -30,22 +61,22 @@ struct ShoppingListView: View { if !items.isEmpty { Section("Artikel unter Mindestbestand") { ForEach(items) { item in - VStack(alignment: .leading, spacing: 2) { - Text(item.name) - Text("fehlt \(formatAmount(item.deficit)) · Bestand \(formatAmount(item.stock)) von \(formatAmount(item.minStock))") - .font(.caption).foregroundStyle(.secondary) - } + ListRow( + systemImage: "shippingbox", + title: item.name, + subtitle: "fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))" + ) { EmptyView() } } } } if !groups.isEmpty { - Section("Gruppen unter Mindestbestand") { + Section("Kategorien unter Mindestbestand") { ForEach(groups) { group in - VStack(alignment: .leading, spacing: 2) { - Text(group.name) - Text("fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel") - .font(.caption).foregroundStyle(.secondary) - } + ListRow( + systemImage: "square.stack.3d.up", + title: group.name, + subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel" + ) { CategoryBadge() } } } } @@ -76,6 +107,8 @@ struct ShoppingListView: View { // MARK: - Bald ablaufend struct ExpiringView: View { + @EnvironmentObject private var display: DisplaySettings + @State private var items: [ExpiringItem] = [] @State private var busy = true @State private var error: String? @@ -89,7 +122,7 @@ struct ExpiringView: View { HStack { VStack(alignment: .leading, spacing: 2) { Text(item.productName) - Text("MHD \(formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))") + Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))") .font(.caption).foregroundStyle(.secondary) } Spacer() @@ -148,11 +181,11 @@ struct ProductListView: View { NavigationLink { ProductDetailView(product: product) } label: { - VStack(alignment: .leading, spacing: 2) { - Text(product.name) - Text("\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)") - .font(.caption).foregroundStyle(.secondary) - } + ListRow( + systemImage: "shippingbox", + title: product.name, + subtitle: "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)" + ) { EmptyView() } } } if products.isEmpty && !busy { diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 9dee38a..398cb0d 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -303,21 +303,38 @@ struct ExpiringItem: Codable, Identifiable { // MARK: - Aenderungen -/// Nur gesetzte Felder werden geschickt (PATCH). -struct ProductUpdateRequest: Codable { +/// Die Felder, die das Produktformular der App besitzt (PATCH). +/// +/// Bewusst mit eigener Kodierung: Swift laesst `nil`-Optionals sonst einfach +/// weg, und das Backend wertet nur mitgeschickte Felder aus. Leeren waere damit +/// unmoeglich - "keine Kategorie" oder eine geloeschte Marke wuerden stumm +/// verpuffen. Felder, die die App nicht bearbeitet (etwa der Mindestbestand), +/// stehen deshalb gar nicht erst hier drin. +struct ProductUpdateRequest: Encodable { var name: String? var brand: String? var packageSize: Double? var packageLabel: String? var datePrecision: String? - var minStock: Double? + /// Kategorie (heisst in der API weiterhin group_id). + var groupId: Int? enum CodingKeys: String, CodingKey { case name, brand case packageSize = "package_size" case packageLabel = "package_label" case datePrecision = "date_precision" - case minStock = "min_stock" + case groupId = "group_id" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(brand, forKey: .brand) + try container.encode(packageSize, forKey: .packageSize) + try container.encode(packageLabel, forKey: .packageLabel) + try container.encode(datePrecision, forKey: .datePrecision) + try container.encode(groupId, forKey: .groupId) } } @@ -332,3 +349,16 @@ struct LotUpdateRequest: Codable { case bestBeforePrecision = "best_before_precision" } } + +/// Ein Eintrag aus /settings (Schluessel/Wert). +struct SettingEntry: Codable { + let key: String + let value: String +} + +/// Kategorie (heisst in der Datenbank und in der API weiterhin "group"; +/// nur die Beschriftung in der Oberflaeche wurde auf "Kategorie" geaendert). +struct GroupItem: Codable, Identifiable, Hashable { + let id: Int + let name: String +} diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index a33c96d..dfb8c41 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -4,6 +4,8 @@ import SwiftUI 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 @@ -16,6 +18,8 @@ struct ProductDetailView: View { @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? @@ -44,20 +48,35 @@ struct ProductDetailView: View { } } + // Beschriftungen links, Werte rechts: Ohne sie las man nur noch + // "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang. Section("Artikel") { - TextField("Name", text: $name) - TextField("Marke", text: $brand) - TextField("Packungsgröße (in \(current.baseUnit))", text: $packageSize) - .keyboardType(.decimalPad) + 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) @@ -70,7 +89,7 @@ struct ProductDetailView: View { } label: { HStack { VStack(alignment: .leading, spacing: 2) { - Text("MHD \(formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") + Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)") .font(.caption).foregroundStyle(.secondary) } @@ -109,9 +128,11 @@ struct ProductDetailView: View { 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 @@ -132,7 +153,7 @@ struct ProductDetailView: View { packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageLabel: packageLabel.isEmpty ? nil : packageLabel, datePrecision: datePrecision, - minStock: nil + groupId: groupId ) ) status = "Gespeichert." @@ -172,14 +193,8 @@ struct LotEditView: View { var body: some View { Form { Section("Menge") { - HStack { - Text(product.articleUnitLabel) - Spacer(minLength: 12) - TextField("Menge", text: $quantity) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(maxWidth: 110) - } + QuantityField(label: "Menge", text: $quantity, + suffix: product.articleUnitLabel) } Section("MHD") { diff --git a/ios/Sources/ProductViews.swift b/ios/Sources/ProductViews.swift index b78d1cc..24be9c1 100644 --- a/ios/Sources/ProductViews.swift +++ b/ios/Sources/ProductViews.swift @@ -65,25 +65,34 @@ struct ProductFormView: View { @State private var packageSize = "" @State private var packageLabel = "" @State private var datePrecision = "day" + @State private var selectedGroupId: Int? + @State private var categories: [GroupItem] = [] @State private var busy = false @State private var error: String? private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")] + // Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel. private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] var body: some View { Form { Section("Artikel") { - TextField("Barcode", text: $barcode).keyboardType(.numberPad) - TextField("Name", text: $name) - TextField("Marke", text: $brand) + LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad) + LabeledField(label: "Name", text: $name) + LabeledField(label: "Marke", text: $brand) + Picker("Kategorie", selection: $selectedGroupId) { + Text("– keine –").tag(Int?.none) + ForEach(categories) { kategorie in + Text(kategorie.name).tag(Int?.some(kategorie.id)) + } + } } Section("Einheit") { Picker("Basiseinheit", selection: $baseUnit) { ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) } } - TextField("Packungsgröße (in Basiseinheit)", text: $packageSize) - .keyboardType(.decimalPad) + QuantityField(label: "Packungsgröße", text: $packageSize, + suffix: DisplaySettings.baseUnitLabel(baseUnit)) Picker("Bezeichnung", selection: $packageLabel) { ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } } @@ -105,10 +114,13 @@ struct ProductFormView: View { .navigationTitle("Neuer Artikel") .navigationBarTitleDisplayMode(.inline) .onAppear(perform: prefill) + .task { categories = (try? await APIClient.shared.groups()) ?? [] } } private func prefill() { barcode = suggestion?.barcode ?? prefillBarcode ?? "" + // Aus dem gescannten Code vorgeschlagene Kategorie uebernehmen. + selectedGroupId = groupId if let suggestion { name = suggestion.name brand = suggestion.brand ?? "" @@ -132,7 +144,7 @@ struct ProductFormView: View { packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageLabel: packageLabel.isEmpty ? nil : packageLabel, datePrecision: datePrecision, - groupId: groupId + groupId: selectedGroupId ) ) onCreated(product) diff --git a/ios/Sources/ProjectGoodApp.swift b/ios/Sources/ProjectGoodApp.swift index 9360ea6..0fdc386 100644 --- a/ios/Sources/ProjectGoodApp.swift +++ b/ios/Sources/ProjectGoodApp.swift @@ -28,12 +28,14 @@ struct ProjectGoodApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var session = Session.shared @StateObject private var router = Router() + @StateObject private var display = DisplaySettings.shared var body: some Scene { WindowGroup { RootView() .environmentObject(session) .environmentObject(router) + .environmentObject(display) .onOpenURL { router.handle(url: $0) } .onAppear { if let type = AppDelegate.pendingShortcut { diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index a98c8f5..0840d87 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -3,6 +3,7 @@ import SwiftUI struct RootView: View { @EnvironmentObject private var session: Session @EnvironmentObject private var router: Router + @EnvironmentObject private var display: DisplaySettings var body: some View { Group { @@ -12,6 +13,10 @@ struct RootView: View { LoginView() } } + // Anzeigeeinstellungen (Datumsformat) gehoeren dem Server. + .task(id: session.isLoggedIn) { + if session.isLoggedIn { await display.load() } + } // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt. .fullScreenCover(item: $router.route) { route in NavigationStack { diff --git a/ios/project.yml b/ios/project.yml index e9a5e92..48ece0e 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -5,6 +5,9 @@ name: ProjectGood options: bundleIdPrefix: com.scarriffle + # Ohne das baut Xcode eine englische App: DatePicker und Monatsnamen + # erscheinen dann als "10. Dec 2027" statt "10. Dez. 2027". + developmentLanguage: de deploymentTarget: iOS: "16.0" createIntermediateGroups: true diff --git a/web/src/App.jsx b/web/src/App.jsx index 12b217e..49869df 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -47,7 +47,7 @@ function Sidebar() { - + {isAdmin && ( diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index 3801080..ed3456c 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -13,7 +13,7 @@ export default function CheckIn() { const [barcode, setBarcode] = useState(""); const [product, setProduct] = useState(null); const [suggestion, setSuggestion] = useState(null); - // Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl"). + // Kategorie, die diesem Code zugeordnet ist (heisst in der API weiterhin "group"). const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" }); const [unknownBarcode, setUnknownBarcode] = useState(null); const [results, setResults] = useState([]); @@ -80,7 +80,7 @@ export default function CheckIn() { async function createFromSuggestion() { setError(null); setBusy(true); try { - // Ist der Code einer Gruppe zugeordnet, hat das Vorrang vor dem Kategorie-Rateversuch. + // Ist der Code einer Kategorie zugeordnet, hat das Vorrang vor dem Rateversuch aus OFF. const groupId = suggestionGroup.id || guessGroup(groups, suggestion); const payload = suggestionToProduct(suggestion, groupId); const created = await api.createProduct(payload); @@ -185,7 +185,7 @@ export default function CheckIn() {
Noch nicht im Katalog. - {suggestionGroup.name && ` Gruppe: ${suggestionGroup.name}`} + {suggestionGroup.name && ` Kategorie: ${suggestionGroup.name}`}
{isAdmin ? ( diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 9b286e0..de00430 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -120,7 +120,7 @@ export default function Dashboard() { {groupShopping.map((it) => ( - Gruppe {it.name} + Kategorie {it.name} {fmt(it.stock)} {it.unit_name} {fmt(it.deficit)} {it.unit_name} diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx index 940e6d4..39143f5 100644 --- a/web/src/pages/Groups.jsx +++ b/web/src/pages/Groups.jsx @@ -52,7 +52,7 @@ export default function Groups() { } async function remove(group) { - if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return; + if (!confirm(`Kategorie "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return; try { await api.deleteGroup(group.id); if (selectedId === group.id) setSelectedId(null); @@ -68,9 +68,9 @@ export default function Groups() {
-

Gruppen

+

Kategorien

- Produkte zusammenfassen, Gruppen-Mindestbestand setzen und EAN-Codes hinterlegen + Produkte zusammenfassen, Mindestbestand je Kategorie setzen und EAN-Codes hinterlegen
@@ -82,7 +82,7 @@ export default function Groups() { - + @@ -146,7 +146,7 @@ export default function Groups() { ); })} - {groups.length === 0 && } + {groups.length === 0 && }
GruppeKategorie Produkte Bestand Mindestbestand
Noch keine Gruppen.
Noch keine Kategorien.
@@ -162,7 +162,7 @@ export default function Groups() { { try { await api.addGroupBarcode(selected.id, body); @@ -182,7 +182,7 @@ export default function Groups() { {isAdmin && (
-

Neue Gruppe

+

Neue Kategorie

diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index 5b37eb4..fca2f06 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -69,7 +69,7 @@ export default function Transfer() {

CSV enthält eine Zeile je Charge und lässt sich in Excel oder LibreOffice bearbeiten. JSON ist ein vollständiges Backup inklusive - Einheiten, Gruppen und Lagerorten. + Einheiten, Kategorien und Lagerorten.