Compare commits
3
Commits
3dff435156
...
bf437b3b69
| 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 einstellungen = Einstellungen()
|
||||||
private(set) var client: CertainClient?
|
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,
|
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
|
||||||
/// sondern eine Zeile unter dem Formular.
|
/// sondern eine Zeile unter dem Formular.
|
||||||
var meldung: String?
|
var meldung: String?
|
||||||
@@ -61,8 +67,12 @@ final class AppZustand {
|
|||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
do {
|
do {
|
||||||
let ich = try await client.ichSelbst()
|
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()
|
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
|
||||||
lage = .bereit(ich)
|
lage = .bereit(ich)
|
||||||
|
await ladeNachschlag()
|
||||||
} catch APIFehler.nichtAngemeldet {
|
} catch APIFehler.nichtAngemeldet {
|
||||||
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
||||||
Ablage.marke = nil
|
Ablage.marke = nil
|
||||||
@@ -77,6 +87,7 @@ final class AppZustand {
|
|||||||
// Ansichten den letzten Stand. Die Marke ist ja da.
|
// Ansichten den letzten Stand. Die Marke ist ja da.
|
||||||
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
||||||
lage = .bereit(gemerkt.wert)
|
lage = .bereit(gemerkt.wert)
|
||||||
|
await ladeNachschlag()
|
||||||
} else {
|
} else {
|
||||||
meldung = error.localizedDescription
|
meldung = error.localizedDescription
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
@@ -240,6 +251,20 @@ final class AppZustand {
|
|||||||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
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
|
// MARK: - Kleinkram
|
||||||
|
|
||||||
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
|
/// 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
|
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
||||||
/// erledigt `.sidebarAdaptable` von selbst.
|
/// erledigt `.sidebarAdaptable` von selbst.
|
||||||
///
|
///
|
||||||
/// Stufe 1 hat erst zwei Einträge. Die weiteren kommen hier dazu, die
|
/// Reihenfolge nach Häufigkeit, nicht nach Umfang: Das Dashboard beantwortet
|
||||||
/// Anordnung bleibt.
|
/// die Frage, wegen der man die App öffnet. Konten und Einstellungen stehen
|
||||||
|
/// hinten, weil man sie selten braucht.
|
||||||
struct Hauptnavigation: View {
|
struct Hauptnavigation: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
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") {
|
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
|
||||||
NavigationStack { KontenListe() }
|
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 = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
|
||||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.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 */; };
|
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 */; };
|
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
||||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
|
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
|
||||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.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 */; };
|
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 */; };
|
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
||||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.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 */; };
|
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 */; };
|
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 */; };
|
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
|
||||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.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 */; };
|
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
||||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||||
@@ -44,11 +54,14 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -92,6 +112,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
|
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
|
||||||
|
7DD1C597D594D0F965FF1547 /* Fixtures */,
|
||||||
);
|
);
|
||||||
path = Tests;
|
path = Tests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -114,16 +135,37 @@
|
|||||||
path = Bausteine;
|
path = Bausteine;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
7DD1C597D594D0F965FF1547 /* Fixtures */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3137E525D1ADE3DB2F8E45A8 /* antworten.json */,
|
||||||
|
);
|
||||||
|
path = Fixtures;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
|
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E8CBA42A030509615199DD45 /* Abo.swift */,
|
||||||
|
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||||
|
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||||
|
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||||
|
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||||
);
|
);
|
||||||
path = Modelle;
|
path = Modelle;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AE11D36E2DFAC34130E2868F /* Dashboard */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */,
|
||||||
|
);
|
||||||
|
path = Dashboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
|
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -138,9 +180,13 @@
|
|||||||
BC82A6A323BDD2AD8325BEA2 = {
|
BC82A6A323BDD2AD8325BEA2 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||||
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
||||||
|
D99101183ED9DA14C8D22722 /* Buchungen */,
|
||||||
|
C4B3E718A814E0236A12EB86 /* Budget */,
|
||||||
|
AE11D36E2DFAC34130E2868F /* Dashboard */,
|
||||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||||
@@ -170,6 +216,30 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
DB51F54CC077E85C286AD9DC /* Netzwerk */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -209,6 +279,7 @@
|
|||||||
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
|
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
104B97AFE88042A9CF83B9C7 /* Sources */,
|
104B97AFE88042A9CF83B9C7 /* Sources */,
|
||||||
|
5E084A3C9ED86572E29E01B7 /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -267,6 +338,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
5E084A3C9ED86572E29E01B7 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -284,15 +363,24 @@
|
|||||||
files = (
|
files = (
|
||||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
||||||
|
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
||||||
|
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
||||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.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 */,
|
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
||||||
|
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
||||||
|
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
||||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||||
|
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.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 {
|
func konto(_ id: Int) async throws -> Konto {
|
||||||
try await hole("accounts/\(id)")
|
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)
|
#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.nochmal" = "Nochmal versuchen";
|
||||||
"allgemein.abbrechen" = "Abbrechen";
|
"allgemein.abbrechen" = "Abbrechen";
|
||||||
"allgemein.abmelden" = "Abmelden";
|
"allgemein.abmelden" = "Abmelden";
|
||||||
|
"allgemein.unbekannt" = "Unbekannt";
|
||||||
|
|
||||||
/* --- Reiter --- */
|
/* --- Reiter --- */
|
||||||
|
"reiter.dashboard" = "Übersicht";
|
||||||
|
"reiter.budget" = "Budget";
|
||||||
|
"reiter.buchungen" = "Buchungen";
|
||||||
|
"reiter.abos" = "Verträge";
|
||||||
"reiter.konten" = "Konten";
|
"reiter.konten" = "Konten";
|
||||||
"reiter.einstellungen" = "Einstellungen";
|
"reiter.einstellungen" = "Einstellungen";
|
||||||
|
|
||||||
@@ -102,3 +107,59 @@
|
|||||||
"einstellungen.budgetStarttag" = "Budget beginnt am";
|
"einstellungen.budgetStarttag" = "Budget beginnt am";
|
||||||
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
|
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
|
||||||
"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt.";
|
"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.nochmal" = "Try again";
|
||||||
"allgemein.abbrechen" = "Cancel";
|
"allgemein.abbrechen" = "Cancel";
|
||||||
"allgemein.abmelden" = "Sign out";
|
"allgemein.abmelden" = "Sign out";
|
||||||
|
"allgemein.unbekannt" = "Unknown";
|
||||||
|
|
||||||
/* --- Tabs --- */
|
/* --- Tabs --- */
|
||||||
|
"reiter.dashboard" = "Overview";
|
||||||
|
"reiter.budget" = "Budget";
|
||||||
|
"reiter.buchungen" = "Transactions";
|
||||||
|
"reiter.abos" = "Subscriptions";
|
||||||
"reiter.konten" = "Accounts";
|
"reiter.konten" = "Accounts";
|
||||||
"reiter.einstellungen" = "Settings";
|
"reiter.einstellungen" = "Settings";
|
||||||
|
|
||||||
@@ -102,3 +107,59 @@
|
|||||||
"einstellungen.budgetStarttag" = "Budget starts on";
|
"einstellungen.budgetStarttag" = "Budget starts on";
|
||||||
"einstellungen.abmeldenFrage" = "Sign out on this device?";
|
"einstellungen.abmeldenFrage" = "Sign out on this device?";
|
||||||
"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";
|
"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:
|
sources:
|
||||||
- path: Anmeldung
|
- path: Anmeldung
|
||||||
- path: App
|
- path: App
|
||||||
|
- path: Abos
|
||||||
- path: Bausteine
|
- path: Bausteine
|
||||||
|
- path: Buchungen
|
||||||
|
- path: Budget
|
||||||
|
- path: Dashboard
|
||||||
- path: Einstellungen
|
- path: Einstellungen
|
||||||
- path: Konten
|
- path: Konten
|
||||||
- path: Modelle
|
- path: Modelle
|
||||||
@@ -52,6 +56,8 @@ targets:
|
|||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
|
# Tests/Fixtures/antworten.json landet als Ressource im Buendel -
|
||||||
|
# der Test "Serverantworten" liest sie von dort.
|
||||||
- path: Tests
|
- path: Tests
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: Certain
|
- target: Certain
|
||||||
|
|||||||
Reference in New Issue
Block a user