Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf437b3b69 | ||
|
|
58bd504333 | ||
|
|
a071422e23 |
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Echte Antworten des Backends als Prüfdatei für die iOS-Modelle.
|
||||
|
||||
Aufruf aus ``backend/``::
|
||||
|
||||
.venv/bin/python -m scripts.erzeuge_ios_fixture
|
||||
|
||||
Schreibt ``ios/Tests/Fixtures/antworten.json``. Der Swift-Test
|
||||
``Serverantworten`` entschlüsselt daraus jedes Modell und schlägt fehl, sobald
|
||||
ein Feldname nicht mehr passt.
|
||||
|
||||
**Warum es das gibt.** Die iOS-App wird gegen Modelle geschrieben, die jemand
|
||||
aus den Pydantic-Schemas abgeschrieben hat. Ein vertippter Feldname fällt beim
|
||||
Übersetzen nicht auf -- er fällt erst dem Nutzer auf, wenn die Liste leer
|
||||
bleibt. Diese Datei ist die einzige Prüfung, die das fängt.
|
||||
|
||||
Nach jeder Änderung an einem Schema neu laufen lassen und das Ergebnis
|
||||
mit committen. Die Datenbank ist eine Wegwerfdatenbank; es landen keine
|
||||
echten Daten in der Datei.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Muss vor dem Import von app stehen -- die Konfiguration wird beim Import gelesen.
|
||||
os.environ["APP_DATA_DIR"] = tempfile.mkdtemp(prefix="cf-fixture-")
|
||||
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
|
||||
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
|
||||
os.environ.setdefault("APP_ADMIN_PASSWORD", "AdminPass1")
|
||||
os.environ.setdefault("APP_SESSION_SECRET", "fixture-secret")
|
||||
|
||||
from datetime import date # noqa: E402
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
|
||||
|
||||
|
||||
def _seed(c: TestClient) -> None:
|
||||
"""Genug Daten, dass jedes Feld einmal belegt ist.
|
||||
|
||||
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
|
||||
nur auf, wenn es eine Zeile gibt.
|
||||
"""
|
||||
c.put("/api/settings/budget_start_day", params={"value": "1"})
|
||||
giro = c.post("/api/accounts", json={
|
||||
"name": "Girokonto", "type": "girokonto", "currency": "EUR",
|
||||
"opening_balance_cents": 120000}).json()
|
||||
sparen = c.post("/api/accounts", json={
|
||||
"name": "Sparen", "type": "sparkonto", "currency": "CHF",
|
||||
"opening_balance_cents": 500000, "is_locked": True}).json()
|
||||
|
||||
gruppen = c.get("/api/categories").json()
|
||||
ausgabe = next(g for g in gruppen if g["kind"] == "expense")["categories"][0]
|
||||
einnahme = next(g for g in gruppen if g["kind"] == "income")["categories"][0]
|
||||
|
||||
tag = c.post("/api/tags", json={"name": "Urlaub", "color": "#2a78d6"}).json()
|
||||
abo = c.post("/api/subscriptions", json={
|
||||
"name": "Streaming", "amount_cents": 1299, "currency": "EUR",
|
||||
"interval_months": 12, "category_id": ausgabe["id"]}).json()
|
||||
|
||||
heute = str(date.today())
|
||||
kauf = c.post("/api/transactions", json={
|
||||
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||
"amount_cents": 4250, "category_id": ausgabe["id"], "payee": "Supermarkt",
|
||||
"description": "Wocheneinkauf", "notes": "mit Pfand"}).json()
|
||||
c.patch(f"/api/transactions/{kauf['id']}", json={"tag_ids": [tag["id"]]})
|
||||
c.post("/api/transactions", json={
|
||||
"type": "income", "date": heute, "account_id": giro["id"],
|
||||
"amount_cents": 350000, "category_id": einnahme["id"], "payee": "Arbeitgeber"})
|
||||
c.post("/api/transactions", json={
|
||||
"type": "transfer", "date": heute, "account_id": giro["id"],
|
||||
"to_account_id": sparen["id"], "amount_cents": 20000})
|
||||
|
||||
# Zwei getrennte Buchungen, von Hand zu einem Uebertrag verknuepft -- der
|
||||
# zweite Mechanismus, den die App nachbauen muss.
|
||||
raus = c.post("/api/transactions", json={
|
||||
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||
"amount_cents": 15000, "payee": "Ueberweisung"}).json()
|
||||
rein = c.post("/api/transactions", json={
|
||||
"type": "income", "date": heute, "account_id": sparen["id"],
|
||||
"amount_cents": 15000, "payee": "Gutschrift"}).json()
|
||||
c.post("/api/uebertraege/verbinden",
|
||||
json={"a_id": raus["id"], "b_id": rein["id"]})
|
||||
|
||||
c.put(f"/api/budget/template/category/{ausgabe['id']}",
|
||||
params={"planned_cents": 50000})
|
||||
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
|
||||
|
||||
|
||||
PFADE = {
|
||||
"version": "/api/version",
|
||||
"ichSelbst": "/api/auth/me",
|
||||
"einstellungen": "/api/settings",
|
||||
"konten": "/api/accounts",
|
||||
"kategoriegruppen": "/api/categories",
|
||||
"schlagwoerter": "/api/tags",
|
||||
"buchungen": "/api/transactions",
|
||||
"uebertragsverknuepfungen": "/api/transactions/links",
|
||||
"budget": "/api/budget/current",
|
||||
"budgetvorlage": "/api/budget/template",
|
||||
"abos": "/api/subscriptions",
|
||||
"kontostaende": "/api/dashboard/balances",
|
||||
"budgetfortschritt": "/api/dashboard/budget-progress",
|
||||
"einnahmenAusgaben": "/api/dashboard/income-expense",
|
||||
"letzteBuchungen": "/api/dashboard/recent",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/auth/login",
|
||||
data={"username": "admin", "password": "AdminPass1"})
|
||||
if r.status_code != 200:
|
||||
print("Anmeldung fehlgeschlagen:", r.text)
|
||||
return 1
|
||||
_seed(c)
|
||||
|
||||
heraus = {}
|
||||
for name, pfad in PFADE.items():
|
||||
antwort = c.get(pfad)
|
||||
if antwort.status_code != 200:
|
||||
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
|
||||
return 1
|
||||
heraus[name] = antwort.json()
|
||||
|
||||
ZIEL.parent.mkdir(parents=True, exist_ok=True)
|
||||
ZIEL.write_text(json.dumps(heraus, indent=1, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"{ZIEL} geschrieben, {len(heraus)} Antworten.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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: " · ")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ final class AppZustand {
|
||||
private(set) var einstellungen = Einstellungen()
|
||||
private(set) var client: CertainClient?
|
||||
|
||||
/// Kategorien, Schlagwörter und Konten – einmal geladen, von allen
|
||||
/// Listen benutzt. Jede Zeile will zu einer ID den Namen, das Symbol und
|
||||
/// die Farbe; das je Ansicht neu zu holen wäre genau das N+1, das das
|
||||
/// Backend sich selbst verbietet.
|
||||
private(set) var nachschlag = Nachschlag.leer
|
||||
|
||||
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
|
||||
/// sondern eine Zeile unter dem Formular.
|
||||
var meldung: String?
|
||||
@@ -61,8 +67,12 @@ final class AppZustand {
|
||||
guard let client else { return }
|
||||
do {
|
||||
let ich = try await client.ichSelbst()
|
||||
// Muss gemerkt werden, sonst greift der Offline-Zweig unten nie:
|
||||
// Niemand sonst schreibt unter diesem Schlüssel.
|
||||
Zwischenspeicher.lege(ich, unter: "auth/me")
|
||||
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
|
||||
lage = .bereit(ich)
|
||||
await ladeNachschlag()
|
||||
} catch APIFehler.nichtAngemeldet {
|
||||
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
||||
Ablage.marke = nil
|
||||
@@ -77,6 +87,7 @@ final class AppZustand {
|
||||
// Ansichten den letzten Stand. Die Marke ist ja da.
|
||||
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
||||
lage = .bereit(gemerkt.wert)
|
||||
await ladeNachschlag()
|
||||
} else {
|
||||
meldung = error.localizedDescription
|
||||
lage = .brauchtAnmeldung
|
||||
@@ -240,6 +251,20 @@ final class AppZustand {
|
||||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||||
}
|
||||
|
||||
/// Holt das Nachschlagewerk. Geht das schief, bleibt der letzte Stand –
|
||||
/// eine Buchungsliste ohne Kategorienamen wäre schlechter als eine mit
|
||||
/// alten.
|
||||
func ladeNachschlag() async {
|
||||
guard let client else { return }
|
||||
if let frisch = try? await client.nachschlag() {
|
||||
Zwischenspeicher.lege(frisch, unter: "nachschlag")
|
||||
nachschlag = frisch
|
||||
} else if nachschlag.gruppen.isEmpty,
|
||||
let gemerkt = Zwischenspeicher.hole(Nachschlag.self, unter: "nachschlag") {
|
||||
nachschlag = gemerkt.wert
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Kleinkram
|
||||
|
||||
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
|
||||
|
||||
@@ -36,11 +36,24 @@ struct RootAnsicht: View {
|
||||
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
||||
/// erledigt `.sidebarAdaptable` von selbst.
|
||||
///
|
||||
/// Stufe 1 hat erst zwei Einträge. Die weiteren kommen hier dazu, die
|
||||
/// Anordnung bleibt.
|
||||
/// Reihenfolge nach Häufigkeit, nicht nach Umfang: Das Dashboard beantwortet
|
||||
/// die Frage, wegen der man die App öffnet. Konten und Einstellungen stehen
|
||||
/// hinten, weil man sie selten braucht.
|
||||
struct Hauptnavigation: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab(String(localized: "reiter.dashboard"), systemImage: "square.grid.2x2") {
|
||||
NavigationStack { DashboardAnsicht() }
|
||||
}
|
||||
Tab(String(localized: "reiter.budget"), systemImage: "chart.pie") {
|
||||
NavigationStack { BudgetAnsicht() }
|
||||
}
|
||||
Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") {
|
||||
NavigationStack { BuchungsListe() }
|
||||
}
|
||||
Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") {
|
||||
NavigationStack { AboListe() }
|
||||
}
|
||||
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
|
||||
NavigationStack { KontenListe() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,24 +7,34 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
|
||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
|
||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
|
||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
|
||||
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */; };
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51DE5BDB909910D586F819F /* Budget.swift */; };
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */; };
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BE152C9F91584900796ADA /* Grundmodelle.swift */; };
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
|
||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||
@@ -44,11 +54,14 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
|
||||
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
|
||||
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
|
||||
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
|
||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
|
||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
|
||||
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
|
||||
@@ -60,14 +73,21 @@
|
||||
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
|
||||
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = "<group>"; };
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = "<group>"; };
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = "<group>"; };
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = "<group>"; };
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = "<group>"; };
|
||||
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
|
||||
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
|
||||
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
|
||||
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = "<group>"; };
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -92,6 +112,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
|
||||
7DD1C597D594D0F965FF1547 /* Fixtures */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
@@ -114,16 +135,37 @@
|
||||
path = Bausteine;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7DD1C597D594D0F965FF1547 /* Fixtures */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */,
|
||||
);
|
||||
path = Fixtures;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */,
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||
);
|
||||
path = Modelle;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AE11D36E2DFAC34130E2868F /* Dashboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */,
|
||||
);
|
||||
path = Dashboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -138,9 +180,13 @@
|
||||
BC82A6A323BDD2AD8325BEA2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */,
|
||||
C4B3E718A814E0236A12EB86 /* Budget */,
|
||||
AE11D36E2DFAC34130E2868F /* Dashboard */,
|
||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||
@@ -170,6 +216,30 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C4B3E718A814E0236A12EB86 /* Budget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
|
||||
);
|
||||
path = Budget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
|
||||
);
|
||||
path = Abos;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
|
||||
);
|
||||
path = Buchungen;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB51F54CC077E85C286AD9DC /* Netzwerk */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -209,6 +279,7 @@
|
||||
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
|
||||
buildPhases = (
|
||||
104B97AFE88042A9CF83B9C7 /* Sources */,
|
||||
5E084A3C9ED86572E29E01B7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -267,6 +338,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E084A3C9ED86572E29E01B7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -284,15 +363,24 @@
|
||||
files = (
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Alles, was das Dashboard braucht, in einem Abruf.
|
||||
///
|
||||
/// Vier Endpunkte nebeneinander statt nacheinander – sie wissen nichts
|
||||
/// voneinander. Zusammen in einem Wert, damit `Lader` sie als einen Stand
|
||||
/// merkt: Vier getrennte Zwischenspeicher könnten offline aus vier
|
||||
/// verschiedenen Momenten stammen und sich widersprechen.
|
||||
struct Dashboarddaten: Codable, Sendable, Hashable {
|
||||
let konten: Kontostaende
|
||||
let fortschritt: Budgetfortschritt
|
||||
let bilanz: EinnahmenAusgaben
|
||||
let letzte: LetzteBuchungen
|
||||
}
|
||||
|
||||
extension CertainClient {
|
||||
func dashboard() async throws -> Dashboarddaten {
|
||||
async let konten = kontostaende()
|
||||
async let fortschritt = budgetfortschritt()
|
||||
async let bilanz = einnahmenAusgaben()
|
||||
async let letzte = letzteBuchungen()
|
||||
return try await Dashboarddaten(konten: konten, fortschritt: fortschritt,
|
||||
bilanz: bilanz, letzte: letzte)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Startseite. Anders als im Web ist die Auswahl der Karten fest – ein
|
||||
/// verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn.
|
||||
struct DashboardAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<Dashboarddaten>?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { daten in
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
BilanzKarte(bilanz: daten.bilanz)
|
||||
FortschrittKarte(fortschritt: daten.fortschritt)
|
||||
KontenKarte(staende: daten.konten)
|
||||
LetzteKarte(letzte: daten.letzte)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.refreshable { await lader.aktualisiere() }
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.dashboard"))
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<Dashboarddaten>(schluessel: "dashboard") {
|
||||
try await client.dashboard()
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Karten
|
||||
|
||||
/// Der Rahmen, den alle Karten teilen.
|
||||
private struct Karte<Inhalt: View>: View {
|
||||
let titel: String
|
||||
var fussnote: String?
|
||||
@ViewBuilder let inhalt: () -> Inhalt
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(titel)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
inhalt()
|
||||
if let fussnote {
|
||||
Text(fussnote)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// Einnahmen, Ausgaben und was übrig bleibt – die Zahl, wegen der man die
|
||||
/// App öffnet.
|
||||
private struct BilanzKarte: View {
|
||||
let bilanz: EinnahmenAusgaben
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.bilanz")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(bilanz.uebrig.formatiert())
|
||||
.font(.largeTitle.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(bilanz.net < 0 ? .red : .primary)
|
||||
.contentTransition(.numericText())
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Beschriftet(titel: String(localized: "dashboard.einnahmen"),
|
||||
wert: bilanz.einnahmen.formatiert(), farbe: .green)
|
||||
Beschriftet(titel: String(localized: "dashboard.ausgaben"),
|
||||
wert: bilanz.ausgaben.formatiert(), farbe: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Beschriftet: View {
|
||||
let titel: String
|
||||
let wert: String
|
||||
let farbe: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(titel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(wert)
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(farbe)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Je Gruppe ein Balken: wie viel vom Budget schon weg ist.
|
||||
private struct FortschrittKarte: View {
|
||||
let fortschritt: Budgetfortschritt
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.budgetfortschritt")) {
|
||||
if fortschritt.rows.isEmpty {
|
||||
Text(String(localized: "dashboard.keinBudget"))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(fortschritt.rows) { zeile in
|
||||
Balken(zeile: zeile, waehrung: fortschritt.currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Balken: View {
|
||||
let zeile: Budgetfortschritt.Zeile
|
||||
let waehrung: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(zeile.name)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(Geldbetrag(cents: zeile.actual, waehrung: waehrung).formatiert())
|
||||
.font(.caption.monospacedDigit())
|
||||
Text("/")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(Geldbetrag(cents: zeile.planned, waehrung: waehrung).formatiert())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ProgressView(value: zeile.anteil)
|
||||
.tint(farbe)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// Rot nur bei echter Überschreitung. Wer mehr zurücklegt als
|
||||
/// geplant, hat nichts überzogen – deshalb fragt `good` dazwischen.
|
||||
private var farbe: Color {
|
||||
if zeile.good { return .green }
|
||||
return zeile.over ? .red : Color(hex: zeile.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kontostände, und was davon wirklich verfügbar ist.
|
||||
private struct KontenKarte: View {
|
||||
let staende: Kontostaende
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.kontostaende")) {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(staende.accounts.filter { !$0.archived }) { konto in
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 28)
|
||||
Text(konto.name)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
if konto.locked {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(String(localized: "konten.gebunden"))
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(konto.saldo.formatiert())
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text(String(localized: "konten.verfuegbar"))
|
||||
.font(.callout.weight(.medium))
|
||||
Spacer()
|
||||
Text(staende.summe.formatiert())
|
||||
.font(.callout.weight(.medium).monospacedDigit())
|
||||
}
|
||||
if staende.gebunden != 0 {
|
||||
HStack {
|
||||
Text(String(localized: "konten.gebundenSumme"))
|
||||
Spacer()
|
||||
Text(staende.gebundenBetrag.formatiert())
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zuletzt passiert ist. Die Überschriften kommen fertig formuliert vom
|
||||
/// Server – bei einem Übertrag steht dort „Konto A → Konto B".
|
||||
private struct LetzteKarte: View {
|
||||
let letzte: LetzteBuchungen
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.letzteBuchungen")) {
|
||||
if letzte.transactions.isEmpty {
|
||||
Text(String(localized: "buchungen.leerErklaerung"))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(letzte.transactions) { zeile in
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: zeile.icon, farbe: zeile.color, groesse: 30)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(zeile.title)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
Text(zeile.date.formatiert())
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(zeile.betrag.formatiert(vorzeichen: .ohne))
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(farbe(zeile.vorzeichen))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func farbe(_ vorzeichen: LetzteBuchungen.Zeile.Vorzeichen) -> Color {
|
||||
switch vorzeichen {
|
||||
case .plus: return .green
|
||||
case .minus: return .primary
|
||||
case .keines: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
/// `SubscriptionListOut` – die Vertragsliste samt Kopfzahlen.
|
||||
struct Aboliste: Codable, Sendable, Hashable {
|
||||
let subscriptions: [Abo]
|
||||
let summary: Abosummen
|
||||
}
|
||||
|
||||
/// `SubscriptionOut` aus `backend/app/schemas.py`.
|
||||
struct Abo: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
/// Der Betrag je Zahlung, in der Währung des Vertrags.
|
||||
let amountCents: Int
|
||||
let currency: String
|
||||
/// 1 = monatlich, 12 = jährlich, 3 = vierteljährlich …
|
||||
let intervalMonths: Int
|
||||
let accountId: Int?
|
||||
let categoryId: Int?
|
||||
let nextDue: Tagesdatum?
|
||||
/// Woran die Erkennung den Vertrag in einer Buchung wiedererkennt.
|
||||
let payeePattern: String?
|
||||
let savingsAccountId: Int?
|
||||
let isActive: Bool
|
||||
let cancelledAt: Tagesdatum?
|
||||
let notes: String?
|
||||
let position: Int
|
||||
/// Auf einen Monat umgelegt, in der Vertragswährung – vom Server gerechnet.
|
||||
let monatlichCents: Int
|
||||
/// Dasselbe in der Hauptwährung. Fehlt der Kurs, fällt es still auf den
|
||||
/// nativen Wert zurück; welche Währungen betroffen sind, sagt
|
||||
/// `Abosummen.ohneKurs`.
|
||||
let monatlichBaseCents: Int
|
||||
let tageBisFaellig: Int?
|
||||
let hasLogo: Bool
|
||||
let logoVersion: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, currency, notes, position
|
||||
case amountCents = "amount_cents"
|
||||
case intervalMonths = "interval_months"
|
||||
case accountId = "account_id"
|
||||
case categoryId = "category_id"
|
||||
case nextDue = "next_due"
|
||||
case payeePattern = "payee_pattern"
|
||||
case savingsAccountId = "savings_account_id"
|
||||
case isActive = "is_active"
|
||||
case cancelledAt = "cancelled_at"
|
||||
case monatlichCents = "monatlich_cents"
|
||||
case monatlichBaseCents = "monatlich_base_cents"
|
||||
case tageBisFaellig = "tage_bis_faellig"
|
||||
case hasLogo = "has_logo"
|
||||
case logoVersion = "logo_version"
|
||||
}
|
||||
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||
|
||||
/// „monatlich", „jährlich", „alle 3 Monate".
|
||||
var rhythmus: String {
|
||||
switch intervalMonths {
|
||||
case 1: return String(localized: "abo.monatlich")
|
||||
case 3: return String(localized: "abo.vierteljaehrlich")
|
||||
case 6: return String(localized: "abo.halbjaehrlich")
|
||||
case 12: return String(localized: "abo.jaehrlich")
|
||||
default: return String(format: String(localized: "abo.alleNMonate"), intervalMonths)
|
||||
}
|
||||
}
|
||||
|
||||
/// Steht die nächste Zahlung in der nächsten Woche an?
|
||||
var istBaldFaellig: Bool {
|
||||
guard isActive, let tage = tageBisFaellig else { return false }
|
||||
return tage <= 7
|
||||
}
|
||||
}
|
||||
|
||||
/// Was am Kopf der Liste steht – die Zahl, wegen der man sie öffnet.
|
||||
struct Abosummen: Codable, Sendable, Hashable {
|
||||
let currency: String
|
||||
let monatlichCents: Int
|
||||
let jaehrlichCents: Int
|
||||
let anzahlAktiv: Int
|
||||
/// Währungen, die sich mangels Kurs nicht umrechnen liessen. Lieber
|
||||
/// nennen als stillschweigend zu klein summieren.
|
||||
let ohneKurs: [String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case currency
|
||||
case monatlichCents = "monatlich_cents"
|
||||
case jaehrlichCents = "jaehrlich_cents"
|
||||
case anzahlAktiv = "anzahl_aktiv"
|
||||
case ohneKurs = "ohne_kurs"
|
||||
}
|
||||
|
||||
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||
var jaehrlich: Geldbetrag { Geldbetrag(cents: jaehrlichCents, waehrung: currency) }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
|
||||
/// `TransactionOut` aus `backend/app/schemas.py`.
|
||||
///
|
||||
/// Drei Beträge an jeder Buchung, und keiner davon wird hier gerechnet:
|
||||
/// `amountCents` steht in der Kontowährung, `amountEurCents` ist der beim
|
||||
/// Speichern eingefrorene EUR-Wert, `amountBaseCents` rechnet der Server bei
|
||||
/// jedem Lesen in die Hauptwährung um. Die App formatiert nur.
|
||||
struct Buchung: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let type: Buchungsart
|
||||
let date: Tagesdatum
|
||||
/// `"14:18:15"` oder nichts. Nur zur Anzeige – gerechnet wird mit dem Tag.
|
||||
let time: String?
|
||||
let accountId: Int
|
||||
let toAccountId: Int?
|
||||
let accountCurrency: String
|
||||
let amountCents: Int
|
||||
let toAmountCents: Int?
|
||||
let fxRate: Double?
|
||||
let amountEurCents: Int
|
||||
let amountBaseCents: Int
|
||||
let categoryId: Int?
|
||||
let budgetTreatment: Budgetwirkung?
|
||||
let budgetCategoryId: Int?
|
||||
/// Der Vertrag, zu dem die Buchung gehört – von der Erkennung gesetzt.
|
||||
let subscriptionId: Int?
|
||||
let budgetPeriod: String
|
||||
let budgetPeriodManuell: Bool
|
||||
let importBatchId: Int?
|
||||
/// bank, mail, shortcut, transfer, csv, manuell.
|
||||
let herkunft: String
|
||||
let payee: String?
|
||||
let description: String?
|
||||
let notes: String?
|
||||
let tagIds: [Int]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, type, date, time, herkunft, payee, description, notes
|
||||
case accountId = "account_id"
|
||||
case toAccountId = "to_account_id"
|
||||
case accountCurrency = "account_currency"
|
||||
case amountCents = "amount_cents"
|
||||
case toAmountCents = "to_amount_cents"
|
||||
case fxRate = "fx_rate"
|
||||
case amountEurCents = "amount_eur_cents"
|
||||
case amountBaseCents = "amount_base_cents"
|
||||
case categoryId = "category_id"
|
||||
case budgetTreatment = "budget_treatment"
|
||||
case budgetCategoryId = "budget_category_id"
|
||||
case subscriptionId = "subscription_id"
|
||||
case budgetPeriod = "budget_period"
|
||||
case budgetPeriodManuell = "budget_period_manuell"
|
||||
case importBatchId = "import_batch_id"
|
||||
case tagIds = "tag_ids"
|
||||
}
|
||||
|
||||
/// In der Kontowährung.
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: accountCurrency) }
|
||||
|
||||
/// Was in der Liste als Überschrift steht. Ohne Empfänger nimmt der
|
||||
/// Verwendungszweck seinen Platz ein – eine leere Zeile hilft niemandem.
|
||||
var ueberschrift: String {
|
||||
for text in [payee, description] where !(text ?? "").isEmpty {
|
||||
return text!
|
||||
}
|
||||
return String(localized: "buchung.ohneBezeichnung")
|
||||
}
|
||||
|
||||
/// Die Zeile darunter: Verwendungszweck, falls er nicht schon oben steht.
|
||||
var unterschrift: String? {
|
||||
guard let beschreibung = description, !beschreibung.isEmpty,
|
||||
beschreibung != ueberschrift else { return nil }
|
||||
return beschreibung
|
||||
}
|
||||
|
||||
/// Ob diese Buchung Geld aus dem Haushalt herausbewegt. Überträge nicht –
|
||||
/// sie verschieben nur.
|
||||
var istAusgabe: Bool { type == .expense }
|
||||
}
|
||||
|
||||
/// `TxnType`
|
||||
enum Buchungsart: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||
case expense, income, transfer
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .expense: return String(localized: "buchungsart.ausgabe")
|
||||
case .income: return String(localized: "buchungsart.einnahme")
|
||||
case .transfer: return String(localized: "buchungsart.uebertrag")
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .expense: return "arrow.up.right"
|
||||
case .income: return "arrow.down.left"
|
||||
case .transfer: return "arrow.left.arrow.right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BudgetTreatment` – ob eine Buchung im Budget zählt oder bloss durchläuft.
|
||||
enum Budgetwirkung: String, Codable, Sendable {
|
||||
case budgeted, neutral
|
||||
}
|
||||
|
||||
/// Ein Eintrag aus `GET /api/transactions/links`: die Gegenseite einer
|
||||
/// Buchung, die mit einer zweiten zu einem Übertrag verknüpft ist.
|
||||
///
|
||||
/// Das Gegenkonto steht mit drin, weil die andere Buchung oft gar nicht in
|
||||
/// der gerade gefilterten Liste steht – und „Übertrag" ohne Angabe wohin
|
||||
/// sagt nichts.
|
||||
struct Uebertragsverknuepfung: Codable, Sendable, Hashable {
|
||||
let linkId: Int
|
||||
let otherId: Int
|
||||
/// `"raus"`: Von dieser Buchung geht das Geld weg. `"rein"`: hier kommt es an.
|
||||
let richtung: String
|
||||
let otherAccountId: Int?
|
||||
let automatisch: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case richtung, automatisch
|
||||
case linkId = "link_id"
|
||||
case otherId = "other_id"
|
||||
case otherAccountId = "other_account_id"
|
||||
}
|
||||
|
||||
var gehtRaus: Bool { richtung == "raus" }
|
||||
}
|
||||
|
||||
/// Eine Zeile der Buchungsliste: entweder eine einzelne Buchung oder zwei,
|
||||
/// die zusammen einen Übertrag ergeben.
|
||||
///
|
||||
/// Das Backend kennt zwei Mechanismen nebeneinander: einen Übertrag am Stück
|
||||
/// (eine Buchung mit `toAccountId`) und die Verknüpfung zweier getrennter
|
||||
/// Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen,
|
||||
/// hiesse dasselbe Geld zweimal zeigen. Das Zusammenfassen macht der Client –
|
||||
/// das Backend liefert nur die Paarliste.
|
||||
struct Buchungszeile: Identifiable, Hashable, Sendable {
|
||||
let buchung: Buchung
|
||||
/// Das zweite Bein, wenn es in derselben Liste steht.
|
||||
let gegenstueck: Buchung?
|
||||
/// Die Verknüpfung selbst – auch dann da, wenn das zweite Bein fehlt.
|
||||
let verknuepfung: Uebertragsverknuepfung?
|
||||
|
||||
var id: Int { buchung.id }
|
||||
|
||||
var istUebertrag: Bool { buchung.type == .transfer || verknuepfung != nil }
|
||||
|
||||
/// Von welchem Konto das Geld weggeht.
|
||||
var vonKonto: Int {
|
||||
guard let verknuepfung else { return buchung.accountId }
|
||||
if verknuepfung.gehtRaus { return buchung.accountId }
|
||||
return gegenstueck?.accountId ?? verknuepfung.otherAccountId ?? buchung.accountId
|
||||
}
|
||||
|
||||
/// Auf welchem es ankommt – `nil`, wenn es sich nicht sagen lässt.
|
||||
var nachKonto: Int? {
|
||||
guard let verknuepfung else { return buchung.toAccountId }
|
||||
if verknuepfung.gehtRaus { return gegenstueck?.accountId ?? verknuepfung.otherAccountId }
|
||||
return buchung.accountId
|
||||
}
|
||||
|
||||
/// Baut aus einer Buchungsliste und der Paarliste die Anzeigezeilen.
|
||||
///
|
||||
/// Zusammengefasst wird nur, wenn **beide** Beine in dieser Liste stehen.
|
||||
/// Fehlt eines, bleibt die Zeile für sich – sie trägt dann aber die
|
||||
/// Verknüpfung, damit sie „Übertrag nach …" sagen kann statt gar nichts.
|
||||
/// Ohne diese Vorsicht liesse ein Kontofilter Zeilen verschwinden, die
|
||||
/// sehr wohl zu diesem Konto gehören.
|
||||
static func zeilen(aus buchungen: [Buchung],
|
||||
paare: [String: Uebertragsverknuepfung]) -> [Buchungszeile] {
|
||||
let nachId = Dictionary(buchungen.map { ($0.id, $0) }, uniquingKeysWith: { erstes, _ in erstes })
|
||||
var erledigt = Set<Int>()
|
||||
var ergebnis: [Buchungszeile] = []
|
||||
|
||||
for buchung in buchungen where !erledigt.contains(buchung.id) {
|
||||
let verknuepfung = paare[String(buchung.id)]
|
||||
let anderes = verknuepfung.flatMap { nachId[$0.otherId] }
|
||||
if let anderes {
|
||||
erledigt.insert(anderes.id)
|
||||
}
|
||||
erledigt.insert(buchung.id)
|
||||
ergebnis.append(Buchungszeile(buchung: buchung, gegenstueck: anderes,
|
||||
verknuepfung: verknuepfung))
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// Die Monatsansicht aus `GET /api/budget/{label}` beziehungsweise
|
||||
/// `/budget/current` (`_build_view` in `backend/app/routers/budgets.py`).
|
||||
///
|
||||
/// Alle Beträge stehen schon in der Hauptwährung. Der Client rechnet nur,
|
||||
/// was es in der API nicht gibt: Prozente und „Rest".
|
||||
struct Budgetmonat: Codable, Sendable, Hashable {
|
||||
/// `"2026-10"`.
|
||||
let label: String
|
||||
let start: Tagesdatum
|
||||
let end: Tagesdatum
|
||||
/// Ob dieser Monat von der Vorlage abweicht.
|
||||
let customized: Bool
|
||||
let baseCurrency: String
|
||||
let prev: String
|
||||
let next: String
|
||||
let groups: [Budgetgruppe]
|
||||
let income: [GeplanteEinnahme]
|
||||
let vertraege: Vertragsreste
|
||||
let totals: Budgetsummen
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case label, start, end, customized, prev, next, groups, income, vertraege, totals
|
||||
case baseCurrency = "base_currency"
|
||||
}
|
||||
|
||||
/// Was vom geplanten Ausgabenbudget noch übrig ist. Kann negativ werden.
|
||||
var restCents: Int { totals.plannedExpense - totals.actualExpense }
|
||||
|
||||
/// Anteil des Verbrauchten, zwischen 0 und 1 gekappt – für einen Balken.
|
||||
/// Ohne Plan gibt es keinen Anteil, nicht etwa einen vollen Balken.
|
||||
var anteilVerbraucht: Double {
|
||||
guard totals.plannedExpense > 0 else { return 0 }
|
||||
return min(1, max(0, Double(totals.actualExpense) / Double(totals.plannedExpense)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Kategoriegruppe im Budget.
|
||||
struct Budgetgruppe: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let plannedCents: Int
|
||||
let actualCents: Int
|
||||
/// Überzogen. Sparen ist davon ausgenommen – siehe `good`.
|
||||
let over: Bool
|
||||
/// Alle Kategorien darin sind „Ausgaben, die kein Verlust sind" (Sparen).
|
||||
/// Dann ist Mehr-als-geplant keine Warnung.
|
||||
let good: Bool
|
||||
let ausVertraegenCents: Int
|
||||
let categories: [Budgetkategorie]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, over, good, categories
|
||||
case plannedCents = "planned_cents"
|
||||
case actualCents = "actual_cents"
|
||||
case ausVertraegenCents = "aus_vertraegen_cents"
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile im Budget.
|
||||
struct Budgetkategorie: Codable, Sendable, Identifiable, Hashable {
|
||||
let categoryId: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let plannedCents: Int
|
||||
let actualCents: Int
|
||||
let deltaCents: Int
|
||||
let over: Bool
|
||||
/// `"fest"` oder `"berechnet"`. Bei „berechnet" ergibt sich der Soll-Wert
|
||||
/// aus den Verträgen plus Zuschlag.
|
||||
let modus: String
|
||||
let zuschlagCents: Int
|
||||
/// Was die Verträge dieser Kategorie im Monat kosten. Steht auch bei
|
||||
/// „fest" da – dann als Hinweis, nicht als Summand.
|
||||
let ausVertraegenCents: Int
|
||||
let vertraegeAnzahl: Int
|
||||
let good: Bool
|
||||
|
||||
var id: Int { categoryId }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color, over, modus, good
|
||||
case categoryId = "category_id"
|
||||
case plannedCents = "planned_cents"
|
||||
case actualCents = "actual_cents"
|
||||
case deltaCents = "delta_cents"
|
||||
case zuschlagCents = "zuschlag_cents"
|
||||
case ausVertraegenCents = "aus_vertraegen_cents"
|
||||
case vertraegeAnzahl = "vertraege_anzahl"
|
||||
}
|
||||
|
||||
var istBerechnet: Bool { modus == "berechnet" }
|
||||
|
||||
var anteilVerbraucht: Double {
|
||||
guard plannedCents > 0 else { return 0 }
|
||||
return min(1, max(0, Double(actualCents) / Double(plannedCents)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine geplante Einnahme – „Gehalt", „Kindergeld".
|
||||
struct GeplanteEinnahme: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let label: String
|
||||
let categoryId: Int?
|
||||
let targetAccountId: Int?
|
||||
let plannedCents: Int
|
||||
let receivedCents: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, label
|
||||
case categoryId = "category_id"
|
||||
case targetAccountId = "target_account_id"
|
||||
case plannedCents = "planned_cents"
|
||||
case receivedCents = "received_cents"
|
||||
}
|
||||
}
|
||||
|
||||
/// Verträge, die sich nicht zuordnen liessen.
|
||||
///
|
||||
/// Ohne diese Angaben fehlt ihr Geld im Budget, ohne dass irgendwo steht,
|
||||
/// dass es fehlt. `ohneKurs` nennt die Währungen, für die kein Wechselkurs
|
||||
/// vorlag – deren Beträge fehlen in der Summe.
|
||||
struct Vertragsreste: Codable, Sendable, Hashable {
|
||||
let ohneKategorieCents: Int
|
||||
let ohneKategorieAnzahl: Int
|
||||
let ohneKurs: [String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ohneKategorieCents = "ohne_kategorie_cents"
|
||||
case ohneKategorieAnzahl = "ohne_kategorie_anzahl"
|
||||
case ohneKurs = "ohne_kurs"
|
||||
}
|
||||
|
||||
var istLeer: Bool { ohneKategorieAnzahl == 0 && ohneKurs.isEmpty }
|
||||
}
|
||||
|
||||
struct Budgetsummen: Codable, Sendable, Hashable {
|
||||
let plannedExpense: Int
|
||||
let actualExpense: Int
|
||||
let plannedIncome: Int
|
||||
let receivedIncome: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case plannedExpense = "planned_expense"
|
||||
case actualExpense = "actual_expense"
|
||||
case plannedIncome = "planned_income"
|
||||
case receivedIncome = "received_income"
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/budget/template` – das Standard-Budget hinter den Monaten.
|
||||
struct Budgetvorlage: Codable, Sendable, Hashable {
|
||||
let lines: [Vorlagenzeile]
|
||||
let income: [GeplanteEinnahme]
|
||||
}
|
||||
|
||||
struct Vorlagenzeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let categoryId: Int
|
||||
let plannedCents: Int
|
||||
let modus: String
|
||||
let zuschlagCents: Int
|
||||
|
||||
var id: Int { categoryId }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case modus
|
||||
case categoryId = "category_id"
|
||||
case plannedCents = "planned_cents"
|
||||
case zuschlagCents = "zuschlag_cents"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
// Die Karten des Dashboards. Anders als im Web ist die Auswahl hier fest –
|
||||
// ein verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn. Die
|
||||
// vier Endpunkte sind dieselben, die das Web benutzt.
|
||||
|
||||
/// `GET /api/dashboard/balances`
|
||||
struct Kontostaende: Codable, Sendable, Hashable {
|
||||
let accounts: [Kontozeile]
|
||||
/// Verfügbar, ohne gebundene und archivierte Konten.
|
||||
let total: Int
|
||||
/// Was auf gebundenen Konten liegt – Geld, an das man nicht herankommt.
|
||||
let gebunden: Int
|
||||
let currency: String
|
||||
|
||||
var summe: Geldbetrag { Geldbetrag(cents: total, waehrung: currency) }
|
||||
var gebundenBetrag: Geldbetrag { Geldbetrag(cents: gebunden, waehrung: currency) }
|
||||
|
||||
struct Kontozeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let currency: String
|
||||
let balanceCents: Int
|
||||
let balanceBaseCents: Int
|
||||
let archived: Bool
|
||||
let locked: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, currency, archived, locked
|
||||
case balanceCents = "balance_cents"
|
||||
case balanceBaseCents = "balance_base_cents"
|
||||
}
|
||||
|
||||
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/budget-progress`
|
||||
struct Budgetfortschritt: Codable, Sendable, Hashable {
|
||||
let rows: [Zeile]
|
||||
let currency: String
|
||||
let label: String
|
||||
let groupBy: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rows, currency, label
|
||||
case groupBy = "group_by"
|
||||
}
|
||||
|
||||
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let color: String
|
||||
let planned: Int
|
||||
let actual: Int
|
||||
let over: Bool
|
||||
/// Sparen: mehr als geplant ist hier keine Warnung.
|
||||
let good: Bool
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var anteil: Double {
|
||||
guard planned > 0 else { return 0 }
|
||||
return min(1, max(0, Double(actual) / Double(planned)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/income-expense`
|
||||
struct EinnahmenAusgaben: Codable, Sendable, Hashable {
|
||||
let income: Int
|
||||
let expense: Int
|
||||
let net: Int
|
||||
let currency: String
|
||||
let label: String
|
||||
|
||||
var einnahmen: Geldbetrag { Geldbetrag(cents: income, waehrung: currency) }
|
||||
var ausgaben: Geldbetrag { Geldbetrag(cents: expense, waehrung: currency) }
|
||||
var uebrig: Geldbetrag { Geldbetrag(cents: net, waehrung: currency) }
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/recent`
|
||||
struct LetzteBuchungen: Codable, Sendable, Hashable {
|
||||
let transactions: [Zeile]
|
||||
let currency: String
|
||||
|
||||
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let date: Tagesdatum
|
||||
let type: Buchungsart
|
||||
/// Schon fertig formuliert: die Kategorie, sonst der Empfänger, bei
|
||||
/// einem Übertrag „Konto A → Konto B".
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: String
|
||||
/// `"pos"`, `"neg"` oder nichts – bei einem Übertrag gibt es keine
|
||||
/// Richtung, weil kein Geld den Haushalt verlässt.
|
||||
let sign: String?
|
||||
let amountCents: Int
|
||||
let currency: String
|
||||
let amountBaseCents: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, date, type, title, icon, color, sign, currency
|
||||
case amountCents = "amount_cents"
|
||||
case amountBaseCents = "amount_base_cents"
|
||||
}
|
||||
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||
|
||||
/// Grün bei Einnahme, rot bei Ausgabe, neutral beim Übertrag.
|
||||
var vorzeichen: Vorzeichen {
|
||||
switch sign {
|
||||
case "pos": return .plus
|
||||
case "neg": return .minus
|
||||
default: return .keines
|
||||
}
|
||||
}
|
||||
|
||||
enum Vorzeichen: Sendable { case plus, minus, keines }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
|
||||
/// `CategoryGroupOut` – Kategorien stehen immer in einer Gruppe.
|
||||
struct Kategoriegruppe: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let kind: Kategorieart
|
||||
let icon: String
|
||||
let color: String
|
||||
let position: Int
|
||||
let isBuiltin: Bool
|
||||
let categories: [Kategorie]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, kind, icon, color, position, categories
|
||||
case isBuiltin = "is_builtin"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryOut`
|
||||
struct Kategorie: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let groupId: Int
|
||||
let name: String
|
||||
let type: Kategorieart
|
||||
let icon: String
|
||||
let color: String
|
||||
let position: Int
|
||||
let isBuiltin: Bool
|
||||
let isArchived: Bool
|
||||
/// Zählt in keiner Auswertung mit – etwa „Durchlaufende Posten".
|
||||
let excludedFromStats: Bool
|
||||
/// Eine Ausgabe, die kein Verlust ist: Sparen. Mehr als geplant ist hier
|
||||
/// keine Warnung.
|
||||
let spendingIsGood: Bool
|
||||
let keywordCount: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, type, icon, color, position
|
||||
case groupId = "group_id"
|
||||
case isBuiltin = "is_builtin"
|
||||
case isArchived = "is_archived"
|
||||
case excludedFromStats = "excluded_from_stats"
|
||||
case spendingIsGood = "spending_is_good"
|
||||
case keywordCount = "keyword_count"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryKind`
|
||||
enum Kategorieart: String, Codable, Sendable {
|
||||
case income, expense
|
||||
}
|
||||
|
||||
/// `TagOut` – ein frei vergebenes Etikett, quer zur Kategorie.
|
||||
struct Schlagwort: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let color: String
|
||||
}
|
||||
|
||||
/// Kategorien und Tags, wie die Ansichten sie brauchen: einmal geladen,
|
||||
/// danach als Nachschlagewerk.
|
||||
///
|
||||
/// Fast jede Liste will zu einer ID den Namen, das Symbol und die Farbe.
|
||||
/// Das je Zeile zu holen wäre genau das N+1, das das Backend sich selbst
|
||||
/// verbietet.
|
||||
struct Nachschlag: Codable, Sendable {
|
||||
let gruppen: [Kategoriegruppe]
|
||||
let schlagwoerter: [Schlagwort]
|
||||
let konten: [Konto]
|
||||
|
||||
private let kategorienNachId: [Int: Kategorie]
|
||||
private let schlagwortNachId: [Int: Schlagwort]
|
||||
private let kontoNachId: [Int: Konto]
|
||||
|
||||
init(gruppen: [Kategoriegruppe], schlagwoerter: [Schlagwort], konten: [Konto]) {
|
||||
self.gruppen = gruppen
|
||||
self.schlagwoerter = schlagwoerter
|
||||
self.konten = konten
|
||||
let alle = gruppen.flatMap(\.categories)
|
||||
self.kategorienNachId = Dictionary(alle.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
self.schlagwortNachId = Dictionary(schlagwoerter.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
self.kontoNachId = Dictionary(konten.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
}
|
||||
|
||||
static let leer = Nachschlag(gruppen: [], schlagwoerter: [], konten: [])
|
||||
|
||||
/// Gespeichert werden nur die drei Listen; die Nachschlagetabellen baut
|
||||
/// der Konstruktor daraus neu. Sie mitzuschreiben hiesse, dieselben Daten
|
||||
/// zweimal auf der Platte zu halten.
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case gruppen, schlagwoerter, konten
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let behaelter = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(gruppen: try behaelter.decode([Kategoriegruppe].self, forKey: .gruppen),
|
||||
schlagwoerter: try behaelter.decode([Schlagwort].self, forKey: .schlagwoerter),
|
||||
konten: try behaelter.decode([Konto].self, forKey: .konten))
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var behaelter = encoder.container(keyedBy: CodingKeys.self)
|
||||
try behaelter.encode(gruppen, forKey: .gruppen)
|
||||
try behaelter.encode(schlagwoerter, forKey: .schlagwoerter)
|
||||
try behaelter.encode(konten, forKey: .konten)
|
||||
}
|
||||
|
||||
/// Alle Ausgabekategorien, flach – für Auswahllisten.
|
||||
var ausgabekategorien: [Kategorie] {
|
||||
gruppen.filter { $0.kind == .expense }.flatMap(\.categories).filter { !$0.isArchived }
|
||||
}
|
||||
|
||||
func kategorie(_ id: Int?) -> Kategorie? { id.flatMap { kategorienNachId[$0] } }
|
||||
func schlagwort(_ id: Int) -> Schlagwort? { schlagwortNachId[id] }
|
||||
func konto(_ id: Int?) -> Konto? { id.flatMap { kontoNachId[$0] } }
|
||||
|
||||
func kontoname(_ id: Int?) -> String {
|
||||
konto(id)?.name ?? String(localized: "allgemein.unbekannt")
|
||||
}
|
||||
}
|
||||
@@ -95,4 +95,99 @@ extension CertainClient {
|
||||
func konto(_ id: Int) async throws -> Konto {
|
||||
try await hole("accounts/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Kategorien und Schlagwörter
|
||||
|
||||
func kategoriegruppen() async throws -> [Kategoriegruppe] {
|
||||
try await hole("categories")
|
||||
}
|
||||
|
||||
func schlagwoerter() async throws -> [Schlagwort] {
|
||||
try await hole("tags")
|
||||
}
|
||||
|
||||
/// Das Nachschlagewerk, das fast jede Liste braucht: Kategorien, Tags und
|
||||
/// Konten in einem Rutsch. Drei Abrufe nebeneinander statt nacheinander –
|
||||
/// sie wissen nichts voneinander.
|
||||
func nachschlag() async throws -> Nachschlag {
|
||||
async let gruppen = kategoriegruppen()
|
||||
async let tags = schlagwoerter()
|
||||
async let alleKonten = konten()
|
||||
return try await Nachschlag(gruppen: gruppen, schlagwoerter: tags, konten: alleKonten)
|
||||
}
|
||||
|
||||
// MARK: - Buchungen
|
||||
|
||||
/// - Parameter suche: Volltext über Empfänger, Verwendungszweck und Notiz.
|
||||
/// Seit der Backend-Ergänzung siebt der Server, nicht das Telefon.
|
||||
func buchungen(konto: Int? = nil, kategorie: Int? = nil, art: Buchungsart? = nil,
|
||||
von: Tagesdatum? = nil, bis: Tagesdatum? = nil,
|
||||
suche: String? = nil, schlagwort: Int? = nil, abo: Int? = nil,
|
||||
ohneKategorie: Bool = false, grenze: Int = 200) async throws -> [Buchung] {
|
||||
var abfrage: [URLQueryItem] = [URLQueryItem(name: "limit", value: String(grenze))]
|
||||
func dazu(_ name: String, _ wert: String?) {
|
||||
guard let wert, !wert.isEmpty else { return }
|
||||
abfrage.append(URLQueryItem(name: name, value: wert))
|
||||
}
|
||||
dazu("account_id", konto.map(String.init))
|
||||
dazu("category_id", kategorie.map(String.init))
|
||||
dazu("type", art?.rawValue)
|
||||
dazu("date_from", von?.text)
|
||||
dazu("date_to", bis?.text)
|
||||
dazu("q", suche)
|
||||
dazu("tag_id", schlagwort.map(String.init))
|
||||
dazu("subscription_id", abo.map(String.init))
|
||||
if ohneKategorie { dazu("uncategorized", "true") }
|
||||
return try await hole("transactions", abfrage: abfrage)
|
||||
}
|
||||
|
||||
func buchung(_ id: Int) async throws -> Buchung {
|
||||
try await hole("transactions/\(id)")
|
||||
}
|
||||
|
||||
/// Je Buchungs-ID die Gegenseite, wenn zwei Buchungen zu einem Übertrag
|
||||
/// verknüpft sind. Der Schlüssel ist die ID als Zeichenkette – JSON kennt
|
||||
/// keine Zahlen als Schlüssel.
|
||||
func uebertragsverknuepfungen() async throws -> [String: Uebertragsverknuepfung] {
|
||||
try await hole("transactions/links")
|
||||
}
|
||||
|
||||
// MARK: - Budget
|
||||
|
||||
func budget(_ label: String? = nil) async throws -> Budgetmonat {
|
||||
try await hole(label.map { "budget/\($0)" } ?? "budget/current")
|
||||
}
|
||||
|
||||
func budgetvorlage() async throws -> Budgetvorlage {
|
||||
try await hole("budget/template")
|
||||
}
|
||||
|
||||
// MARK: - Verträge
|
||||
|
||||
func abos() async throws -> Aboliste {
|
||||
try await hole("subscriptions")
|
||||
}
|
||||
|
||||
func abo(_ id: Int) async throws -> Abo {
|
||||
try await hole("subscriptions/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
func kontostaende() async throws -> Kontostaende {
|
||||
try await hole("dashboard/balances")
|
||||
}
|
||||
|
||||
func budgetfortschritt() async throws -> Budgetfortschritt {
|
||||
try await hole("dashboard/budget-progress")
|
||||
}
|
||||
|
||||
func einnahmenAusgaben() async throws -> EinnahmenAusgaben {
|
||||
try await hole("dashboard/income-expense")
|
||||
}
|
||||
|
||||
func letzteBuchungen(grenze: Int = 8) async throws -> LetzteBuchungen {
|
||||
try await hole("dashboard/recent",
|
||||
abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -302,3 +302,279 @@ struct ModellTests {
|
||||
#expect(fremd.istCertain == false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Überträge zusammenfassen
|
||||
|
||||
@Suite("Übertragszeilen")
|
||||
struct UebertragszeilenTests {
|
||||
|
||||
/// Eine Buchung, wie der Server sie schickt – nur die Felder, auf die es
|
||||
/// hier ankommt.
|
||||
private func buchung(_ id: Int, konto: Int, cents: Int,
|
||||
art: Buchungsart = .expense) throws -> Buchung {
|
||||
let roh = """
|
||||
{"id": \(id), "type": "\(art.rawValue)", "date": "2026-10-15", "time": null,
|
||||
"account_id": \(konto), "to_account_id": null, "account_currency": "EUR",
|
||||
"amount_cents": \(cents), "to_amount_cents": null, "fx_rate": null,
|
||||
"amount_eur_cents": \(cents), "amount_base_cents": \(cents),
|
||||
"category_id": null, "budget_treatment": null, "budget_category_id": null,
|
||||
"subscription_id": null, "budget_period": "2026-10",
|
||||
"budget_period_manuell": false, "import_batch_id": null,
|
||||
"herkunft": "bank", "payee": "Test", "description": null, "notes": null,
|
||||
"tag_ids": []}
|
||||
"""
|
||||
return try JSONDecoder().decode(Buchung.self, from: Data(roh.utf8))
|
||||
}
|
||||
|
||||
private func verknuepfung(_ anderes: Int, raus: Bool, gegenkonto: Int?) -> Uebertragsverknuepfung {
|
||||
Uebertragsverknuepfung(linkId: 1, otherId: anderes,
|
||||
richtung: raus ? "raus" : "rein",
|
||||
otherAccountId: gegenkonto, automatisch: true)
|
||||
}
|
||||
|
||||
@Test("Zwei verknüpfte Buchungen ergeben eine Zeile, nicht zwei")
|
||||
func paarWirdZusammengefasst() throws {
|
||||
let raus = try buchung(1, konto: 10, cents: -5000)
|
||||
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [raus, rein], paare: [
|
||||
"1": verknuepfung(2, raus: true, gegenkonto: 20),
|
||||
"2": verknuepfung(1, raus: false, gegenkonto: 10),
|
||||
])
|
||||
|
||||
#expect(zeilen.count == 1)
|
||||
#expect(zeilen[0].istUebertrag)
|
||||
#expect(zeilen[0].vonKonto == 10)
|
||||
#expect(zeilen[0].nachKonto == 20)
|
||||
}
|
||||
|
||||
@Test("Fehlt das zweite Bein, verschwindet die Zeile nicht")
|
||||
func einzelnesBeinBleibtStehen() throws {
|
||||
// Genau der Fall eines Kontofilters: Die Gegenbuchung liegt auf einem
|
||||
// Konto, das gerade nicht gezeigt wird.
|
||||
let raus = try buchung(1, konto: 10, cents: -5000)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [raus], paare: [
|
||||
"1": verknuepfung(2, raus: true, gegenkonto: 20),
|
||||
])
|
||||
|
||||
#expect(zeilen.count == 1)
|
||||
#expect(zeilen[0].istUebertrag)
|
||||
// Das Gegenkonto steht in der Verknüpfung, auch ohne die Buchung.
|
||||
#expect(zeilen[0].nachKonto == 20)
|
||||
}
|
||||
|
||||
@Test("Die Richtung kommt aus der Verknüpfung, nicht aus dem Vorzeichen")
|
||||
func richtungZaehlt() throws {
|
||||
// Manche Banken liefern beide Beine positiv. Wer am Vorzeichen
|
||||
// ablesen wollte, wohin das Geld geht, läge hier falsch herum.
|
||||
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [rein], paare: [
|
||||
"2": verknuepfung(1, raus: false, gegenkonto: 10),
|
||||
])
|
||||
|
||||
#expect(zeilen[0].vonKonto == 10)
|
||||
#expect(zeilen[0].nachKonto == 20)
|
||||
}
|
||||
|
||||
@Test("Ohne Verknüpfung bleibt jede Buchung ihre eigene Zeile")
|
||||
func ohneVerknuepfung() throws {
|
||||
let eins = try buchung(1, konto: 10, cents: -1000)
|
||||
let zwei = try buchung(2, konto: 10, cents: -2000)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [eins, zwei], paare: [:])
|
||||
|
||||
#expect(zeilen.count == 2)
|
||||
#expect(zeilen.allSatisfy { !$0.istUebertrag })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was der Client selbst rechnet
|
||||
|
||||
@Suite("Budget-Anteile")
|
||||
struct BudgetAnteilTests {
|
||||
|
||||
private func kategorie(geplant: Int, ist: Int) throws -> Budgetkategorie {
|
||||
let roh = """
|
||||
{"category_id": 1, "name": "Test", "icon": "home", "color": "#3f51b5",
|
||||
"planned_cents": \(geplant), "actual_cents": \(ist),
|
||||
"delta_cents": \(geplant - ist), "over": \(ist > geplant),
|
||||
"modus": "fest", "zuschlag_cents": 0, "aus_vertraegen_cents": 0,
|
||||
"vertraege_anzahl": 0, "good": false}
|
||||
"""
|
||||
return try JSONDecoder().decode(Budgetkategorie.self, from: Data(roh.utf8))
|
||||
}
|
||||
|
||||
@Test("Ohne Plan gibt es keinen Anteil, keinen vollen Balken")
|
||||
func ohnePlanKeinBalken() throws {
|
||||
#expect(try kategorie(geplant: 0, ist: 0).anteilVerbraucht == 0)
|
||||
// Ausgegeben ohne Plan: Ein voller Balken sähe aus wie "genau
|
||||
// aufgebraucht" und wäre die falsche Auskunft.
|
||||
#expect(try kategorie(geplant: 0, ist: 5000).anteilVerbraucht == 0)
|
||||
}
|
||||
|
||||
@Test("Überzogen wird bei eins gekappt")
|
||||
func gekappt() throws {
|
||||
#expect(try kategorie(geplant: 10000, ist: 25000).anteilVerbraucht == 1)
|
||||
#expect(try kategorie(geplant: 10000, ist: 5000).anteilVerbraucht == 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nachschlagewerk
|
||||
|
||||
@Suite("Nachschlag")
|
||||
struct NachschlagTests {
|
||||
|
||||
private func gruppe() throws -> Kategoriegruppe {
|
||||
let roh = """
|
||||
{"id": 1, "name": "Fixkosten", "kind": "expense", "icon": "landmark",
|
||||
"color": "#3f51b5", "position": 0, "is_builtin": true,
|
||||
"categories": [{"id": 7, "group_id": 1, "name": "Wohnen", "type": "expense",
|
||||
"icon": "home", "color": "#3f51b5", "position": 0, "is_builtin": true,
|
||||
"is_archived": false, "excluded_from_stats": false,
|
||||
"spending_is_good": false, "keyword_count": 0}]}
|
||||
"""
|
||||
return try JSONDecoder().decode(Kategoriegruppe.self, from: Data(roh.utf8))
|
||||
}
|
||||
|
||||
@Test("Eine ID findet ihren Namen")
|
||||
func kategorieNachId() throws {
|
||||
let nachschlag = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
|
||||
#expect(nachschlag.kategorie(7)?.name == "Wohnen")
|
||||
#expect(nachschlag.kategorie(999) == nil)
|
||||
#expect(nachschlag.kategorie(nil) == nil)
|
||||
}
|
||||
|
||||
@Test("Ein unbekanntes Konto ergibt Text, nicht nichts")
|
||||
func unbekanntesKonto() throws {
|
||||
// Eine leere Zeile in der Liste wäre schlimmer als das Wort
|
||||
// "Unbekannt" – sie sähe aus wie ein Fehler der App.
|
||||
#expect(!Nachschlag.leer.kontoname(42).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Der Zwischenspeicher gibt dasselbe Nachschlagewerk zurück")
|
||||
func ueberlebtDenSpeicher() throws {
|
||||
let original = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
|
||||
let roh = try JSONEncoder().encode(original)
|
||||
let zurueck = try JSONDecoder().decode(Nachschlag.self, from: roh)
|
||||
// Die Nachschlagetabellen werden beim Lesen neu gebaut, nicht
|
||||
// mitgeschrieben – genau das prüft dieser Test.
|
||||
#expect(zurueck.kategorie(7)?.name == "Wohnen")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Modelle gegen echte Serverantworten
|
||||
|
||||
/// Nur da, um über `Bundle(for:)` an die Prüfdatei zu kommen. Swift Testing
|
||||
/// kennt keine Testklasse, an der sich das Bündel ablesen liesse.
|
||||
private final class Markierung {}
|
||||
|
||||
/// Entschlüsselt jedes Modell aus einer echten Antwort des Backends.
|
||||
///
|
||||
/// Das ist die Prüfung, die beim blinden Schreiben wirklich etwas fängt: Ein
|
||||
/// vertippter Feldname übersetzt anstandslos und fällt erst auf, wenn eine
|
||||
/// Liste beim Nutzer leer bleibt. `Tests/Fixtures/antworten.json` kommt aus
|
||||
/// `backend/scripts/erzeuge_ios_fixture.py`; nach jeder Schema-Änderung neu
|
||||
/// erzeugen.
|
||||
@Suite("Serverantworten")
|
||||
struct ServerantwortenTests {
|
||||
|
||||
private static let antworten: [String: Data] = {
|
||||
guard let ort = Bundle(for: Markierung.self)
|
||||
.url(forResource: "antworten", withExtension: "json"),
|
||||
let roh = try? Data(contentsOf: ort),
|
||||
let oben = try? JSONSerialization.jsonObject(with: roh) as? [String: Any]
|
||||
else { return [:] }
|
||||
return oben.compactMapValues {
|
||||
try? JSONSerialization.data(withJSONObject: $0, options: .fragmentsAllowed)
|
||||
}
|
||||
}()
|
||||
|
||||
private func lies<A: Decodable>(_ typ: A.Type, _ name: String) throws -> A {
|
||||
let daten = try #require(Self.antworten[name],
|
||||
"Prüfdatei kennt '\(name)' nicht – neu erzeugen?")
|
||||
return try JSONDecoder().decode(A.self, from: daten)
|
||||
}
|
||||
|
||||
@Test("Die Prüfdatei liegt im Testbündel")
|
||||
func dateiIstDa() {
|
||||
// Ohne diesen Test wären alle folgenden still übersprungen, wenn die
|
||||
// Datei nicht ins Ziel kopiert wird.
|
||||
#expect(!Self.antworten.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Konten, Kategorien und Schlagwörter")
|
||||
func stammdaten() throws {
|
||||
let konten = try lies([Konto].self, "konten")
|
||||
#expect(konten.count == 2)
|
||||
#expect(konten.contains { $0.isLocked })
|
||||
|
||||
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
|
||||
#expect(gruppen.contains { !$0.categories.isEmpty })
|
||||
|
||||
#expect(try !lies([Schlagwort].self, "schlagwoerter").isEmpty)
|
||||
}
|
||||
|
||||
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
|
||||
func buchungen() throws {
|
||||
let alle = try lies([Buchung].self, "buchungen")
|
||||
#expect(alle.count == 5)
|
||||
// Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
|
||||
// nur in einem Fall belegt sind.
|
||||
#expect(alle.contains { !$0.tagIds.isEmpty })
|
||||
#expect(alle.contains { $0.type == .transfer && $0.toAccountId != nil })
|
||||
#expect(alle.contains { $0.subscriptionId != nil })
|
||||
#expect(alle.contains { $0.notes?.isEmpty == false })
|
||||
}
|
||||
|
||||
@Test("Die Paarliste fasst zwei getrennte Buchungen zusammen")
|
||||
func verknuepfungen() throws {
|
||||
let paare = try lies([String: Uebertragsverknuepfung].self, "uebertragsverknuepfungen")
|
||||
#expect(paare.count == 2)
|
||||
|
||||
let alle = try lies([Buchung].self, "buchungen")
|
||||
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
|
||||
// Fünf Buchungen, zwei davon ein Paar: vier Zeilen.
|
||||
#expect(zeilen.count == 4)
|
||||
#expect(zeilen.filter(\.istUebertrag).count == 2)
|
||||
}
|
||||
|
||||
@Test("Budget-Monat, Vorlage und Verträge")
|
||||
func budget() throws {
|
||||
let monat = try lies(Budgetmonat.self, "budget")
|
||||
#expect(!monat.groups.isEmpty)
|
||||
#expect(monat.groups.contains { !$0.categories.isEmpty })
|
||||
#expect(monat.totals.plannedExpense > 0)
|
||||
// prev/next tragen die Blätterung – ohne sie steht die Ansicht still.
|
||||
#expect(!monat.prev.isEmpty && !monat.next.isEmpty)
|
||||
|
||||
let vorlage = try lies(Budgetvorlage.self, "budgetvorlage")
|
||||
// Betriebsart und Zuschlag kamen mit Backend-Ergaenzung C dazu.
|
||||
#expect(vorlage.lines.contains { $0.modus == "fest" })
|
||||
|
||||
let abos = try lies(Aboliste.self, "abos")
|
||||
#expect(abos.summary.anzahlAktiv >= 1)
|
||||
#expect(abos.subscriptions.contains { $0.intervalMonths == 12 })
|
||||
}
|
||||
|
||||
@Test("Alle vier Dashboard-Karten")
|
||||
func dashboard() throws {
|
||||
let staende = try lies(Kontostaende.self, "kontostaende")
|
||||
#expect(staende.accounts.count == 2)
|
||||
#expect(staende.gebunden != 0)
|
||||
|
||||
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
|
||||
#expect(try lies(EinnahmenAusgaben.self, "einnahmenAusgaben").income > 0)
|
||||
|
||||
let letzte = try lies(LetzteBuchungen.self, "letzteBuchungen")
|
||||
#expect(!letzte.transactions.isEmpty)
|
||||
// Ein Übertrag hat keine Richtung – sign ist dort null.
|
||||
#expect(letzte.transactions.contains { $0.vorzeichen == .keines })
|
||||
}
|
||||
|
||||
@Test("Benutzer, Einstellungen und Serverkennung")
|
||||
func zugang() throws {
|
||||
#expect(try lies(Serverkennung.self, "version").istCertain)
|
||||
#expect(try !lies(Benutzer.self, "ichSelbst").username.isEmpty)
|
||||
|
||||
let roh = try lies([Einstellung].self, "einstellungen")
|
||||
#expect(!Einstellungen(roh).basiswaehrung.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
"allgemein.nochmal" = "Nochmal versuchen";
|
||||
"allgemein.abbrechen" = "Abbrechen";
|
||||
"allgemein.abmelden" = "Abmelden";
|
||||
"allgemein.unbekannt" = "Unbekannt";
|
||||
|
||||
/* --- Reiter --- */
|
||||
"reiter.dashboard" = "Übersicht";
|
||||
"reiter.budget" = "Budget";
|
||||
"reiter.buchungen" = "Buchungen";
|
||||
"reiter.abos" = "Verträge";
|
||||
"reiter.konten" = "Konten";
|
||||
"reiter.einstellungen" = "Einstellungen";
|
||||
|
||||
@@ -102,3 +107,59 @@
|
||||
"einstellungen.budgetStarttag" = "Budget beginnt am";
|
||||
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
|
||||
"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt.";
|
||||
|
||||
/* --- Dashboard --- */
|
||||
"dashboard.bilanz" = "Dieser Monat";
|
||||
"dashboard.einnahmen" = "Einnahmen";
|
||||
"dashboard.ausgaben" = "Ausgaben";
|
||||
"dashboard.budgetfortschritt" = "Budget";
|
||||
"dashboard.keinBudget" = "Für diesen Monat ist noch nichts geplant.";
|
||||
"dashboard.kontostaende" = "Konten";
|
||||
"dashboard.letzteBuchungen" = "Zuletzt";
|
||||
|
||||
/* --- Budget --- */
|
||||
"budget.einnahmen" = "Geplante Einnahmen";
|
||||
"budget.uebrig" = "übrig";
|
||||
"budget.vonGeplant" = "%1$@ von %2$@ ausgegeben";
|
||||
"budget.vonBetrag" = "von %@";
|
||||
"budget.angepasst" = "angepasst";
|
||||
"budget.vorigerMonat" = "Voriger Monat";
|
||||
"budget.naechsterMonat" = "Nächster Monat";
|
||||
"budget.ausVertraegenHinweis" = "%1$d Verträge: %2$@";
|
||||
"budget.ausVertraegenBerechnet" = "Berechnet aus %1$d Verträgen: %2$@";
|
||||
"budget.vertraegeOhneKategorie" = "%1$d Verträge ohne Kategorie (%2$@) fehlen in diesen Summen.";
|
||||
"budget.ohneKurs" = "Kein Wechselkurs für %@ – diese Beträge fehlen in den Summen.";
|
||||
|
||||
/* --- Buchungen --- */
|
||||
"buchungen.suchen" = "Empfänger, Zweck, Notiz";
|
||||
"buchungen.konto" = "Konto";
|
||||
"buchungen.alleKonten" = "Alle Konten";
|
||||
"buchungen.art" = "Art";
|
||||
"buchungen.alleArten" = "Alle Arten";
|
||||
"buchungen.leerTitel" = "Keine Buchungen";
|
||||
"buchungen.leerErklaerung" = "Sobald etwas gebucht ist, steht es hier.";
|
||||
"buchungen.leerGefiltert" = "Zu dieser Suche gibt es nichts. Andere Filter versuchen?";
|
||||
"buchung.ohneBezeichnung" = "Ohne Bezeichnung";
|
||||
"buchung.ohneKategorie" = "Ohne Kategorie";
|
||||
"buchung.uebertrag" = "Übertrag";
|
||||
|
||||
/* --- Buchungsarten --- */
|
||||
"buchungsart.ausgabe" = "Ausgabe";
|
||||
"buchungsart.einnahme" = "Einnahme";
|
||||
"buchungsart.uebertrag" = "Übertrag";
|
||||
|
||||
/* --- Verträge --- */
|
||||
"abos.leerTitel" = "Keine Verträge";
|
||||
"abos.leerErklaerung" = "Hier stehen Abos und Verträge, sobald welche angelegt sind.";
|
||||
"abos.beendeteZeigen" = "Beendete zeigen";
|
||||
"abos.beendet" = "beendet";
|
||||
"abos.jaehrlichUndAnzahl" = "%1$@ im Jahr · %2$d aktiv";
|
||||
"abos.ohneKurs" = "Kein Wechselkurs für %@ – diese Verträge fehlen in der Summe.";
|
||||
"abo.monatlich" = "monatlich";
|
||||
"abo.vierteljaehrlich" = "vierteljährlich";
|
||||
"abo.halbjaehrlich" = "halbjährlich";
|
||||
"abo.jaehrlich" = "jährlich";
|
||||
"abo.alleNMonate" = "alle %d Monate";
|
||||
"abo.proMonat" = "%@ im Monat";
|
||||
"abo.naechste" = "nächste am %@";
|
||||
"abo.inTagen" = "fällig in %d Tagen";
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
"allgemein.nochmal" = "Try again";
|
||||
"allgemein.abbrechen" = "Cancel";
|
||||
"allgemein.abmelden" = "Sign out";
|
||||
"allgemein.unbekannt" = "Unknown";
|
||||
|
||||
/* --- Tabs --- */
|
||||
"reiter.dashboard" = "Overview";
|
||||
"reiter.budget" = "Budget";
|
||||
"reiter.buchungen" = "Transactions";
|
||||
"reiter.abos" = "Subscriptions";
|
||||
"reiter.konten" = "Accounts";
|
||||
"reiter.einstellungen" = "Settings";
|
||||
|
||||
@@ -102,3 +107,59 @@
|
||||
"einstellungen.budgetStarttag" = "Budget starts on";
|
||||
"einstellungen.abmeldenFrage" = "Sign out on this device?";
|
||||
"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";
|
||||
|
||||
/* --- Dashboard --- */
|
||||
"dashboard.bilanz" = "This month";
|
||||
"dashboard.einnahmen" = "Income";
|
||||
"dashboard.ausgaben" = "Expenses";
|
||||
"dashboard.budgetfortschritt" = "Budget";
|
||||
"dashboard.keinBudget" = "Nothing planned for this month yet.";
|
||||
"dashboard.kontostaende" = "Accounts";
|
||||
"dashboard.letzteBuchungen" = "Recent";
|
||||
|
||||
/* --- Budget --- */
|
||||
"budget.einnahmen" = "Planned income";
|
||||
"budget.uebrig" = "left";
|
||||
"budget.vonGeplant" = "%1$@ of %2$@ spent";
|
||||
"budget.vonBetrag" = "of %@";
|
||||
"budget.angepasst" = "adjusted";
|
||||
"budget.vorigerMonat" = "Previous month";
|
||||
"budget.naechsterMonat" = "Next month";
|
||||
"budget.ausVertraegenHinweis" = "%1$d subscriptions: %2$@";
|
||||
"budget.ausVertraegenBerechnet" = "Calculated from %1$d subscriptions: %2$@";
|
||||
"budget.vertraegeOhneKategorie" = "%1$d subscriptions without a category (%2$@) are missing from these totals.";
|
||||
"budget.ohneKurs" = "No exchange rate for %@ – those amounts are missing from the totals.";
|
||||
|
||||
/* --- Transactions --- */
|
||||
"buchungen.suchen" = "Payee, description, note";
|
||||
"buchungen.konto" = "Account";
|
||||
"buchungen.alleKonten" = "All accounts";
|
||||
"buchungen.art" = "Type";
|
||||
"buchungen.alleArten" = "All types";
|
||||
"buchungen.leerTitel" = "No transactions";
|
||||
"buchungen.leerErklaerung" = "Once something is booked, it shows up here.";
|
||||
"buchungen.leerGefiltert" = "Nothing matches this search. Try different filters?";
|
||||
"buchung.ohneBezeichnung" = "Untitled";
|
||||
"buchung.ohneKategorie" = "No category";
|
||||
"buchung.uebertrag" = "Transfer";
|
||||
|
||||
/* --- Transaction types --- */
|
||||
"buchungsart.ausgabe" = "Expense";
|
||||
"buchungsart.einnahme" = "Income";
|
||||
"buchungsart.uebertrag" = "Transfer";
|
||||
|
||||
/* --- Subscriptions --- */
|
||||
"abos.leerTitel" = "No subscriptions";
|
||||
"abos.leerErklaerung" = "Subscriptions and contracts show up here once you add them.";
|
||||
"abos.beendeteZeigen" = "Show cancelled";
|
||||
"abos.beendet" = "cancelled";
|
||||
"abos.jaehrlichUndAnzahl" = "%1$@ per year · %2$d active";
|
||||
"abos.ohneKurs" = "No exchange rate for %@ – those subscriptions are missing from the total.";
|
||||
"abo.monatlich" = "monthly";
|
||||
"abo.vierteljaehrlich" = "quarterly";
|
||||
"abo.halbjaehrlich" = "twice a year";
|
||||
"abo.jaehrlich" = "yearly";
|
||||
"abo.alleNMonate" = "every %d months";
|
||||
"abo.proMonat" = "%@ per month";
|
||||
"abo.naechste" = "next on %@";
|
||||
"abo.inTagen" = "due in %d days";
|
||||
|
||||
@@ -19,7 +19,11 @@ targets:
|
||||
sources:
|
||||
- path: Anmeldung
|
||||
- path: App
|
||||
- path: Abos
|
||||
- path: Bausteine
|
||||
- path: Buchungen
|
||||
- path: Budget
|
||||
- path: Dashboard
|
||||
- path: Einstellungen
|
||||
- path: Konten
|
||||
- path: Modelle
|
||||
@@ -52,6 +56,8 @@ targets:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
# Tests/Fixtures/antworten.json landet als Ressource im Buendel -
|
||||
# der Test "Serverantworten" liest sie von dort.
|
||||
- path: Tests
|
||||
dependencies:
|
||||
- target: Certain
|
||||
|
||||
Reference in New Issue
Block a user