Die App kann jetzt lesen, wofuer man sie oeffnet. Vier neue Reiter: - Uebersicht: Einnahmen/Ausgaben des Monats, Budget-Fortschritt je Gruppe, Kontostaende und die letzten Buchungen. Feste Auswahl statt des verschiebbaren Rasters der Weboberflaeche - auf Telefonbreite waere das Arbeit ohne Gewinn. Vier Endpunkte nebeneinander in einem Abruf, damit der Offline-Stand nicht aus vier verschiedenen Momenten stammt. - Budget: Soll gegen Ist je Gruppe und Kategorie, blaetterbar ueber prev/next aus der Antwort. Rot nur bei echter Ueberschreitung - wer mehr zuruecklegt als geplant, hat nichts ueberzogen. Vertraege ohne Kategorie und fehlende Wechselkurse stehen als Hinweis darunter, statt die Summen still zu verfaelschen. - Buchungen: Suche und Filter laufen im Server (Backend-Ergaenzung B), nicht im Telefon. Gesucht wird beim Absenden, nicht je Tastendruck. - Vertraege: Monats- und Jahressumme, Rhythmus, naechste Faelligkeit. Zwei Dinge, die das Backend bewusst dem Client ueberlaesst: Ein Uebertrag kommt in zwei Gestalten - am Stueck (eine Buchung mit to_account_id) oder als zwei verknuepfte Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen hiesse, dasselbe Geld zweimal zu zeigen. Buchungszeile.zeilen fasst sie zusammen, aber nur wenn beide in der Liste stehen - sonst liesse ein Kontofilter Zeilen verschwinden, die sehr wohl zu diesem Konto gehoeren. Die Richtung kommt aus der Verknuepfung, nicht aus dem Vorzeichen: Manche Banken liefern beide Beine positiv. Kategorien, Schlagwoerter und Konten liegen als Nachschlagewerk im AppZustand, einmal geladen. Je Ansicht neu zu holen waere genau das N+1, das das Backend sich selbst verbietet. Texte in Deutsch und Englisch, zeilengleich. 44 Tests gruen, darunter die Uebertrags-Paarung und jedes Modell gegen eine echte Serverantwort. Nicht geprueft: der Weg gegen ein echtes Backend. Die App laesst nur https zu, ein lokaler Server haette eine ATS-Ausnahme gebraucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
10 KiB
Swift
279 lines
10 KiB
Swift
import SwiftUI
|
||
|
||
/// Alles, was das Dashboard braucht, in einem Abruf.
|
||
///
|
||
/// Vier Endpunkte nebeneinander statt nacheinander – sie wissen nichts
|
||
/// voneinander. Zusammen in einem Wert, damit `Lader` sie als einen Stand
|
||
/// merkt: Vier getrennte Zwischenspeicher könnten offline aus vier
|
||
/// verschiedenen Momenten stammen und sich widersprechen.
|
||
struct Dashboarddaten: Codable, Sendable, Hashable {
|
||
let konten: Kontostaende
|
||
let fortschritt: Budgetfortschritt
|
||
let bilanz: EinnahmenAusgaben
|
||
let letzte: LetzteBuchungen
|
||
}
|
||
|
||
extension CertainClient {
|
||
func dashboard() async throws -> Dashboarddaten {
|
||
async let konten = kontostaende()
|
||
async let fortschritt = budgetfortschritt()
|
||
async let bilanz = einnahmenAusgaben()
|
||
async let letzte = letzteBuchungen()
|
||
return try await Dashboarddaten(konten: konten, fortschritt: fortschritt,
|
||
bilanz: bilanz, letzte: letzte)
|
||
}
|
||
}
|
||
|
||
/// Die Startseite. Anders als im Web ist die Auswahl der Karten fest – ein
|
||
/// verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn.
|
||
struct DashboardAnsicht: View {
|
||
@Environment(AppZustand.self) private var zustand
|
||
@State private var lader: Lader<Dashboarddaten>?
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let lader {
|
||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { daten in
|
||
ScrollView {
|
||
VStack(spacing: 16) {
|
||
BilanzKarte(bilanz: daten.bilanz)
|
||
FortschrittKarte(fortschritt: daten.fortschritt)
|
||
KontenKarte(staende: daten.konten)
|
||
LetzteKarte(letzte: daten.letzte)
|
||
}
|
||
.padding()
|
||
}
|
||
.refreshable { await lader.aktualisiere() }
|
||
}
|
||
} else {
|
||
ProgressView()
|
||
}
|
||
}
|
||
.navigationTitle(String(localized: "reiter.dashboard"))
|
||
.task {
|
||
guard lader == nil, let client = zustand.client else { return }
|
||
let neuer = Lader<Dashboarddaten>(schluessel: "dashboard") {
|
||
try await client.dashboard()
|
||
}
|
||
lader = neuer
|
||
await neuer.lade()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Karten
|
||
|
||
/// Der Rahmen, den alle Karten teilen.
|
||
private struct Karte<Inhalt: View>: View {
|
||
let titel: String
|
||
var fussnote: String?
|
||
@ViewBuilder let inhalt: () -> Inhalt
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text(titel)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
inhalt()
|
||
if let fussnote {
|
||
Text(fussnote)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(16)
|
||
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
}
|
||
}
|
||
|
||
/// Einnahmen, Ausgaben und was übrig bleibt – die Zahl, wegen der man die
|
||
/// App öffnet.
|
||
private struct BilanzKarte: View {
|
||
let bilanz: EinnahmenAusgaben
|
||
|
||
var body: some View {
|
||
Karte(titel: String(localized: "dashboard.bilanz")) {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(bilanz.uebrig.formatiert())
|
||
.font(.largeTitle.weight(.semibold).monospacedDigit())
|
||
.foregroundStyle(bilanz.net < 0 ? .red : .primary)
|
||
.contentTransition(.numericText())
|
||
|
||
HStack(spacing: 20) {
|
||
Beschriftet(titel: String(localized: "dashboard.einnahmen"),
|
||
wert: bilanz.einnahmen.formatiert(), farbe: .green)
|
||
Beschriftet(titel: String(localized: "dashboard.ausgaben"),
|
||
wert: bilanz.ausgaben.formatiert(), farbe: .red)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct Beschriftet: View {
|
||
let titel: String
|
||
let wert: String
|
||
let farbe: Color
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(titel)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Text(wert)
|
||
.font(.callout.monospacedDigit())
|
||
.foregroundStyle(farbe)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Je Gruppe ein Balken: wie viel vom Budget schon weg ist.
|
||
private struct FortschrittKarte: View {
|
||
let fortschritt: Budgetfortschritt
|
||
|
||
var body: some View {
|
||
Karte(titel: String(localized: "dashboard.budgetfortschritt")) {
|
||
if fortschritt.rows.isEmpty {
|
||
Text(String(localized: "dashboard.keinBudget"))
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
VStack(spacing: 12) {
|
||
ForEach(fortschritt.rows) { zeile in
|
||
Balken(zeile: zeile, waehrung: fortschritt.currency)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct Balken: View {
|
||
let zeile: Budgetfortschritt.Zeile
|
||
let waehrung: String
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(zeile.name)
|
||
.font(.callout)
|
||
.lineLimit(1)
|
||
Spacer(minLength: 8)
|
||
Text(Geldbetrag(cents: zeile.actual, waehrung: waehrung).formatiert())
|
||
.font(.caption.monospacedDigit())
|
||
Text("/")
|
||
.font(.caption)
|
||
.foregroundStyle(.tertiary)
|
||
Text(Geldbetrag(cents: zeile.planned, waehrung: waehrung).formatiert())
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
ProgressView(value: zeile.anteil)
|
||
.tint(farbe)
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
|
||
/// Rot nur bei echter Überschreitung. Wer mehr zurücklegt als
|
||
/// geplant, hat nichts überzogen – deshalb fragt `good` dazwischen.
|
||
private var farbe: Color {
|
||
if zeile.good { return .green }
|
||
return zeile.over ? .red : Color(hex: zeile.color)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Die Kontostände, und was davon wirklich verfügbar ist.
|
||
private struct KontenKarte: View {
|
||
let staende: Kontostaende
|
||
|
||
var body: some View {
|
||
Karte(titel: String(localized: "dashboard.kontostaende")) {
|
||
VStack(spacing: 10) {
|
||
ForEach(staende.accounts.filter { !$0.archived }) { konto in
|
||
HStack(spacing: 10) {
|
||
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 28)
|
||
Text(konto.name)
|
||
.font(.callout)
|
||
.lineLimit(1)
|
||
if konto.locked {
|
||
Image(systemName: "lock.fill")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityLabel(String(localized: "konten.gebunden"))
|
||
}
|
||
Spacer(minLength: 8)
|
||
Text(konto.saldo.formatiert())
|
||
.font(.callout.monospacedDigit())
|
||
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
|
||
}
|
||
}
|
||
|
||
Divider()
|
||
|
||
HStack {
|
||
Text(String(localized: "konten.verfuegbar"))
|
||
.font(.callout.weight(.medium))
|
||
Spacer()
|
||
Text(staende.summe.formatiert())
|
||
.font(.callout.weight(.medium).monospacedDigit())
|
||
}
|
||
if staende.gebunden != 0 {
|
||
HStack {
|
||
Text(String(localized: "konten.gebundenSumme"))
|
||
Spacer()
|
||
Text(staende.gebundenBetrag.formatiert())
|
||
.monospacedDigit()
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Was zuletzt passiert ist. Die Überschriften kommen fertig formuliert vom
|
||
/// Server – bei einem Übertrag steht dort „Konto A → Konto B".
|
||
private struct LetzteKarte: View {
|
||
let letzte: LetzteBuchungen
|
||
|
||
var body: some View {
|
||
Karte(titel: String(localized: "dashboard.letzteBuchungen")) {
|
||
if letzte.transactions.isEmpty {
|
||
Text(String(localized: "buchungen.leerErklaerung"))
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
VStack(spacing: 12) {
|
||
ForEach(letzte.transactions) { zeile in
|
||
HStack(spacing: 10) {
|
||
Symbolplakette(symbol: zeile.icon, farbe: zeile.color, groesse: 30)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(zeile.title)
|
||
.font(.callout)
|
||
.lineLimit(1)
|
||
Text(zeile.date.formatiert())
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer(minLength: 8)
|
||
Text(zeile.betrag.formatiert(vorzeichen: .ohne))
|
||
.font(.callout.monospacedDigit())
|
||
.foregroundStyle(farbe(zeile.vorzeichen))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func farbe(_ vorzeichen: LetzteBuchungen.Zeile.Vorzeichen) -> Color {
|
||
switch vorzeichen {
|
||
case .plus: return .green
|
||
case .minus: return .primary
|
||
case .keines: return .secondary
|
||
}
|
||
}
|
||
}
|