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
+94
View File
@@ -0,0 +1,94 @@
import SwiftUI
/// Der Hinweis über einer Liste, deren Daten aus dem Zwischenspeicher kommen.
///
/// Wichtiger als er aussieht: Ohne ihn zeigt die App im Flugmodus alte Zahlen,
/// die aussehen wie frische. Mit Datum, weil „offline" allein nicht sagt, ob
/// der Stand von vor fünf Minuten oder von vor drei Wochen ist.
struct StandHinweis: View {
let geholtAm: Date
var body: some View {
Label {
Text(String(format: String(localized: "stand.hinweis"),
geholtAm.formatted(.relative(presentation: .named))))
} icon: {
Image(systemName: "wifi.slash")
}
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.4))
}
}
/// Wenn gar nichts da ist: Fehler zeigen und, wo es Sinn ergibt, einen
/// Knopf zum Nochmalversuchen.
struct FehlerAnsicht: View {
let fehler: APIFehler
var nochmal: (() -> Void)?
var body: some View {
ContentUnavailableView {
Label(String(localized: "fehler.titel"), systemImage: symbol)
} description: {
Text(fehler.errorDescription ?? String(localized: "fehler.unbekannt"))
} actions: {
if let nochmal, fehler.lohntWiederholung {
Button(String(localized: "allgemein.nochmal"), action: nochmal)
.buttonStyle(.borderedProminent)
}
}
}
private var symbol: String {
switch fehler {
case .keineVerbindung: return "wifi.slash"
case .nichtAngemeldet: return "person.crop.circle.badge.exclamationmark"
case .nichtGefunden: return "questionmark.folder"
default: return "exclamationmark.triangle"
}
}
}
/// Eine Liste, die noch nichts enthält – aber nicht wegen eines Fehlers.
struct LeerAnsicht: View {
let titel: String
let erklaerung: String
var symbol: String = "tray"
var body: some View {
ContentUnavailableView {
Label(titel, systemImage: symbol)
} description: {
Text(erklaerung)
}
}
}
/// Setzt die drei Zustände für eine Liste zusammen, damit das nicht jede
/// Ansicht neu baut.
struct Inhalt<A: Sendable, Darstellung: View>: View {
let zustand: Ladezustand<A>
var nochmal: (() -> Void)?
@ViewBuilder let darstellung: (A) -> Darstellung
var body: some View {
switch zustand {
case .laedt:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .da(let wert):
darstellung(wert)
case .ausSpeicher(let wert, let wann, _):
VStack(spacing: 0) {
StandHinweis(geholtAm: wann)
darstellung(wert)
}
case .fehler(let fehler):
FehlerAnsicht(fehler: fehler, nochmal: nochmal)
}
}
}