Buchungen anlegen, aendern und loeschen; Budget-Zeilen setzen, fuer den Monat oder fuer die Vorlage; Vertraege und Konten anlegen und aendern. Damit kann die App alles, wofuer man bisher an den Rechner musste - ausser Import, Arbeitsliste und Verwaltung. Ein Formular je Sache, nicht zwei: Anlegen und Aendern teilen sich die Maske, weil die Felder dieselben sind und zwei Kopien genau die Stelle waeren, an der sie auseinanderlaufen. Was sich unterscheidet, steht in einem Zweck-Typ. Geteilt in Bausteine/Formular.swift: ein Speichervorgang, der den Knopf sperrt und den Fehler festhaelt (das Formular bleibt offen - zugeklappt waere die Eingabe weg und die Meldung nutzlos), ein Betragsfeld, das an Cent gebunden ist statt an Text, und die Auswahlfelder fuer Kategorie, Konto und Schlagwoerter. Drei Dinge, die stillschweigend falsch waeren: Eine Teilaenderung schickt nur, was gesetzt ist. Ginge ein nicht angefasstes Feld als null hinaus, loeschte jedes Speichern den Empfaenger, die Notiz und die Kategorie gleich mit. Eine leere Tag-Liste geht dagegen sehr wohl mit - sie bedeutet "alle abnehmen" und darf nicht mit "nicht angefasst" zusammenfallen. Bei einem Uebertrag wird keine Kategorie mitgeschickt: Das Geld wechselt nur die Tasche, es wird nicht eingeordnet. Bei Betriebsart "berechnet" bleibt planned_cents unberuehrt - das Backend liest es dann nicht, und es zu ueberschreiben verloere den Wert, auf den ein Zurueckschalten auf "fest" zurueckfallen soll. Die Waehrung eines bestehenden Kontos laesst sich nicht aendern: Jeder gespeicherte Betrag wuerde danach etwas anderes bedeuten. 49 Tests gruen. Texte in Deutsch und Englisch, zeilengleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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(.indigo)
|
||
}
|
||
}
|
||
} 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)
|
||
}
|
||
}
|