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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-29 15:04:25 +02:00
parent a0979a0780
commit 8250b5dc84
7 changed files with 68 additions and 31 deletions

View File

@@ -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

View File

@@ -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,14 +187,20 @@ struct CheckInView: View {
// MARK: - Bausteine
private func banner(_ text: String, color: Color) -> some View {
Text(text)
.font(.callout)
.padding(10)
// 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)
.background(color.opacity(0.9))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.listRowInsets(EdgeInsets())
.background(color, in: RoundedRectangle(cornerRadius: 14))
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.listRowBackground(Color.clear)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(
// 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)
} catch {
// Unerreichbar (Heimnetz von unterwegs) oder Token abgelaufen:
// diesen Server ueberspringen, die anderen trotzdem planen.
continue
}
}
// Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst.

View File

@@ -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 {

View File

@@ -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<T: Decodable>(