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
+106
View File
@@ -0,0 +1,106 @@
import Foundation
/// Was beim Reden mit dem Certain-Backend schiefgehen kann.
///
/// Die Fälle sind nach dem geschnitten, was die App damit *tun* muss – nicht
/// nach HTTP-Status. `pflichtOffen` führt auf einen anderen Bildschirm,
/// `konflikt` ist eine Rückfrage, die sich mit „trotzdem" wiederholen lässt,
/// alles übrige ist eine Meldung.
enum APIFehler: Error, LocalizedError, Sendable, Equatable {
/// Netz weg, Server nicht erreichbar, Zeitüberschreitung.
case keineVerbindung(String)
/// Unter der Adresse antwortet etwas, aber kein Certain.
case keinCertainServer
/// 401 – Marke ungültig oder widerrufen, Sitzung abgelaufen.
case nichtAngemeldet
/// 403 mit maschinenlesbarem Schlüssel: erst muss etwas erledigt werden.
case pflichtOffen(Pflicht)
/// 403 aus einem anderen Grund.
case verboten(String)
/// 404 – gibt es nicht, oder gehört jemand anderem.
case nichtGefunden
/// 409 – meistens eine Rückfrage, die sich mit `trotzdem = true`
/// wiederholen lässt. Der Text ist für den Nutzer geschrieben.
case konflikt(String)
/// 400 oder 422.
case ungueltig(String)
/// Alles übrige, was der Server sagt.
case server(status: Int, text: String)
/// Die Antwort passt nicht zu dem, was wir erwarten.
case antwortUnlesbar(String)
/// Was der Benutzer erledigen muss, bevor die API ihn durchlässt.
/// Die Werte sind die Schlüssel aus `backend/app/deps.py`.
enum Pflicht: String, Sendable {
case passwortWechseln = "password_change_required"
case zweiterFaktor = "mfa_setup_required"
}
var errorDescription: String? {
switch self {
case .keineVerbindung(let grund):
return String(format: String(localized: "fehler.keineVerbindung"), grund)
case .keinCertainServer:
return String(localized: "fehler.keinCertainServer")
case .nichtAngemeldet:
return String(localized: "fehler.nichtAngemeldet")
case .pflichtOffen(.passwortWechseln):
return String(localized: "fehler.passwortWechseln")
case .pflichtOffen(.zweiterFaktor):
return String(localized: "fehler.zweiterFaktor")
case .verboten(let text), .konflikt(let text), .ungueltig(let text):
return text
case .nichtGefunden:
return String(localized: "fehler.nichtGefunden")
case .server(let status, let text):
return text.isEmpty
? String(format: String(localized: "fehler.server"), status)
: text
case .antwortUnlesbar(let grund):
return String(format: String(localized: "fehler.antwortUnlesbar"), grund)
}
}
/// Ob ein erneuter Versuch überhaupt Sinn ergibt.
var lohntWiederholung: Bool {
switch self {
case .keineVerbindung, .server:
return true
default:
return false
}
}
}
/// Der Fehlerkörper des Backends.
///
/// `detail` ist dort mal eine Zeichenkette (`{"detail": "Nicht gefunden"}`),
/// mal eine Liste von Pydantic-Meldungen (bei 422). Beides muss hier durch,
/// sonst verliert die App bei jedem Eingabefehler die Meldung.
struct Fehlerantwort: Decodable, Sendable {
let detail: String
private enum Schluessel: String, CodingKey { case detail }
private struct Einzelfehler: Decodable {
let msg: String?
}
init(from decoder: Decoder) throws {
let behaelter = try decoder.container(keyedBy: Schluessel.self)
if let text = try? behaelter.decode(String.self, forKey: .detail) {
detail = text
} else if let liste = try? behaelter.decode([Einzelfehler].self, forKey: .detail) {
detail = liste.compactMap(\.msg).joined(separator: ", ")
} else {
detail = ""
}
}
/// Versucht, aus Antwortdaten eine Meldung zu ziehen. Schlägt das fehl,
/// kommt eine leere Zeichenkette – nie ein Fehler, denn wir sind hier
/// bereits im Fehlerfall.
static func ausDaten(_ daten: Data) -> String {
(try? JSONDecoder().decode(Fehlerantwort.self, from: daten))?.detail ?? ""
}
}