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

Die App kann jetzt lesen, wofuer man sie oeffnet. Vier neue Reiter:

- Uebersicht: Einnahmen/Ausgaben des Monats, Budget-Fortschritt je Gruppe,
  Kontostaende und die letzten Buchungen. Feste Auswahl statt des
  verschiebbaren Rasters der Weboberflaeche - auf Telefonbreite waere das
  Arbeit ohne Gewinn. Vier Endpunkte nebeneinander in einem Abruf, damit der
  Offline-Stand nicht aus vier verschiedenen Momenten stammt.
- Budget: Soll gegen Ist je Gruppe und Kategorie, blaetterbar ueber prev/next
  aus der Antwort. Rot nur bei echter Ueberschreitung - wer mehr zuruecklegt
  als geplant, hat nichts ueberzogen. Vertraege ohne Kategorie und fehlende
  Wechselkurse stehen als Hinweis darunter, statt die Summen still zu
  verfaelschen.
- Buchungen: Suche und Filter laufen im Server (Backend-Ergaenzung B), nicht
  im Telefon. Gesucht wird beim Absenden, nicht je Tastendruck.
- Vertraege: Monats- und Jahressumme, Rhythmus, naechste Faelligkeit.

Zwei Dinge, die das Backend bewusst dem Client ueberlaesst:

Ein Uebertrag kommt in zwei Gestalten - am Stueck (eine Buchung mit
to_account_id) oder als zwei verknuepfte Buchungen, wie die Bank sie liefert.
Beide Beine als zwei Zeilen zu zeigen hiesse, dasselbe Geld zweimal zu zeigen.
Buchungszeile.zeilen fasst sie zusammen, aber nur wenn beide in der Liste
stehen - sonst liesse ein Kontofilter Zeilen verschwinden, die sehr wohl zu
diesem Konto gehoeren. Die Richtung kommt aus der Verknuepfung, nicht aus dem
Vorzeichen: Manche Banken liefern beide Beine positiv.

Kategorien, Schlagwoerter und Konten liegen als Nachschlagewerk im AppZustand,
einmal geladen. Je Ansicht neu zu holen waere genau das N+1, das das Backend
sich selbst verbietet.

Texte in Deutsch und Englisch, zeilengleich. 44 Tests gruen, darunter die
Uebertrags-Paarung und jedes Modell gegen eine echte Serverantwort.

Nicht geprueft: der Weg gegen ein echtes Backend. Die App laesst nur https zu,
ein lokaler Server haette eine ATS-Ausnahme gebraucht.

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