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)" } }