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.