feat: Erste Stufe der iOS-App - Anmeldung und Kontenliste

Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage,
Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server
waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste
Fachansicht die Kontenliste.

Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine
App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der
Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen.

Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist
dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei
kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-20 16:20:03 +02:00
co-authored by Claude Opus 5
parent 8a7545bf5b
commit 6b86a6bf91
25 changed files with 2908 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import Observation
/// Holt eine Liste, legt sie in den Zwischenspeicher und weiss, was bei einem
/// Fehlschlag noch da ist.
///
/// Jede Listenansicht braucht dasselbe: laden, Fehler zeigen, zum Aktualisieren
/// herunterziehen, offline den letzten Stand behalten. Einmal hier, statt in
/// jeder Ansicht neu.
@MainActor
@Observable
final class Lader<A: Codable & Sendable> {
private(set) var zustand: Ladezustand<A> = .laedt
private let schluessel: String
private let holen: () async throws -> A
/// - Parameter schluessel: Name im Zwischenspeicher. Der Endpunktpfad ist
/// eine gute Wahl – er ist eindeutig und beim Suchen lesbar.
init(schluessel: String, holen: @escaping () async throws -> A) {
self.schluessel = schluessel
self.holen = holen
}
/// Lädt. Beim ersten Mal wird ein vorhandener Stand sofort angezeigt,
/// damit die Liste nicht leer aufblitzt, während der Abruf läuft.
func lade(zeigeGemerktesSofort: Bool = true) async {
if zeigeGemerktesSofort, zustand.inhalt == nil,
let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
fehler: .keineVerbindung(""))
} else if zustand.inhalt == nil {
zustand = .laedt
}
do {
let frisch = try await holen()
Zwischenspeicher.lege(frisch, unter: schluessel)
zustand = .da(frisch)
} catch let fehler as APIFehler {
if let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
fehler: fehler)
} else {
zustand = .fehler(fehler)
}
} catch {
zustand = .fehler(.keineVerbindung(error.localizedDescription))
}
}
/// Fürs Herunterziehen: kein Zwischenzustand, die alte Liste bleibt
/// stehen, bis die neue da ist.
func aktualisiere() async {
await lade(zeigeGemerktesSofort: false)
}
}