Files
Vorrania/ios/Sources/DisplaySettings.swift
Scarriffle ff158a1e5f iOS: mehrere Server, TabBar, Uebersicht, Verlauf und Stammdaten
Die App konnte fuenf Dinge, die Web-Oberflaeche vierzehn. Alles darueber hinaus
- nachsehen was gestern passiert ist, einen Lagerort anlegen - zwang zurueck an
den Browser.

Mehrere Server: Statt einer Adresse und einem Token kennt die App jetzt
beliebig viele Profile, jedes mit eigenem Token im Keychain. Ein Wechsel ist
damit ein Tipp und meldet an keinem der Server ab. Jedes Profil hat einen frei
waehlbaren Namen ("Zuhause"), der oben in der App steht; ohne Angabe faellt er
auf den Rechnernamen zurueck. Eine bestehende Anmeldung wird beim ersten Start
in ein Profil ueberfuehrt - ohne das waere man nach dem Update abgemeldet.

Statt einer Startseite mit Kacheln gibt es vier Tabs: Start, Scannen, Listen
und - nur fuer Administratoren, wie im Web - Verwaltung. Neu darin sind eine
Uebersicht mit den Kennzahlen des Servers, die per Tipp in die passende Liste
fuehren, der Bewegungsverlauf (auch je Artikel, weil /movements danach filtern
kann) und die Stammdaten: Lagerorte, Einheiten, Gebinde, Kategorien und
Gruppen. Die fuenf teilen sich eine Ansicht und unterscheiden sich nur darin,
wie geladen und geschrieben wird.

Zwei Dinge, die beim Pruefen gegen einen echten Server auffielen: Der Server
schickt Zeitstempel in UTC, haengt hinter SQLite aber keine Zeitzone an. Naiv
gelesen haette die App jede Uhrzeit um die eigene Zeitverschiebung daneben
angezeigt, deshalb wird die nackte Form ausdruecklich als UTC gelesen. Und die
Anzeigeeinstellungen gehoeren dem Server, werden beim Wechsel also neu geladen.

Bewusst nicht dabei: Benutzer, Einstellungen, Branding und Import/Export. Die
bleiben vorerst im Web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:24:07 +02:00

143 lines
5.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
private init() {}
/// Nach der Anmeldung aufrufen. Schlaegt der 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 }),
!entry.value.isEmpty {
dateFormat = entry.value
}
}
// 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)"
}
}