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
+155
View File
@@ -0,0 +1,155 @@
import SwiftUI
/// Die Kontenübersicht – die erste Fachansicht und zugleich die Probe, ob
/// Netzwerk, Modelle, Zwischenspeicher und Geldformatierung zusammenpassen.
struct KontenListe: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<[Konto]>?
@State private var zeigeArchivierte = false
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { konten in
liste(konten)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "konten.titel"))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(String(localized: "konten.archivierteZeigen"),
isOn: $zeigeArchivierte)
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<[Konto]>(schluessel: "accounts") {
try await client.konten()
}
lader = neuer
await neuer.lade()
}
}
@ViewBuilder
private func liste(_ alle: [Konto]) -> some View {
let sichtbar = alle.filter { zeigeArchivierte || !$0.isArchived }
if sichtbar.isEmpty {
LeerAnsicht(titel: String(localized: "konten.leerTitel"),
erklaerung: String(localized: "konten.leerErklaerung"),
symbol: "wallet.bifold")
} else {
List {
Section {
ForEach(sichtbar) { konto in
KontoZeile(konto: konto)
}
} footer: {
Gesamtsumme(konten: sichtbar,
waehrung: zustand.einstellungen.basiswaehrung)
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
}
}
private struct KontoZeile: View {
let konto: Konto
var body: some View {
HStack(spacing: 12) {
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 36)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(konto.name)
.font(.body)
.lineLimit(1)
if konto.isLocked {
// „Gebundenes Geld": zählt nicht zum verfügbaren Betrag.
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel(String(localized: "konten.gebunden"))
}
if konto.isArchived {
Text(String(localized: "konten.archiviert"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
Text(konto.type.name)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(konto.saldo.formatiert())
.font(.body.monospacedDigit())
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
if konto.weichtVonDerBankAb, let bank = konto.bankBalanceCents {
// Nur melden, nicht korrigieren – welcher Wert stimmt,
// entscheidet der Mensch.
Label(Geldbetrag(cents: bank, waehrung: konto.currency).formatiert(),
systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(String(localized: "konten.bankWeichtAb"))
}
}
}
.padding(.vertical, 2)
}
}
/// Die Summe unten. Gebundene und archivierte Konten bleiben aussen vor –
/// genauso rechnet das Dashboard im Backend (`routers/dashboard.py`).
private struct Gesamtsumme: View {
let konten: [Konto]
let waehrung: String
var body: some View {
let verfuegbar = konten
.filter { !$0.isLocked && !$0.isArchived }
.reduce(0) { $0 + $1.balanceBaseCents }
let gebunden = konten
.filter { $0.isLocked && !$0.isArchived }
.reduce(0) { $0 + $1.balanceBaseCents }
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(String(localized: "konten.verfuegbar"))
Spacer()
Text(Geldbetrag(cents: verfuegbar, waehrung: waehrung).formatiert())
.monospacedDigit()
}
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
if gebunden != 0 {
HStack {
Text(String(localized: "konten.gebundenSumme"))
Spacer()
Text(Geldbetrag(cents: gebunden, waehrung: waehrung).formatiert())
.monospacedDigit()
}
.font(.caption)
}
}
.padding(.top, 6)
}
}