import SwiftUI /// Soll gegen Ist, Monat für Monat. /// /// Geblättert wird über `prev` und `next` aus der Antwort – welcher Monat auf /// welchen folgt, hängt am eingestellten Budget-Starttag und ist nichts, was /// der Client ausrechnen sollte. struct BudgetAnsicht: View { @Environment(AppZustand.self) private var zustand /// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label. @State private var label: String? @State private var lader: Lader? var body: some View { Group { if let lader { Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { monat in liste(monat) } } else { ProgressView() } } .navigationTitle(String(localized: "reiter.budget")) .navigationBarTitleDisplayMode(.inline) .task(id: label) { await ladeMonat() } } private func ladeMonat() async { guard let client = zustand.client else { return } let gewaehlt = label let neuer = Lader(schluessel: "budget/\(gewaehlt ?? "current")") { try await client.budget(gewaehlt) } lader = neuer await neuer.lade() } @ViewBuilder private func liste(_ monat: Budgetmonat) -> some View { List { Section { MonatsKopf(monat: monat) } if !monat.income.isEmpty { Section(String(localized: "budget.einnahmen")) { ForEach(monat.income) { posten in EinnahmeZeile(posten: posten, waehrung: monat.baseCurrency) } } } ForEach(monat.groups) { gruppe in Section { ForEach(gruppe.categories) { kategorie in KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency) } } header: { GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency) } } if !monat.vertraege.istLeer { Section { VertragsHinweis(reste: monat.vertraege, waehrung: monat.baseCurrency) } } } .listStyle(.insetGrouped) .refreshable { await lader?.aktualisiere() } .toolbar { ToolbarItem(placement: .topBarLeading) { Button { label = monat.prev } label: { Image(systemName: "chevron.left") } .accessibilityLabel(String(localized: "budget.vorigerMonat")) } ToolbarItem(placement: .principal) { Text(monatsname(monat)) .font(.headline) } ToolbarItem(placement: .topBarTrailing) { Button { label = monat.next } label: { Image(systemName: "chevron.right") } .accessibilityLabel(String(localized: "budget.naechsterMonat")) } } } /// „Oktober 2026" statt „2026-10". private func monatsname(_ monat: Budgetmonat) -> String { monat.start.datum.formatted(.dateTime.month(.wide).year()) } } // MARK: - Bausteine private struct MonatsKopf: View { let monat: Budgetmonat var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { Text(Geldbetrag(cents: monat.restCents, waehrung: monat.baseCurrency).formatiert()) .font(.title.weight(.semibold).monospacedDigit()) .foregroundStyle(monat.restCents < 0 ? .red : .primary) Text(String(localized: "budget.uebrig")) .font(.subheadline) .foregroundStyle(.secondary) } ProgressView(value: monat.anteilVerbraucht) .tint(monat.restCents < 0 ? .red : .accentColor) HStack { Text(String(format: String(localized: "budget.vonGeplant"), Geldbetrag(cents: monat.totals.actualExpense, waehrung: monat.baseCurrency).formatiert(), Geldbetrag(cents: monat.totals.plannedExpense, waehrung: monat.baseCurrency).formatiert())) Spacer() if monat.customized { // Wer das nicht sieht, wundert sich, warum die Vorlage // andere Zahlen zeigt als dieser Monat. Text(String(localized: "budget.angepasst")) .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(.quaternary, in: Capsule()) } } .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) } } private struct GruppenKopf: View { let gruppe: Budgetgruppe let waehrung: String var body: some View { HStack(spacing: 8) { Image(systemName: Symbole.sf(gruppe.icon)) .foregroundStyle(Color(hex: gruppe.color)) Text(gruppe.name) Spacer(minLength: 8) Text(Geldbetrag(cents: gruppe.actualCents, waehrung: waehrung).formatiert()) .monospacedDigit() .foregroundStyle(gruppe.over && !gruppe.good ? .red : .secondary) Text("/") .foregroundStyle(.tertiary) Text(Geldbetrag(cents: gruppe.plannedCents, waehrung: waehrung).formatiert()) .monospacedDigit() .foregroundStyle(.secondary) } .textCase(nil) .font(.footnote) } } private struct KategorieZeile: View { let kategorie: Budgetkategorie let waehrung: String var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 10) { Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30) VStack(alignment: .leading, spacing: 1) { Text(kategorie.name) .font(.callout) .lineLimit(1) if let unterzeile { Text(unterzeile) .font(.caption2) .foregroundStyle(.secondary) } } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 1) { Text(Geldbetrag(cents: kategorie.actualCents, waehrung: waehrung).formatiert()) .font(.callout.monospacedDigit()) .foregroundStyle(warnt ? .red : .primary) Text(String(format: String(localized: "budget.vonBetrag"), Geldbetrag(cents: kategorie.plannedCents, waehrung: waehrung).formatiert())) .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } } if kategorie.plannedCents > 0 || kategorie.actualCents > 0 { ProgressView(value: kategorie.anteilVerbraucht) .tint(warnt ? .red : (kategorie.good ? .green : Color(hex: kategorie.color))) } } .padding(.vertical, 2) } /// Rot nur bei echter Überschreitung: Mehr sparen als geplant ist keine. private var warnt: Bool { kategorie.over && !kategorie.good } /// Woher der Soll-Wert kommt, wenn er sich nicht einfach ablesen lässt. private var unterzeile: String? { guard kategorie.vertraegeAnzahl > 0 else { return nil } let ausVertraegen = Geldbetrag(cents: kategorie.ausVertraegenCents, waehrung: waehrung).formatiert() if kategorie.istBerechnet { return String(format: String(localized: "budget.ausVertraegenBerechnet"), kategorie.vertraegeAnzahl, ausVertraegen) } return String(format: String(localized: "budget.ausVertraegenHinweis"), kategorie.vertraegeAnzahl, ausVertraegen) } } private struct EinnahmeZeile: View { let posten: GeplanteEinnahme let waehrung: String var body: some View { HStack { Text(posten.label) .font(.callout) Spacer(minLength: 8) Text(Geldbetrag(cents: posten.receivedCents, waehrung: waehrung).formatiert()) .font(.callout.monospacedDigit()) .foregroundStyle(posten.receivedCents >= posten.plannedCents ? .green : .primary) Text("/") .foregroundStyle(.tertiary) Text(Geldbetrag(cents: posten.plannedCents, waehrung: waehrung).formatiert()) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } } /// Verträge, die im Budget fehlen – und Währungen, für die kein Kurs vorlag. /// /// Ohne diesen Hinweis stimmen die Summen still nicht. Genau davor warnt das /// Backend mit den `ohne_kurs`-Listen. private struct VertragsHinweis: View { let reste: Vertragsreste let waehrung: String var body: some View { VStack(alignment: .leading, spacing: 6) { if reste.ohneKategorieAnzahl > 0 { Label { Text(String(format: String(localized: "budget.vertraegeOhneKategorie"), reste.ohneKategorieAnzahl, Geldbetrag(cents: reste.ohneKategorieCents, waehrung: waehrung).formatiert())) } icon: { Image(systemName: "questionmark.circle") } } if !reste.ohneKurs.isEmpty { Label { Text(String(format: String(localized: "budget.ohneKurs"), reste.ohneKurs.joined(separator: ", "))) } icon: { Image(systemName: "exclamationmark.triangle") } .foregroundStyle(.orange) } } .font(.footnote) } }