365 lines
14 KiB
Swift
365 lines
14 KiB
Swift
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<Budgetmonat>?
|
||
@State private var bearbeitet: ZeileZumBearbeiten?
|
||
@State private var vorgang = Speichervorgang()
|
||
@State private var fragtNachZuruecksetzen = false
|
||
|
||
/// Welche Zeile wohin gesetzt wird – beides zusammen, damit das Blatt
|
||
/// weiss, ob es den Monat oder die Vorlage ändert.
|
||
struct ZeileZumBearbeiten: Identifiable, Equatable {
|
||
let kategorie: Budgetkategorie
|
||
let ziel: BudgetZeileBearbeiten.Ziel
|
||
var id: Int { kategorie.categoryId }
|
||
}
|
||
|
||
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<Budgetmonat>(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
|
||
Button {
|
||
bearbeitet = ZeileZumBearbeiten(kategorie: kategorie,
|
||
ziel: .monat(monat.label))
|
||
} label: {
|
||
KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.swipeActions(edge: .trailing) {
|
||
Button {
|
||
bearbeitet = ZeileZumBearbeiten(kategorie: kategorie,
|
||
ziel: .vorlage)
|
||
} label: {
|
||
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
|
||
}
|
||
.tint(.marke)
|
||
}
|
||
}
|
||
} 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"))
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Menu {
|
||
Button {
|
||
Task { await uebernimm { try await $0.holeVorlageInMonat(monat.label) } }
|
||
} label: {
|
||
Label(String(localized: "budget.vorlageHolen"),
|
||
systemImage: "arrow.down.doc")
|
||
}
|
||
Button {
|
||
Task { await uebernimm { try await $0.macheMonatZurVorlage(monat.label) } }
|
||
} label: {
|
||
Label(String(localized: "budget.alsVorlageSetzen"),
|
||
systemImage: "arrow.up.doc")
|
||
}
|
||
if monat.customized {
|
||
Button(role: .destructive) {
|
||
fragtNachZuruecksetzen = true
|
||
} label: {
|
||
Label(String(localized: "budget.zuruecksetzen"),
|
||
systemImage: "arrow.uturn.backward")
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
}
|
||
.disabled(vorgang.laeuft)
|
||
}
|
||
}
|
||
.sheet(item: $bearbeitet) { eintrag in
|
||
BudgetZeileBearbeiten(ziel: eintrag.ziel, kategorie: eintrag.kategorie,
|
||
waehrung: monat.baseCurrency) {
|
||
Task { await lader?.aktualisiere() }
|
||
}
|
||
.environment(zustand)
|
||
}
|
||
.confirmationDialog(String(localized: "budget.zuruecksetzenFrage"),
|
||
isPresented: $fragtNachZuruecksetzen, titleVisibility: .visible) {
|
||
Button(String(localized: "budget.zuruecksetzen"), role: .destructive) {
|
||
Task { await uebernimm { try await $0.setzeBudgetMonatZurueck(monat.label) } }
|
||
}
|
||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||
} message: {
|
||
Text(String(localized: "budget.zuruecksetzenErklaerung"))
|
||
}
|
||
.alert(String(localized: "fehler.titel"),
|
||
isPresented: .constant(vorgang.fehler != nil)) {
|
||
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
|
||
} message: {
|
||
Text(vorgang.fehler ?? "")
|
||
}
|
||
}
|
||
|
||
/// Die drei Knöpfe im Menü tun dasselbe: etwas schreiben, dann neu laden.
|
||
private func uebernimm(_ arbeit: @escaping (CertainClient) async throws -> Void) async {
|
||
guard let client = zustand.client else { return }
|
||
if await vorgang.fuehreAus({ try await arbeit(client) }) {
|
||
await lader?.aktualisiere()
|
||
}
|
||
}
|
||
|
||
/// „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)
|
||
}
|
||
}
|