feat: Zweite Stufe der iOS-App - Uebersicht, Budget, Buchungen, Vertraege

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>
This commit is contained in:
Guido
2026-10-21 20:24:06 +02:00
co-authored by Claude Opus 5
parent 58bd504333
commit bf437b3b69
16 changed files with 2252 additions and 2 deletions
+158
View File
@@ -0,0 +1,158 @@
import SwiftUI
/// Verträge und Abos: was jeden Monat von selbst abgeht.
struct AboListe: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<Aboliste>?
@State private var zeigeBeendete = false
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
inhalt(liste)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "reiter.abos"))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(String(localized: "abos.beendeteZeigen"), isOn: $zeigeBeendete)
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<Aboliste>(schluessel: "subscriptions") {
try await client.abos()
}
lader = neuer
await neuer.lade()
}
}
@ViewBuilder
private func inhalt(_ liste: Aboliste) -> some View {
let sichtbar = liste.subscriptions.filter { zeigeBeendete || $0.isActive }
if sichtbar.isEmpty {
LeerAnsicht(titel: String(localized: "abos.leerTitel"),
erklaerung: String(localized: "abos.leerErklaerung"),
symbol: "arrow.triangle.2.circlepath")
} else {
List {
Section {
SummenKopf(summen: liste.summary)
}
Section {
ForEach(sichtbar) { abo in
AboZeile(abo: abo, nachschlag: zustand.nachschlag)
}
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
}
}
private struct SummenKopf: View {
let summen: Abosummen
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(summen.monatlich.formatiert())
.font(.title.weight(.semibold).monospacedDigit())
Text(String(localized: "abo.monatlich"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(String(format: String(localized: "abos.jaehrlichUndAnzahl"),
summen.jaehrlich.formatiert(), summen.anzahlAktiv))
.font(.caption)
.foregroundStyle(.secondary)
if !summen.ohneKurs.isEmpty {
// Ohne diesen Hinweis ist die Summe still zu klein, und
// niemand sieht, dass etwas fehlt.
Label {
Text(String(format: String(localized: "abos.ohneKurs"),
summen.ohneKurs.joined(separator: ", ")))
} icon: {
Image(systemName: "exclamationmark.triangle")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
.padding(.vertical, 4)
}
}
private struct AboZeile: View {
let abo: Abo
let nachschlag: Nachschlag
var body: some View {
HStack(spacing: 12) {
Symbolplakette(symbol: abo.icon, farbe: abo.color, groesse: 36)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(abo.name)
.font(.body)
.lineLimit(1)
if !abo.isActive {
Text(String(localized: "abos.beendet"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
Text(unterzeile)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(abo.betrag.formatiert())
.font(.body.monospacedDigit())
// Nur dort nennen, wo es etwas sagt: Bei einem monatlichen
// Vertrag stuende zweimal derselbe Betrag.
if abo.intervalMonths != 1 {
Text(String(format: String(localized: "abo.proMonat"),
abo.monatlich.formatiert()))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 2)
}
private var unterzeile: String {
var teile = [abo.rhythmus]
if let kategorie = nachschlag.kategorie(abo.categoryId) {
teile.append(kategorie.name)
}
if abo.isActive, let faellig = abo.nextDue {
if let tage = abo.tageBisFaellig, tage <= 7 {
teile.append(String(format: String(localized: "abo.inTagen"), tage))
} else {
teile.append(String(format: String(localized: "abo.naechste"), faellig.formatiert()))
}
}
return teile.joined(separator: " · ")
}
}