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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user