Files
certain/ios/Budget/BudgetAnsicht.swift

365 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}