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:
co-authored by
Claude Opus 5
parent
8a7545bf5b
commit
6b86a6bf91
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user