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:
@@ -0,0 +1,220 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Buchungen und die Paarliste in einem Stand.
|
||||
///
|
||||
/// Beides gehört zusammen: Ohne die Paare zeigt die Liste beide Beine eines
|
||||
/// Übertrags als zwei Zeilen und damit dasselbe Geld zweimal.
|
||||
struct Buchungsstand: Codable, Sendable, Hashable {
|
||||
let buchungen: [Buchung]
|
||||
let paare: [String: Uebertragsverknuepfung]
|
||||
}
|
||||
|
||||
/// Die Buchungsliste.
|
||||
///
|
||||
/// Gesucht und gefiltert wird im **Server** – seit der Backend-Ergänzung
|
||||
/// kennt `GET /transactions` `q`, `tag_id` und `subscription_id`. Die
|
||||
/// Weboberfläche lädt bis zu 5000 Zeilen und siebt im Browser; auf dem
|
||||
/// Telefon wäre das der Unterschied zwischen „lädt" und „benutzbar".
|
||||
struct BuchungsListe: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
|
||||
@State private var lader: Lader<Buchungsstand>?
|
||||
@State private var suche = ""
|
||||
@State private var konto: Int?
|
||||
@State private var art: Buchungsart?
|
||||
/// Was zuletzt wirklich abgerufen wurde – damit das Tippen nicht je
|
||||
/// Buchstabe einen Abruf auslöst.
|
||||
@State private var abgerufen = ""
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lade() } }) { stand in
|
||||
liste(stand)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.buchungen"))
|
||||
.searchable(text: $suche, prompt: String(localized: "buchungen.suchen"))
|
||||
// Erst beim Absenden abrufen. Je Tastendruck zu fragen hiesse, dem
|
||||
// Server bei „Supermarkt" zehn Abrufe zu schicken, von denen neun
|
||||
// niemanden interessieren.
|
||||
.onSubmit(of: .search) { Task { await lade() } }
|
||||
.onChange(of: suche) { _, neu in
|
||||
if neu.isEmpty, !abgerufen.isEmpty { Task { await lade() } }
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { filterMenue }
|
||||
}
|
||||
.task {
|
||||
guard lader == nil else { return }
|
||||
await lade()
|
||||
}
|
||||
}
|
||||
|
||||
private var filterMenue: some View {
|
||||
Menu {
|
||||
Picker(String(localized: "buchungen.konto"), selection: $konto) {
|
||||
Text(String(localized: "buchungen.alleKonten")).tag(Int?.none)
|
||||
ForEach(zustand.nachschlag.konten.filter { !$0.isArchived }) { eines in
|
||||
Text(eines.name).tag(Int?.some(eines.id))
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "buchungen.art"), selection: $art) {
|
||||
Text(String(localized: "buchungen.alleArten")).tag(Buchungsart?.none)
|
||||
ForEach(Buchungsart.allCases) { eine in
|
||||
Text(eine.name).tag(Buchungsart?.some(eine))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: gefiltert ? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.onChange(of: konto) { _, _ in Task { await lade() } }
|
||||
.onChange(of: art) { _, _ in Task { await lade() } }
|
||||
}
|
||||
|
||||
private var gefiltert: Bool { konto != nil || art != nil || !abgerufen.isEmpty }
|
||||
|
||||
private func lade() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let text = suche.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gewaehltesKonto = konto
|
||||
let gewaehlteArt = art
|
||||
abgerufen = text
|
||||
|
||||
// Der Schlüssel trägt die Filter: Sonst überschriebe eine gefilterte
|
||||
// Liste den Offline-Stand der vollständigen.
|
||||
let schluessel = "transactions_\(gewaehltesKonto ?? 0)_\(gewaehlteArt?.rawValue ?? "alle")_\(text)"
|
||||
let neuer = Lader<Buchungsstand>(schluessel: schluessel) {
|
||||
async let buchungen = client.buchungen(konto: gewaehltesKonto, art: gewaehlteArt,
|
||||
suche: text.isEmpty ? nil : text)
|
||||
async let paare = client.uebertragsverknuepfungen()
|
||||
return try await Buchungsstand(buchungen: buchungen, paare: paare)
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liste(_ stand: Buchungsstand) -> some View {
|
||||
let zeilen = Buchungszeile.zeilen(aus: stand.buchungen, paare: stand.paare)
|
||||
|
||||
if zeilen.isEmpty {
|
||||
LeerAnsicht(titel: String(localized: "buchungen.leerTitel"),
|
||||
erklaerung: String(localized: gefiltert ? "buchungen.leerGefiltert"
|
||||
: "buchungen.leerErklaerung"),
|
||||
symbol: "list.bullet.rectangle")
|
||||
} else {
|
||||
List {
|
||||
ForEach(nachTagen(zeilen), id: \.tag) { gruppe in
|
||||
Section(gruppe.tag.formatiert()) {
|
||||
ForEach(gruppe.zeilen) { zeile in
|
||||
BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Nach Tagen gruppiert, Reihenfolge wie geliefert (Datum absteigend).
|
||||
private func nachTagen(_ zeilen: [Buchungszeile]) -> [(tag: Tagesdatum, zeilen: [Buchungszeile])] {
|
||||
var reihenfolge: [Tagesdatum] = []
|
||||
var nachTag: [Tagesdatum: [Buchungszeile]] = [:]
|
||||
for zeile in zeilen {
|
||||
let tag = zeile.buchung.date
|
||||
if nachTag[tag] == nil { reihenfolge.append(tag) }
|
||||
nachTag[tag, default: []].append(zeile)
|
||||
}
|
||||
return reihenfolge.map { (tag: $0, zeilen: nachTag[$0] ?? []) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Eine Zeile
|
||||
|
||||
private struct BuchungsZeile: View {
|
||||
let zeile: Buchungszeile
|
||||
let nachschlag: Nachschlag
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: symbol, farbe: farbe, groesse: 34)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(ueberschrift)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(unterzeile)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
ForEach(zeile.buchung.tagIds, id: \.self) { tagId in
|
||||
if let etikett = nachschlag.schlagwort(tagId) {
|
||||
Text(etikett.name)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color(hex: etikett.color).opacity(0.18), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(zeile.buchung.betrag.formatiert(vorzeichen: .ohne))
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(betragsfarbe)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var kategorie: Kategorie? { nachschlag.kategorie(zeile.buchung.categoryId) }
|
||||
|
||||
private var symbol: String {
|
||||
if zeile.istUebertrag { return "arrow-left-right" }
|
||||
return kategorie?.icon ?? "more-horizontal"
|
||||
}
|
||||
|
||||
private var farbe: String {
|
||||
if zeile.istUebertrag { return "#6b7480" }
|
||||
return kategorie?.color ?? "#6b7480"
|
||||
}
|
||||
|
||||
/// Bei einem Übertrag sagt die Zeile, wohin – „Übertrag" allein sagt
|
||||
/// nichts. Sonst steht da der Empfänger.
|
||||
private var ueberschrift: String {
|
||||
guard zeile.istUebertrag else { return zeile.buchung.ueberschrift }
|
||||
let von = nachschlag.kontoname(zeile.vonKonto)
|
||||
guard let nach = zeile.nachKonto else {
|
||||
return String(localized: "buchung.uebertrag")
|
||||
}
|
||||
return "\(von) → \(nachschlag.kontoname(nach))"
|
||||
}
|
||||
|
||||
private var unterzeile: String {
|
||||
if zeile.istUebertrag {
|
||||
return zeile.buchung.unterschrift ?? String(localized: "buchung.uebertrag")
|
||||
}
|
||||
var teile: [String] = []
|
||||
if let kategorie { teile.append(kategorie.name) }
|
||||
else { teile.append(String(localized: "buchung.ohneKategorie")) }
|
||||
teile.append(nachschlag.kontoname(zeile.buchung.accountId))
|
||||
return teile.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Grün bei Einnahme, neutral beim Übertrag: Da verlässt kein Geld den
|
||||
/// Haushalt, es wechselt nur die Tasche.
|
||||
private var betragsfarbe: Color {
|
||||
if zeile.istUebertrag { return .secondary }
|
||||
return zeile.buchung.type == .income ? .green : .primary
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user