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
+83
View File
@@ -0,0 +1,83 @@
import Foundation
/// Die letzte geglückte Antwort je Abruf, auf der Platte.
///
/// Wozu: In der U-Bahn und im Ausland soll die App nicht mit einer
/// Fehlermeldung dastehen, sondern den letzten Stand zeigen – mit Datum, damit
/// niemand alte Zahlen für aktuelle hält. Geschrieben wird offline nicht.
///
/// Bewusst schlicht: eine JSON-Datei je Schlüssel, kein Verfallsdatum, kein
/// Aufräumen nach Grösse. Die Antworten sind klein (die grösste, die
/// Buchungsliste, liegt im einstelligen Megabyte-Bereich), und ein Abmelden
/// räumt ohnehin alles weg.
enum Zwischenspeicher {
/// Was aus dem Speicher kam, und wann es geholt wurde.
struct Eintrag<A>: Sendable where A: Sendable {
let wert: A
let geholtAm: Date
}
private static let ordner: URL? = {
guard let basis = try? FileManager.default.url(
for: .cachesDirectory, in: .userDomainMask,
appropriateFor: nil, create: true) else { return nil }
let eigener = basis.appendingPathComponent("CertainAntworten", isDirectory: true)
try? FileManager.default.createDirectory(at: eigener, withIntermediateDirectories: true)
return eigener
}()
private static func datei(_ schluessel: String) -> URL? {
// Der Schluessel ist ein Pfad mit Abfrage; daraus muss ein gueltiger
// Dateiname werden.
let sauber = schluessel.unicodeScalars.map { zeichen -> Character in
CharacterSet.alphanumerics.contains(zeichen) ? Character(zeichen) : "_"
}
return ordner?.appendingPathComponent(String(sauber) + ".json")
}
static func lege<A: Encodable>(_ wert: A, unter schluessel: String) {
guard let ziel = datei(schluessel),
let daten = try? JSONEncoder().encode(wert) else { return }
try? daten.write(to: ziel, options: .atomic)
}
static func hole<A: Decodable & Sendable>(_ typ: A.Type, unter schluessel: String) -> Eintrag<A>? {
guard let quelle = datei(schluessel),
let daten = try? Data(contentsOf: quelle),
let wert = try? JSONDecoder().decode(A.self, from: daten) else { return nil }
let wann = (try? quelle.resourceValues(forKeys: [.contentModificationDateKey]))?
.contentModificationDate ?? Date()
return Eintrag(wert: wert, geholtAm: wann)
}
static func leere() {
guard let ordner else { return }
try? FileManager.default.removeItem(at: ordner)
}
}
/// Was eine Ansicht gerade anzeigen soll.
///
/// Der vierte Fall ist der Grund für diesen Typ: Daten da, aber vom Band –
/// die Ansicht muss das sagen können, ohne dass jede Ansicht ihre eigene
/// Zustandsverwaltung erfindet.
enum Ladezustand<A: Sendable>: Sendable {
case laedt
case da(A)
/// Der Abruf schlug fehl, aber wir haben noch einen Stand von vorher.
case ausSpeicher(A, geholtAm: Date, fehler: APIFehler)
case fehler(APIFehler)
var inhalt: A? {
switch self {
case .da(let wert): return wert
case .ausSpeicher(let wert, _, _): return wert
case .laedt, .fehler: return nil
}
}
var laeuft: Bool {
if case .laedt = self { return true }
return false
}
}