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>
164 lines
6.5 KiB
Swift
164 lines
6.5 KiB
Swift
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
|
||
/// Gebinde-Mehrzahlformen (Einzahl -> Mehrzahl), z.B. „Packung" -> „Packungen".
|
||
@Published private(set) var packagePlural: [String: String] = ["Packung": "Packungen"]
|
||
|
||
private init() {}
|
||
|
||
/// 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 {
|
||
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
|
||
|
||
/// 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: - Zeitstempel
|
||
|
||
/// Zeitpunkte der API ("2026-07-23T16:20:59.510258").
|
||
///
|
||
/// Der Server erzeugt sie mit `datetime.now(timezone.utc)`, haengt an die
|
||
/// Antwort aber keine Zeitzone an, solange SQLite dahinter steht - Postgres
|
||
/// liefert dagegen ein Offset mit. Deshalb erst der Versuch mit Offset,
|
||
/// danach die nackte Form, die **als UTC** gelesen wird. Ohne das zeigte die
|
||
/// App die Uhrzeit um die eigene Zeitverschiebung daneben.
|
||
static func parseTimestamp(_ raw: String) -> Date? {
|
||
for optionen: ISO8601DateFormatter.Options in [
|
||
[.withInternetDateTime, .withFractionalSeconds], [.withInternetDateTime],
|
||
] {
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = optionen
|
||
if let date = formatter.date(from: raw) { return date }
|
||
}
|
||
for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss"] {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||
formatter.dateFormat = muster
|
||
if let date = formatter.date(from: raw) { return date }
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// Uhrzeit einer Bewegung ("17:22").
|
||
func formatTime(_ raw: String) -> String {
|
||
guard let date = DisplaySettings.parseTimestamp(raw) else { return "" }
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "de_DE")
|
||
formatter.dateFormat = "HH:mm"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
/// Ueberschrift einer Tagesgruppe. "Heute" und "Gestern" sind schneller zu
|
||
/// erfassen als ein Datum.
|
||
func formatDay(_ raw: String) -> String {
|
||
guard let date = DisplaySettings.parseTimestamp(raw) else { return raw }
|
||
let kalender = Calendar.current
|
||
if kalender.isDateInToday(date) { return "Heute" }
|
||
if kalender.isDateInYesterday(date) { return "Gestern" }
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "de_DE")
|
||
formatter.dateFormat = DisplaySettings.pattern(for: dateFormat)
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
/// Tagesschluessel zum Gruppieren - unabhaengig von der Schreibweise.
|
||
static func dayKey(_ raw: String) -> String {
|
||
String(raw.prefix(10))
|
||
}
|
||
|
||
// 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)"
|
||
}
|
||
}
|