Files
Vorrania/ios/Sources/DisplaySettings.swift
Scarriffle 8250b5dc84 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>
2026-07-29 15:04:25 +02:00

164 lines
6.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)"
}
}