From 8250b5dc845c16659fd3cfe82b256ef21035a152 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 29 Jul 2026 15:04:25 +0200 Subject: [PATCH] iOS: Benachrichtigungs-Kategorien laden + Ein-/Auslager-Meldung huebscher 1) In den Benachrichtigungs-Regeln liess sich keine Kategorie waehlen: der Abruf holte den ganzen Snapshot (Produkte + Ablauf + Kategorien) - schlug ein Teil fehl, kamen auch die Kategorien leer. snapshot ist jetzt widerstandsfaehig (Teilausfaelle egal), und die Regel-Bearbeitung holt nur noch die Kategorien. 2) Die gruene Erfolgsmeldung ist jetzt eine saubere, eingerueckte Pille mit Titel ("Eingelagert"/"Ausgelagert") und Detailzeile ("Neuer Bestand: 2 Packungen"). Die Einheit steht in der Mehrzahl (Gebinde-Plural vom Server; Basiseinheiten wie Gramm bleiben unveraendert). Co-Authored-By: Claude Opus 4.8 --- ios/Sources/CheckInFormView.swift | 4 +++- ios/Sources/CheckInView.swift | 28 +++++++++++++--------- ios/Sources/CheckOutView.swift | 2 +- ios/Sources/DisplaySettings.swift | 27 ++++++++++++++++++--- ios/Sources/NotificationScheduler.swift | 19 +++++++-------- ios/Sources/NotificationSettingsView.swift | 3 +-- ios/Sources/ServerFetch.swift | 16 +++++++++++-- 7 files changed, 68 insertions(+), 31 deletions(-) diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index e23ecda..defb758 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -141,6 +141,7 @@ struct CheckInFormView: View { var onDone: (String) -> Void @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var display: DisplaySettings struct Line: Identifiable { let id = UUID() @@ -407,7 +408,8 @@ struct CheckInFormView: View { BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines) ) let total = response.productStock / product.articleUnitFactor - onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)") + // Titel + Detail (zweizeilig im Banner); Einheit mit Mehrzahl. + onDone("Eingelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))") dismiss() } catch { self.error = error.localizedDescription diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 0f52a1f..110eca8 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -135,7 +135,7 @@ struct CheckInView: View { // Einzelstücke werden nicht als Charge eingelagert, sondern als // physische Exemplare (UID/QR) angelegt – die zählen den Bestand. ItemAddSheet(productId: item.id, onDone: { - status = "\(item.name): Einzelstück(e) angelegt." + status = "Eingelagert\n\(item.name): Einzelstück(e) angelegt." }) } else if item.foodLike { // Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD. @@ -147,7 +147,7 @@ struct CheckInView: View { // Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort. ObjectAddSheet(product: item, locations: locations, einheit: item.unitName.isEmpty ? "Stück" : item.unitName, - perform: { status = "\(item.name): Menge eingelagert." }) + perform: { status = "Eingelagert\n\(item.name): Menge hinzugefügt." }) } } } @@ -187,15 +187,21 @@ struct CheckInView: View { // MARK: - Bausteine private func banner(_ text: String, color: Color) -> some View { - Text(text) - .font(.callout) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(color.opacity(0.9)) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) + // Erste Zeile = Titel (fett), Rest = Detail – ergibt eine saubere, + // rundum eingerückte Melde-Pille statt eines randlosen Balkens. + let zeilen = text.components(separatedBy: "\n") + return VStack(alignment: .leading, spacing: 2) { + Text(zeilen.first ?? text).font(.headline) + if zeilen.count > 1 { + Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline) + } + } + .padding(.horizontal, 16).padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(.white) + .background(color, in: RoundedRectangle(cornerRadius: 14)) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) } private func bestandText(_ p: Product) -> String { diff --git a/ios/Sources/CheckOutView.swift b/ios/Sources/CheckOutView.swift index 62c9684..2059beb 100644 --- a/ios/Sources/CheckOutView.swift +++ b/ios/Sources/CheckOutView.swift @@ -215,7 +215,7 @@ struct CheckOutFormView: View { CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId) ) let total = response.productStock / product.articleUnitFactor - onDone("Ausgelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)") + onDone("Ausgelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))") dismiss() } catch { self.error = error.localizedDescription diff --git a/ios/Sources/DisplaySettings.swift b/ios/Sources/DisplaySettings.swift index 40bcb9c..8cb1e11 100644 --- a/ios/Sources/DisplaySettings.swift +++ b/ios/Sources/DisplaySettings.swift @@ -13,18 +13,39 @@ final class DisplaySettings: ObservableObject { static let fallbackFormat = "de" @Published private(set) var dateFormat = DisplaySettings.fallbackFormat + /// Gebinde-Mehrzahlformen (Einzahl -> Mehrzahl), z.B. „Packung" -> „Packungen". + @Published private(set) var packagePlural: [String: String] = ["Packung": "Packungen"] private init() {} - /// Nach der Anmeldung aufrufen. Schlaegt der Abruf fehl, bleibt es beim + /// Nach der Anmeldung aufrufen. Schlaegt ein 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 }), + if let entries = try? await APIClient.shared.settings(), + let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }), !entry.value.isEmpty { dateFormat = entry.value } + if let typen = try? await APIClient.shared.packageTypes() { + var map = ["Packung": "Packungen"] + for t in typen where !t.singular.isEmpty { map[t.singular] = t.plural } + packagePlural = map + } + } + + /// Ein Gebinde in der passenden Zahlform: „1 Packung", aber „2 Packungen". + func gebinde(_ count: Double, _ singular: String) -> String { + count == 1 ? singular : (packagePlural[singular] ?? singular) + } + + /// Artikeleinheit mit Mehrzahl: bei Packungen die Gebinde-Mehrzahl, sonst die + /// Basiseinheit (Gramm/Milliliter/Stück – im Deutschen ohne Mehrzahl). + func articleUnit(_ product: Product, count: Double) -> String { + if let size = product.packageSize, size > 0 { + return gebinde(count, product.packageLabel ?? "Packung") + } + return product.articleUnitLabel } // MARK: - Datum diff --git a/ios/Sources/NotificationScheduler.swift b/ios/Sources/NotificationScheduler.swift index b2c2b33..b7b911e 100644 --- a/ios/Sources/NotificationScheduler.swift +++ b/ios/Sources/NotificationScheduler.swift @@ -199,17 +199,14 @@ final class NotificationScheduler { let token = Keychain.read(account: Session.keychainAccount(for: prof.id)) else { continue } let config = NotificationStore.config(for: prof.id) - do { - let snapshot = try await ServerFetch.snapshot( - baseURL: url, token: token, horizonDays: Self.horizonDays) - geplant += ExpiryPlanner.plan( - profileID: prof.id, serverName: prof.name, config: config, - snapshot: snapshot, today: heute, horizonDays: Self.horizonDays) - } catch { - // Unerreichbar (Heimnetz von unterwegs) oder Token abgelaufen: - // diesen Server ueberspringen, die anderen trotzdem planen. - continue - } + // snapshot ist widerstandsfaehig: unerreichbare Server (Heimnetz von + // unterwegs) liefern leere Listen -> es wird nichts geplant, die + // anderen Server trotzdem. + let snapshot = await ServerFetch.snapshot( + baseURL: url, token: token, horizonDays: Self.horizonDays) + geplant += ExpiryPlanner.plan( + profileID: prof.id, serverName: prof.name, config: config, + snapshot: snapshot, today: heute, horizonDays: Self.horizonDays) } // Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst. diff --git a/ios/Sources/NotificationSettingsView.swift b/ios/Sources/NotificationSettingsView.swift index a9bffbc..a861354 100644 --- a/ios/Sources/NotificationSettingsView.swift +++ b/ios/Sources/NotificationSettingsView.swift @@ -152,8 +152,7 @@ struct NotificationSettingsView: View { let url = profile.url, let token = Keychain.read(account: Session.keychainAccount(for: profileID)) else { return } - categories = (try? await ServerFetch.snapshot( - baseURL: url, token: token, horizonDays: 1).categories) ?? [] + categories = (try? await ServerFetch.categories(baseURL: url, token: token)) ?? [] } private func pruefeBerechtigung() async { diff --git a/ios/Sources/ServerFetch.swift b/ios/Sources/ServerFetch.swift index 4c47b6c..e605a62 100644 --- a/ios/Sources/ServerFetch.swift +++ b/ios/Sources/ServerFetch.swift @@ -15,12 +15,24 @@ enum ServerFetch { let categories: [CategoryItem] } - static func snapshot(baseURL: URL, token: String, horizonDays: Int) async throws -> Snapshot { + static func snapshot(baseURL: URL, token: String, horizonDays: Int) async -> Snapshot { async let expiring: [ExpiringItem] = get( "/expiring?days=\(horizonDays)", baseURL: baseURL, token: token) async let products: [Product] = get("/products", baseURL: baseURL, token: token) async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token) - return try await Snapshot(expiring: expiring, products: products, categories: categories) + // Einzelne Ausfaelle sollen nicht alles kippen (sonst fehlten z.B. die + // Kategorien nur, weil die Produktliste langsam war). + return await Snapshot( + expiring: (try? await expiring) ?? [], + products: (try? await products) ?? [], + categories: (try? await categories) ?? [] + ) + } + + /// Nur den Kategorie-Baum eines bestimmten Servers holen (fuer die + /// Regel-Bearbeitung in den Benachrichtigungen). + static func categories(baseURL: URL, token: String) async throws -> [CategoryItem] { + try await get("/categories", baseURL: baseURL, token: token) } private static func get(