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
+145
View File
@@ -0,0 +1,145 @@
import Foundation
// Die Typen hier bilden die Antwortschemas aus `backend/app/schemas.py` nach.
// Feldnamen stimmen eins zu eins überein – deshalb braucht keiner von ihnen
// `CodingKeys`. Wer ein Feld umbenennt, bricht genau hier.
/// `GET /api/version` – die Prüfung, ob unter einer Adresse ein Certain läuft.
struct Serverkennung: Codable, Sendable {
let name: String
let version: String
let gitSha: String?
private enum CodingKeys: String, CodingKey {
case name, version
case gitSha = "git_sha"
}
/// Der Name, den ein echter Server trägt (`backend/app/branding.py`).
var istCertain: Bool { name.localizedCaseInsensitiveContains("certain") }
}
/// `UserOut`
struct Benutzer: Codable, Sendable, Identifiable, Hashable {
let id: Int
let username: String
let role: Rolle
let displayName: String?
let isActive: Bool
let mustChangePassword: Bool
let mustChangePasswordReason: String?
let totpEnabled: Bool
let createdAt: Zeitpunkt
let mfaSetupRequired: Bool
private enum CodingKeys: String, CodingKey {
case id, username, role
case displayName = "display_name"
case isActive = "is_active"
case mustChangePassword = "must_change_password"
case mustChangePasswordReason = "must_change_password_reason"
case totpEnabled = "totp_enabled"
case createdAt = "created_at"
case mfaSetupRequired = "mfa_setup_required"
}
enum Rolle: String, Codable, Sendable {
case admin, user
}
var anzeigename: String { displayName?.isEmpty == false ? displayName! : username }
}
/// `LoginOut` – `status` ist `"ok"` oder `"2fa_required"`.
struct AnmeldeAntwort: Codable, Sendable {
let status: String
let user: Benutzer?
var brauchtZweitenFaktor: Bool { status == "2fa_required" }
}
/// `TwoFactorVerify`
struct ZweiterFaktorEingabe: Codable, Sendable {
let code: String
let recovery: Bool
}
/// `PasswordChange`
struct PasswortWechsel: Codable, Sendable {
let currentPassword: String
let newPassword: String
private enum CodingKeys: String, CodingKey {
case currentPassword = "current_password"
case newPassword = "new_password"
}
}
/// `ApiTokenCreate`
struct MarkeAnlegen: Codable, Sendable {
let name: String
let scope: String
let defaultAccountId: Int?
private enum CodingKeys: String, CodingKey {
case name, scope
case defaultAccountId = "default_account_id"
}
/// Die Marke, mit der sich die App anmeldet.
static func fuerApp(name: String) -> MarkeAnlegen {
MarkeAnlegen(name: name, scope: "app", defaultAccountId: nil)
}
}
/// `ApiTokenOut` beziehungsweise `ApiTokenCreated`.
/// `token` trägt den Klartext – und zwar genau einmal, beim Anlegen.
struct Marke: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let scope: String
let defaultAccountId: Int?
let lastUsedAt: Zeitpunkt?
let createdAt: Zeitpunkt?
let token: String?
private enum CodingKeys: String, CodingKey {
case id, name, scope, token
case defaultAccountId = "default_account_id"
case lastUsedAt = "last_used_at"
case createdAt = "created_at"
}
}
/// Ein Eintrag aus `GET /api/settings`. Werte sind immer Zeichenketten.
struct Einstellung: Codable, Sendable, Hashable {
let key: String
let value: String
}
/// Die Einstellungen als benutzbares Ganzes.
struct Einstellungen: Sendable, Hashable {
var basiswaehrung: String = "EUR"
var budgetStarttag: Int = 1
var sprache: String = "de"
var erscheinungsbild: String = "system"
var datumsformat: String = "de"
var aktiveWaehrungen: [String] = ["EUR", "CHF", "USD"]
init() {}
init(_ eintraege: [Einstellung]) {
var nach: [String: String] = [:]
for eintrag in eintraege { nach[eintrag.key] = eintrag.value }
basiswaehrung = nach["base_currency"] ?? basiswaehrung
budgetStarttag = min(31, max(1, Int(nach["budget_start_day"] ?? "") ?? 1))
sprache = nach["locale"] ?? sprache
erscheinungsbild = nach["theme"] ?? erscheinungsbild
datumsformat = nach["date_format"] ?? datumsformat
if let roh = nach["active_currencies"] {
aktiveWaehrungen = roh.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).uppercased() }
.filter { !$0.isEmpty }
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
/// `AccountOut` aus `backend/app/schemas.py`.
struct Konto: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let type: Kontoart
let currency: String
let icon: String
let color: String
let openingBalanceCents: Int
let openingDate: Tagesdatum?
let creditLimitCents: Int?
let isLocked: Bool
let isArchived: Bool
let position: Int
let savingsCategoryId: Int?
let identifiers: [Kontokennung]
/// Saldo in der Währung des Kontos.
let balanceCents: Int
/// Derselbe Saldo in der Hauptwährung – vom Server gerechnet.
let balanceBaseCents: Int
/// Was die Bank selbst meldet. Nur bei angebundenen Konten gesetzt.
let bankBalanceCents: Int?
let bankBalanceAt: Zeitpunkt?
/// Nur ein gebuchter Banksaldo lässt sich mit unserer Rechnung
/// vergleichen; ein verfügbarer enthält Vorgemerktes.
let bankBalanceVergleichbar: Bool
private enum CodingKeys: String, CodingKey {
case id, name, type, currency, icon, color, position, identifiers
case openingBalanceCents = "opening_balance_cents"
case openingDate = "opening_date"
case creditLimitCents = "credit_limit_cents"
case isLocked = "is_locked"
case isArchived = "is_archived"
case savingsCategoryId = "savings_category_id"
case balanceCents = "balance_cents"
case balanceBaseCents = "balance_base_cents"
case bankBalanceCents = "bank_balance_cents"
case bankBalanceAt = "bank_balance_at"
case bankBalanceVergleichbar = "bank_balance_vergleichbar"
}
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
/// Ob der gemeldete Banksaldo von unserer Rechnung abweicht – nur dann ist
/// ein Hinweis angebracht.
var weichtVonDerBankAb: Bool {
guard bankBalanceVergleichbar, let bank = bankBalanceCents else { return false }
return bank != balanceCents
}
}
/// `AccountType`
enum Kontoart: String, Codable, Sendable, CaseIterable {
case girokonto, sparkonto, bargeld, kreditkarte, depot, sonstiges
var name: String {
switch self {
case .girokonto: return String(localized: "kontoart.girokonto")
case .sparkonto: return String(localized: "kontoart.sparkonto")
case .bargeld: return String(localized: "kontoart.bargeld")
case .kreditkarte: return String(localized: "kontoart.kreditkarte")
case .depot: return String(localized: "kontoart.depot")
case .sonstiges: return String(localized: "kontoart.sonstiges")
}
}
/// Ein Übertrag auf diese Konten zählt im Budget als Sparen, nicht als
/// neutrale Umbuchung (`SAVINGS_ACCOUNT_TYPES` im Backend).
var giltAlsSparen: Bool { self == .sparkonto || self == .depot }
}
/// `AccountIdentifierOut`
struct Kontokennung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let kind: Art
let value: String
enum Art: String, Codable, Sendable {
case iban
case accountNo = "account_no"
case cardNo = "card_no"
case text
}
}
+127
View File
@@ -0,0 +1,127 @@
import Foundation
/// Ein Kalendertag ohne Uhrzeit, so wie das Backend ihn schickt: `"2026-07-14"`.
///
/// Warum ein eigener Typ und nicht `Date` mit einer Decoder-Strategie: Das
/// Backend mischt zwei Formate. Buchungsdaten sind reine Tage, `created_at`
/// und Konsorten sind volle Zeitstempel mit Zone. Eine einzige
/// `dateDecodingStrategy` müsste beides raten und würde mal das eine, mal das
/// andere zerlegen. Zwei saubere Typen sind hier weniger Arbeit als ein
/// cleverer Decoder.
struct Tagesdatum: Codable, Hashable, Comparable, Sendable, CustomStringConvertible {
/// Mitternacht dieses Tages, in der Zeitzone des Geräts.
let datum: Date
/// Die Form, in der es kam und wieder hinausgeht.
let text: String
private static let leser: DateFormatter = {
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = .current
f.dateFormat = "yyyy-MM-dd"
return f
}()
init?(_ text: String) {
guard let geparst = Tagesdatum.leser.date(from: text) else { return nil }
self.datum = geparst
self.text = text
}
init(_ datum: Date) {
self.datum = datum
self.text = Tagesdatum.leser.string(from: datum)
}
static var heute: Tagesdatum { Tagesdatum(Date()) }
init(from decoder: Decoder) throws {
let roh = try decoder.singleValueContainer().decode(String.self)
guard let fertig = Tagesdatum(roh) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein Tagesdatum: \(roh)"))
}
self = fertig
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.singleValueContainer()
try behaelter.encode(text)
}
static func < (links: Tagesdatum, rechts: Tagesdatum) -> Bool {
links.datum < rechts.datum
}
var description: String { text }
/// Für die Anzeige, in der Sprache des Geräts.
func formatiert(_ stil: Date.FormatStyle.DateStyle = .abbreviated) -> String {
datum.formatted(.dateTime.day().month(.defaultDigits).year())
.replacingOccurrences(of: "\u{202F}", with: " ")
}
}
/// Ein voller Zeitstempel, wie ihn `created_at`, `last_used_at` und
/// `bank_balance_at` tragen.
///
/// Das Backend schickt ISO 8601, mal mit Sekundenbruchteilen, mal ohne, mal
/// mit `Z`, mal mit Offset. Deshalb zwei Leser hintereinander statt einem.
struct Zeitpunkt: Codable, Hashable, Comparable, Sendable {
let datum: Date
private static let mitBruchteilen: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let ohneBruchteile: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
/// Letzter Ausweg: Python schreibt ohne Zone, wenn keine gesetzt ist.
private static let ohneZone: DateFormatter = {
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return f
}()
init(_ datum: Date) { self.datum = datum }
init?(_ text: String) {
let ohneMikro = text.replacingOccurrences(
of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression)
if let d = Zeitpunkt.mitBruchteilen.date(from: ohneMikro) { self.datum = d; return }
if let d = Zeitpunkt.ohneBruchteile.date(from: text) { self.datum = d; return }
let gekuerzt = String(text.prefix(19))
if let d = Zeitpunkt.ohneZone.date(from: gekuerzt) { self.datum = d; return }
return nil
}
init(from decoder: Decoder) throws {
let roh = try decoder.singleValueContainer().decode(String.self)
guard let fertig = Zeitpunkt(roh) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein Zeitpunkt: \(roh)"))
}
self = fertig
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.singleValueContainer()
try behaelter.encode(Zeitpunkt.mitBruchteilen.string(from: datum))
}
static func < (links: Zeitpunkt, rechts: Zeitpunkt) -> Bool {
links.datum < rechts.datum
}
}