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? 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(schluessel: "dashboard") { try await client.dashboard() } lader = neuer await neuer.lade() } } } // MARK: - Karten /// Der Rahmen, den alle Karten teilen. private struct Karte: 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 } } }