Die App kann jetzt lesen, wofuer man sie oeffnet. Vier neue Reiter: - Uebersicht: Einnahmen/Ausgaben des Monats, Budget-Fortschritt je Gruppe, Kontostaende und die letzten Buchungen. Feste Auswahl statt des verschiebbaren Rasters der Weboberflaeche - auf Telefonbreite waere das Arbeit ohne Gewinn. Vier Endpunkte nebeneinander in einem Abruf, damit der Offline-Stand nicht aus vier verschiedenen Momenten stammt. - Budget: Soll gegen Ist je Gruppe und Kategorie, blaetterbar ueber prev/next aus der Antwort. Rot nur bei echter Ueberschreitung - wer mehr zuruecklegt als geplant, hat nichts ueberzogen. Vertraege ohne Kategorie und fehlende Wechselkurse stehen als Hinweis darunter, statt die Summen still zu verfaelschen. - Buchungen: Suche und Filter laufen im Server (Backend-Ergaenzung B), nicht im Telefon. Gesucht wird beim Absenden, nicht je Tastendruck. - Vertraege: Monats- und Jahressumme, Rhythmus, naechste Faelligkeit. Zwei Dinge, die das Backend bewusst dem Client ueberlaesst: Ein Uebertrag kommt in zwei Gestalten - am Stueck (eine Buchung mit to_account_id) oder als zwei verknuepfte Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen hiesse, dasselbe Geld zweimal zu zeigen. Buchungszeile.zeilen fasst sie zusammen, aber nur wenn beide in der Liste stehen - sonst liesse ein Kontofilter Zeilen verschwinden, die sehr wohl zu diesem Konto gehoeren. Die Richtung kommt aus der Verknuepfung, nicht aus dem Vorzeichen: Manche Banken liefern beide Beine positiv. Kategorien, Schlagwoerter und Konten liegen als Nachschlagewerk im AppZustand, einmal geladen. Je Ansicht neu zu holen waere genau das N+1, das das Backend sich selbst verbietet. Texte in Deutsch und Englisch, zeilengleich. 44 Tests gruen, darunter die Uebertrags-Paarung und jedes Modell gegen eine echte Serverantwort. Nicht geprueft: der Weg gegen ein echtes Backend. Die App laesst nur https zu, ein lokaler Server haette eine ATS-Ausnahme gebraucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
10 KiB
Swift
282 lines
10 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>?
|
||
|
||
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
|
||
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)
|
||
}
|
||
}
|