feat: Zweite Stufe der iOS-App - Uebersicht, Budget, Buchungen, Vertraege
Die App kann jetzt lesen, wofuer man sie oeffnet. Vier neue Reiter: - Uebersicht: Einnahmen/Ausgaben des Monats, Budget-Fortschritt je Gruppe, Kontostaende und die letzten Buchungen. Feste Auswahl statt des verschiebbaren Rasters der Weboberflaeche - auf Telefonbreite waere das Arbeit ohne Gewinn. Vier Endpunkte nebeneinander in einem Abruf, damit der Offline-Stand nicht aus vier verschiedenen Momenten stammt. - Budget: Soll gegen Ist je Gruppe und Kategorie, blaetterbar ueber prev/next aus der Antwort. Rot nur bei echter Ueberschreitung - wer mehr zuruecklegt als geplant, hat nichts ueberzogen. Vertraege ohne Kategorie und fehlende Wechselkurse stehen als Hinweis darunter, statt die Summen still zu verfaelschen. - Buchungen: Suche und Filter laufen im Server (Backend-Ergaenzung B), nicht im Telefon. Gesucht wird beim Absenden, nicht je Tastendruck. - Vertraege: Monats- und Jahressumme, Rhythmus, naechste Faelligkeit. Zwei Dinge, die das Backend bewusst dem Client ueberlaesst: Ein Uebertrag kommt in zwei Gestalten - am Stueck (eine Buchung mit to_account_id) oder als zwei verknuepfte Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen hiesse, dasselbe Geld zweimal zu zeigen. Buchungszeile.zeilen fasst sie zusammen, aber nur wenn beide in der Liste stehen - sonst liesse ein Kontofilter Zeilen verschwinden, die sehr wohl zu diesem Konto gehoeren. Die Richtung kommt aus der Verknuepfung, nicht aus dem Vorzeichen: Manche Banken liefern beide Beine positiv. Kategorien, Schlagwoerter und Konten liegen als Nachschlagewerk im AppZustand, einmal geladen. Je Ansicht neu zu holen waere genau das N+1, das das Backend sich selbst verbietet. Texte in Deutsch und Englisch, zeilengleich. 44 Tests gruen, darunter die Uebertrags-Paarung und jedes Modell gegen eine echte Serverantwort. Nicht geprueft: der Weg gegen ein echtes Backend. Die App laesst nur https zu, ein lokaler Server haette eine ATS-Ausnahme gebraucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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: " · ")
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,24 @@ struct RootAnsicht: View {
|
||||
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
||||
/// erledigt `.sidebarAdaptable` von selbst.
|
||||
///
|
||||
/// Stufe 1 hat erst zwei Einträge. Die weiteren kommen hier dazu, die
|
||||
/// Anordnung bleibt.
|
||||
/// Reihenfolge nach Häufigkeit, nicht nach Umfang: Das Dashboard beantwortet
|
||||
/// die Frage, wegen der man die App öffnet. Konten und Einstellungen stehen
|
||||
/// hinten, weil man sie selten braucht.
|
||||
struct Hauptnavigation: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab(String(localized: "reiter.dashboard"), systemImage: "square.grid.2x2") {
|
||||
NavigationStack { DashboardAnsicht() }
|
||||
}
|
||||
Tab(String(localized: "reiter.budget"), systemImage: "chart.pie") {
|
||||
NavigationStack { BudgetAnsicht() }
|
||||
}
|
||||
Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") {
|
||||
NavigationStack { BuchungsListe() }
|
||||
}
|
||||
Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") {
|
||||
NavigationStack { AboListe() }
|
||||
}
|
||||
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
|
||||
NavigationStack { KontenListe() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Buchungen und die Paarliste in einem Stand.
|
||||
///
|
||||
/// Beides gehört zusammen: Ohne die Paare zeigt die Liste beide Beine eines
|
||||
/// Übertrags als zwei Zeilen und damit dasselbe Geld zweimal.
|
||||
struct Buchungsstand: Codable, Sendable, Hashable {
|
||||
let buchungen: [Buchung]
|
||||
let paare: [String: Uebertragsverknuepfung]
|
||||
}
|
||||
|
||||
/// Die Buchungsliste.
|
||||
///
|
||||
/// Gesucht und gefiltert wird im **Server** – seit der Backend-Ergänzung
|
||||
/// kennt `GET /transactions` `q`, `tag_id` und `subscription_id`. Die
|
||||
/// Weboberfläche lädt bis zu 5000 Zeilen und siebt im Browser; auf dem
|
||||
/// Telefon wäre das der Unterschied zwischen „lädt" und „benutzbar".
|
||||
struct BuchungsListe: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
|
||||
@State private var lader: Lader<Buchungsstand>?
|
||||
@State private var suche = ""
|
||||
@State private var konto: Int?
|
||||
@State private var art: Buchungsart?
|
||||
/// Was zuletzt wirklich abgerufen wurde – damit das Tippen nicht je
|
||||
/// Buchstabe einen Abruf auslöst.
|
||||
@State private var abgerufen = ""
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lade() } }) { stand in
|
||||
liste(stand)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.buchungen"))
|
||||
.searchable(text: $suche, prompt: String(localized: "buchungen.suchen"))
|
||||
// Erst beim Absenden abrufen. Je Tastendruck zu fragen hiesse, dem
|
||||
// Server bei „Supermarkt" zehn Abrufe zu schicken, von denen neun
|
||||
// niemanden interessieren.
|
||||
.onSubmit(of: .search) { Task { await lade() } }
|
||||
.onChange(of: suche) { _, neu in
|
||||
if neu.isEmpty, !abgerufen.isEmpty { Task { await lade() } }
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { filterMenue }
|
||||
}
|
||||
.task {
|
||||
guard lader == nil else { return }
|
||||
await lade()
|
||||
}
|
||||
}
|
||||
|
||||
private var filterMenue: some View {
|
||||
Menu {
|
||||
Picker(String(localized: "buchungen.konto"), selection: $konto) {
|
||||
Text(String(localized: "buchungen.alleKonten")).tag(Int?.none)
|
||||
ForEach(zustand.nachschlag.konten.filter { !$0.isArchived }) { eines in
|
||||
Text(eines.name).tag(Int?.some(eines.id))
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "buchungen.art"), selection: $art) {
|
||||
Text(String(localized: "buchungen.alleArten")).tag(Buchungsart?.none)
|
||||
ForEach(Buchungsart.allCases) { eine in
|
||||
Text(eine.name).tag(Buchungsart?.some(eine))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: gefiltert ? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.onChange(of: konto) { _, _ in Task { await lade() } }
|
||||
.onChange(of: art) { _, _ in Task { await lade() } }
|
||||
}
|
||||
|
||||
private var gefiltert: Bool { konto != nil || art != nil || !abgerufen.isEmpty }
|
||||
|
||||
private func lade() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let text = suche.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gewaehltesKonto = konto
|
||||
let gewaehlteArt = art
|
||||
abgerufen = text
|
||||
|
||||
// Der Schlüssel trägt die Filter: Sonst überschriebe eine gefilterte
|
||||
// Liste den Offline-Stand der vollständigen.
|
||||
let schluessel = "transactions_\(gewaehltesKonto ?? 0)_\(gewaehlteArt?.rawValue ?? "alle")_\(text)"
|
||||
let neuer = Lader<Buchungsstand>(schluessel: schluessel) {
|
||||
async let buchungen = client.buchungen(konto: gewaehltesKonto, art: gewaehlteArt,
|
||||
suche: text.isEmpty ? nil : text)
|
||||
async let paare = client.uebertragsverknuepfungen()
|
||||
return try await Buchungsstand(buchungen: buchungen, paare: paare)
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liste(_ stand: Buchungsstand) -> some View {
|
||||
let zeilen = Buchungszeile.zeilen(aus: stand.buchungen, paare: stand.paare)
|
||||
|
||||
if zeilen.isEmpty {
|
||||
LeerAnsicht(titel: String(localized: "buchungen.leerTitel"),
|
||||
erklaerung: String(localized: gefiltert ? "buchungen.leerGefiltert"
|
||||
: "buchungen.leerErklaerung"),
|
||||
symbol: "list.bullet.rectangle")
|
||||
} else {
|
||||
List {
|
||||
ForEach(nachTagen(zeilen), id: \.tag) { gruppe in
|
||||
Section(gruppe.tag.formatiert()) {
|
||||
ForEach(gruppe.zeilen) { zeile in
|
||||
BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Nach Tagen gruppiert, Reihenfolge wie geliefert (Datum absteigend).
|
||||
private func nachTagen(_ zeilen: [Buchungszeile]) -> [(tag: Tagesdatum, zeilen: [Buchungszeile])] {
|
||||
var reihenfolge: [Tagesdatum] = []
|
||||
var nachTag: [Tagesdatum: [Buchungszeile]] = [:]
|
||||
for zeile in zeilen {
|
||||
let tag = zeile.buchung.date
|
||||
if nachTag[tag] == nil { reihenfolge.append(tag) }
|
||||
nachTag[tag, default: []].append(zeile)
|
||||
}
|
||||
return reihenfolge.map { (tag: $0, zeilen: nachTag[$0] ?? []) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Eine Zeile
|
||||
|
||||
private struct BuchungsZeile: View {
|
||||
let zeile: Buchungszeile
|
||||
let nachschlag: Nachschlag
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: symbol, farbe: farbe, groesse: 34)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(ueberschrift)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(unterzeile)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
ForEach(zeile.buchung.tagIds, id: \.self) { tagId in
|
||||
if let etikett = nachschlag.schlagwort(tagId) {
|
||||
Text(etikett.name)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color(hex: etikett.color).opacity(0.18), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(zeile.buchung.betrag.formatiert(vorzeichen: .ohne))
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(betragsfarbe)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var kategorie: Kategorie? { nachschlag.kategorie(zeile.buchung.categoryId) }
|
||||
|
||||
private var symbol: String {
|
||||
if zeile.istUebertrag { return "arrow-left-right" }
|
||||
return kategorie?.icon ?? "more-horizontal"
|
||||
}
|
||||
|
||||
private var farbe: String {
|
||||
if zeile.istUebertrag { return "#6b7480" }
|
||||
return kategorie?.color ?? "#6b7480"
|
||||
}
|
||||
|
||||
/// Bei einem Übertrag sagt die Zeile, wohin – „Übertrag" allein sagt
|
||||
/// nichts. Sonst steht da der Empfänger.
|
||||
private var ueberschrift: String {
|
||||
guard zeile.istUebertrag else { return zeile.buchung.ueberschrift }
|
||||
let von = nachschlag.kontoname(zeile.vonKonto)
|
||||
guard let nach = zeile.nachKonto else {
|
||||
return String(localized: "buchung.uebertrag")
|
||||
}
|
||||
return "\(von) → \(nachschlag.kontoname(nach))"
|
||||
}
|
||||
|
||||
private var unterzeile: String {
|
||||
if zeile.istUebertrag {
|
||||
return zeile.buchung.unterschrift ?? String(localized: "buchung.uebertrag")
|
||||
}
|
||||
var teile: [String] = []
|
||||
if let kategorie { teile.append(kategorie.name) }
|
||||
else { teile.append(String(localized: "buchung.ohneKategorie")) }
|
||||
teile.append(nachschlag.kontoname(zeile.buchung.accountId))
|
||||
return teile.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Grün bei Einnahme, neutral beim Übertrag: Da verlässt kein Geld den
|
||||
/// Haushalt, es wechselt nur die Tasche.
|
||||
private var betragsfarbe: Color {
|
||||
if zeile.istUebertrag { return .secondary }
|
||||
return zeile.buchung.type == .income ? .green : .primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Soll gegen Ist, Monat für Monat.
|
||||
///
|
||||
/// Geblättert wird über `prev` und `next` aus der Antwort – welcher Monat auf
|
||||
/// welchen folgt, hängt am eingestellten Budget-Starttag und ist nichts, was
|
||||
/// der Client ausrechnen sollte.
|
||||
struct BudgetAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
/// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label.
|
||||
@State private var label: String?
|
||||
@State private var lader: Lader<Budgetmonat>?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { monat in
|
||||
liste(monat)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.budget"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: label) { await ladeMonat() }
|
||||
}
|
||||
|
||||
private func ladeMonat() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let gewaehlt = label
|
||||
let neuer = Lader<Budgetmonat>(schluessel: "budget/\(gewaehlt ?? "current")") {
|
||||
try await client.budget(gewaehlt)
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liste(_ monat: Budgetmonat) -> some View {
|
||||
List {
|
||||
Section {
|
||||
MonatsKopf(monat: monat)
|
||||
}
|
||||
|
||||
if !monat.income.isEmpty {
|
||||
Section(String(localized: "budget.einnahmen")) {
|
||||
ForEach(monat.income) { posten in
|
||||
EinnahmeZeile(posten: posten, waehrung: monat.baseCurrency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(monat.groups) { gruppe in
|
||||
Section {
|
||||
ForEach(gruppe.categories) { kategorie in
|
||||
KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency)
|
||||
}
|
||||
} header: {
|
||||
GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency)
|
||||
}
|
||||
}
|
||||
|
||||
if !monat.vertraege.istLeer {
|
||||
Section {
|
||||
VertragsHinweis(reste: monat.vertraege, waehrung: monat.baseCurrency)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
label = monat.prev
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
}
|
||||
.accessibilityLabel(String(localized: "budget.vorigerMonat"))
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text(monatsname(monat))
|
||||
.font(.headline)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
label = monat.next
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.accessibilityLabel(String(localized: "budget.naechsterMonat"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// „Oktober 2026" statt „2026-10".
|
||||
private func monatsname(_ monat: Budgetmonat) -> String {
|
||||
monat.start.datum.formatted(.dateTime.month(.wide).year())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private struct MonatsKopf: View {
|
||||
let monat: Budgetmonat
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(Geldbetrag(cents: monat.restCents, waehrung: monat.baseCurrency).formatiert())
|
||||
.font(.title.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(monat.restCents < 0 ? .red : .primary)
|
||||
Text(String(localized: "budget.uebrig"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ProgressView(value: monat.anteilVerbraucht)
|
||||
.tint(monat.restCents < 0 ? .red : .accentColor)
|
||||
|
||||
HStack {
|
||||
Text(String(format: String(localized: "budget.vonGeplant"),
|
||||
Geldbetrag(cents: monat.totals.actualExpense,
|
||||
waehrung: monat.baseCurrency).formatiert(),
|
||||
Geldbetrag(cents: monat.totals.plannedExpense,
|
||||
waehrung: monat.baseCurrency).formatiert()))
|
||||
Spacer()
|
||||
if monat.customized {
|
||||
// Wer das nicht sieht, wundert sich, warum die Vorlage
|
||||
// andere Zahlen zeigt als dieser Monat.
|
||||
Text(String(localized: "budget.angepasst"))
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GruppenKopf: View {
|
||||
let gruppe: Budgetgruppe
|
||||
let waehrung: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: Symbole.sf(gruppe.icon))
|
||||
.foregroundStyle(Color(hex: gruppe.color))
|
||||
Text(gruppe.name)
|
||||
Spacer(minLength: 8)
|
||||
Text(Geldbetrag(cents: gruppe.actualCents, waehrung: waehrung).formatiert())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(gruppe.over && !gruppe.good ? .red : .secondary)
|
||||
Text("/")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(Geldbetrag(cents: gruppe.plannedCents, waehrung: waehrung).formatiert())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.textCase(nil)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
private struct KategorieZeile: View {
|
||||
let kategorie: Budgetkategorie
|
||||
let waehrung: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(kategorie.name)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
if let unterzeile {
|
||||
Text(unterzeile)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Text(Geldbetrag(cents: kategorie.actualCents, waehrung: waehrung).formatiert())
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(warnt ? .red : .primary)
|
||||
Text(String(format: String(localized: "budget.vonBetrag"),
|
||||
Geldbetrag(cents: kategorie.plannedCents,
|
||||
waehrung: waehrung).formatiert()))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if kategorie.plannedCents > 0 || kategorie.actualCents > 0 {
|
||||
ProgressView(value: kategorie.anteilVerbraucht)
|
||||
.tint(warnt ? .red : (kategorie.good ? .green : Color(hex: kategorie.color)))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
/// Rot nur bei echter Überschreitung: Mehr sparen als geplant ist keine.
|
||||
private var warnt: Bool { kategorie.over && !kategorie.good }
|
||||
|
||||
/// Woher der Soll-Wert kommt, wenn er sich nicht einfach ablesen lässt.
|
||||
private var unterzeile: String? {
|
||||
guard kategorie.vertraegeAnzahl > 0 else { return nil }
|
||||
let ausVertraegen = Geldbetrag(cents: kategorie.ausVertraegenCents,
|
||||
waehrung: waehrung).formatiert()
|
||||
if kategorie.istBerechnet {
|
||||
return String(format: String(localized: "budget.ausVertraegenBerechnet"),
|
||||
kategorie.vertraegeAnzahl, ausVertraegen)
|
||||
}
|
||||
return String(format: String(localized: "budget.ausVertraegenHinweis"),
|
||||
kategorie.vertraegeAnzahl, ausVertraegen)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EinnahmeZeile: View {
|
||||
let posten: GeplanteEinnahme
|
||||
let waehrung: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(posten.label)
|
||||
.font(.callout)
|
||||
Spacer(minLength: 8)
|
||||
Text(Geldbetrag(cents: posten.receivedCents, waehrung: waehrung).formatiert())
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(posten.receivedCents >= posten.plannedCents ? .green : .primary)
|
||||
Text("/")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(Geldbetrag(cents: posten.plannedCents, waehrung: waehrung).formatiert())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verträge, die im Budget fehlen – und Währungen, für die kein Kurs vorlag.
|
||||
///
|
||||
/// Ohne diesen Hinweis stimmen die Summen still nicht. Genau davor warnt das
|
||||
/// Backend mit den `ohne_kurs`-Listen.
|
||||
private struct VertragsHinweis: View {
|
||||
let reste: Vertragsreste
|
||||
let waehrung: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if reste.ohneKategorieAnzahl > 0 {
|
||||
Label {
|
||||
Text(String(format: String(localized: "budget.vertraegeOhneKategorie"),
|
||||
reste.ohneKategorieAnzahl,
|
||||
Geldbetrag(cents: reste.ohneKategorieCents,
|
||||
waehrung: waehrung).formatiert()))
|
||||
} icon: {
|
||||
Image(systemName: "questionmark.circle")
|
||||
}
|
||||
}
|
||||
if !reste.ohneKurs.isEmpty {
|
||||
Label {
|
||||
Text(String(format: String(localized: "budget.ohneKurs"),
|
||||
reste.ohneKurs.joined(separator: ", ")))
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
@@ -7,24 +7,34 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
|
||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
|
||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
|
||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
|
||||
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */; };
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51DE5BDB909910D586F819F /* Budget.swift */; };
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */; };
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BE152C9F91584900796ADA /* Grundmodelle.swift */; };
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
|
||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||
@@ -44,11 +54,14 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
|
||||
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
|
||||
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
|
||||
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
|
||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
|
||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
|
||||
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
|
||||
@@ -60,14 +73,21 @@
|
||||
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
|
||||
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = "<group>"; };
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = "<group>"; };
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = "<group>"; };
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = "<group>"; };
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = "<group>"; };
|
||||
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
|
||||
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
|
||||
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
|
||||
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = "<group>"; };
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -92,6 +112,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
|
||||
7DD1C597D594D0F965FF1547 /* Fixtures */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
@@ -114,16 +135,37 @@
|
||||
path = Bausteine;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7DD1C597D594D0F965FF1547 /* Fixtures */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */,
|
||||
);
|
||||
path = Fixtures;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */,
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||
);
|
||||
path = Modelle;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AE11D36E2DFAC34130E2868F /* Dashboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */,
|
||||
);
|
||||
path = Dashboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -138,9 +180,13 @@
|
||||
BC82A6A323BDD2AD8325BEA2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */,
|
||||
C4B3E718A814E0236A12EB86 /* Budget */,
|
||||
AE11D36E2DFAC34130E2868F /* Dashboard */,
|
||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||
@@ -170,6 +216,30 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C4B3E718A814E0236A12EB86 /* Budget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
|
||||
);
|
||||
path = Budget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
|
||||
);
|
||||
path = Abos;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
|
||||
);
|
||||
path = Buchungen;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB51F54CC077E85C286AD9DC /* Netzwerk */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -209,6 +279,7 @@
|
||||
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
|
||||
buildPhases = (
|
||||
104B97AFE88042A9CF83B9C7 /* Sources */,
|
||||
5E084A3C9ED86572E29E01B7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -267,6 +338,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E084A3C9ED86572E29E01B7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -284,15 +363,24 @@
|
||||
files = (
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Alles, was das Dashboard braucht, in einem Abruf.
|
||||
///
|
||||
/// Vier Endpunkte nebeneinander statt nacheinander – sie wissen nichts
|
||||
/// voneinander. Zusammen in einem Wert, damit `Lader` sie als einen Stand
|
||||
/// merkt: Vier getrennte Zwischenspeicher könnten offline aus vier
|
||||
/// verschiedenen Momenten stammen und sich widersprechen.
|
||||
struct Dashboarddaten: Codable, Sendable, Hashable {
|
||||
let konten: Kontostaende
|
||||
let fortschritt: Budgetfortschritt
|
||||
let bilanz: EinnahmenAusgaben
|
||||
let letzte: LetzteBuchungen
|
||||
}
|
||||
|
||||
extension CertainClient {
|
||||
func dashboard() async throws -> Dashboarddaten {
|
||||
async let konten = kontostaende()
|
||||
async let fortschritt = budgetfortschritt()
|
||||
async let bilanz = einnahmenAusgaben()
|
||||
async let letzte = letzteBuchungen()
|
||||
return try await Dashboarddaten(konten: konten, fortschritt: fortschritt,
|
||||
bilanz: bilanz, letzte: letzte)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Startseite. Anders als im Web ist die Auswahl der Karten fest – ein
|
||||
/// verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn.
|
||||
struct DashboardAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<Dashboarddaten>?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { daten in
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
BilanzKarte(bilanz: daten.bilanz)
|
||||
FortschrittKarte(fortschritt: daten.fortschritt)
|
||||
KontenKarte(staende: daten.konten)
|
||||
LetzteKarte(letzte: daten.letzte)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.refreshable { await lader.aktualisiere() }
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.dashboard"))
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<Dashboarddaten>(schluessel: "dashboard") {
|
||||
try await client.dashboard()
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Karten
|
||||
|
||||
/// Der Rahmen, den alle Karten teilen.
|
||||
private struct Karte<Inhalt: View>: View {
|
||||
let titel: String
|
||||
var fussnote: String?
|
||||
@ViewBuilder let inhalt: () -> Inhalt
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(titel)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
inhalt()
|
||||
if let fussnote {
|
||||
Text(fussnote)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// Einnahmen, Ausgaben und was übrig bleibt – die Zahl, wegen der man die
|
||||
/// App öffnet.
|
||||
private struct BilanzKarte: View {
|
||||
let bilanz: EinnahmenAusgaben
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.bilanz")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(bilanz.uebrig.formatiert())
|
||||
.font(.largeTitle.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(bilanz.net < 0 ? .red : .primary)
|
||||
.contentTransition(.numericText())
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Beschriftet(titel: String(localized: "dashboard.einnahmen"),
|
||||
wert: bilanz.einnahmen.formatiert(), farbe: .green)
|
||||
Beschriftet(titel: String(localized: "dashboard.ausgaben"),
|
||||
wert: bilanz.ausgaben.formatiert(), farbe: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Beschriftet: View {
|
||||
let titel: String
|
||||
let wert: String
|
||||
let farbe: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(titel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(wert)
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(farbe)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Je Gruppe ein Balken: wie viel vom Budget schon weg ist.
|
||||
private struct FortschrittKarte: View {
|
||||
let fortschritt: Budgetfortschritt
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.budgetfortschritt")) {
|
||||
if fortschritt.rows.isEmpty {
|
||||
Text(String(localized: "dashboard.keinBudget"))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(fortschritt.rows) { zeile in
|
||||
Balken(zeile: zeile, waehrung: fortschritt.currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Balken: View {
|
||||
let zeile: Budgetfortschritt.Zeile
|
||||
let waehrung: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(zeile.name)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(Geldbetrag(cents: zeile.actual, waehrung: waehrung).formatiert())
|
||||
.font(.caption.monospacedDigit())
|
||||
Text("/")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(Geldbetrag(cents: zeile.planned, waehrung: waehrung).formatiert())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ProgressView(value: zeile.anteil)
|
||||
.tint(farbe)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// Rot nur bei echter Überschreitung. Wer mehr zurücklegt als
|
||||
/// geplant, hat nichts überzogen – deshalb fragt `good` dazwischen.
|
||||
private var farbe: Color {
|
||||
if zeile.good { return .green }
|
||||
return zeile.over ? .red : Color(hex: zeile.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kontostände, und was davon wirklich verfügbar ist.
|
||||
private struct KontenKarte: View {
|
||||
let staende: Kontostaende
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.kontostaende")) {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(staende.accounts.filter { !$0.archived }) { konto in
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 28)
|
||||
Text(konto.name)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
if konto.locked {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(String(localized: "konten.gebunden"))
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(konto.saldo.formatiert())
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text(String(localized: "konten.verfuegbar"))
|
||||
.font(.callout.weight(.medium))
|
||||
Spacer()
|
||||
Text(staende.summe.formatiert())
|
||||
.font(.callout.weight(.medium).monospacedDigit())
|
||||
}
|
||||
if staende.gebunden != 0 {
|
||||
HStack {
|
||||
Text(String(localized: "konten.gebundenSumme"))
|
||||
Spacer()
|
||||
Text(staende.gebundenBetrag.formatiert())
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zuletzt passiert ist. Die Überschriften kommen fertig formuliert vom
|
||||
/// Server – bei einem Übertrag steht dort „Konto A → Konto B".
|
||||
private struct LetzteKarte: View {
|
||||
let letzte: LetzteBuchungen
|
||||
|
||||
var body: some View {
|
||||
Karte(titel: String(localized: "dashboard.letzteBuchungen")) {
|
||||
if letzte.transactions.isEmpty {
|
||||
Text(String(localized: "buchungen.leerErklaerung"))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(letzte.transactions) { zeile in
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: zeile.icon, farbe: zeile.color, groesse: 30)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(zeile.title)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
Text(zeile.date.formatiert())
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(zeile.betrag.formatiert(vorzeichen: .ohne))
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(farbe(zeile.vorzeichen))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func farbe(_ vorzeichen: LetzteBuchungen.Zeile.Vorzeichen) -> Color {
|
||||
switch vorzeichen {
|
||||
case .plus: return .green
|
||||
case .minus: return .primary
|
||||
case .keines: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
/// `SubscriptionListOut` – die Vertragsliste samt Kopfzahlen.
|
||||
struct Aboliste: Codable, Sendable, Hashable {
|
||||
let subscriptions: [Abo]
|
||||
let summary: Abosummen
|
||||
}
|
||||
|
||||
/// `SubscriptionOut` aus `backend/app/schemas.py`.
|
||||
struct Abo: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
/// Der Betrag je Zahlung, in der Währung des Vertrags.
|
||||
let amountCents: Int
|
||||
let currency: String
|
||||
/// 1 = monatlich, 12 = jährlich, 3 = vierteljährlich …
|
||||
let intervalMonths: Int
|
||||
let accountId: Int?
|
||||
let categoryId: Int?
|
||||
let nextDue: Tagesdatum?
|
||||
/// Woran die Erkennung den Vertrag in einer Buchung wiedererkennt.
|
||||
let payeePattern: String?
|
||||
let savingsAccountId: Int?
|
||||
let isActive: Bool
|
||||
let cancelledAt: Tagesdatum?
|
||||
let notes: String?
|
||||
let position: Int
|
||||
/// Auf einen Monat umgelegt, in der Vertragswährung – vom Server gerechnet.
|
||||
let monatlichCents: Int
|
||||
/// Dasselbe in der Hauptwährung. Fehlt der Kurs, fällt es still auf den
|
||||
/// nativen Wert zurück; welche Währungen betroffen sind, sagt
|
||||
/// `Abosummen.ohneKurs`.
|
||||
let monatlichBaseCents: Int
|
||||
let tageBisFaellig: Int?
|
||||
let hasLogo: Bool
|
||||
let logoVersion: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, currency, notes, position
|
||||
case amountCents = "amount_cents"
|
||||
case intervalMonths = "interval_months"
|
||||
case accountId = "account_id"
|
||||
case categoryId = "category_id"
|
||||
case nextDue = "next_due"
|
||||
case payeePattern = "payee_pattern"
|
||||
case savingsAccountId = "savings_account_id"
|
||||
case isActive = "is_active"
|
||||
case cancelledAt = "cancelled_at"
|
||||
case monatlichCents = "monatlich_cents"
|
||||
case monatlichBaseCents = "monatlich_base_cents"
|
||||
case tageBisFaellig = "tage_bis_faellig"
|
||||
case hasLogo = "has_logo"
|
||||
case logoVersion = "logo_version"
|
||||
}
|
||||
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||
|
||||
/// „monatlich", „jährlich", „alle 3 Monate".
|
||||
var rhythmus: String {
|
||||
switch intervalMonths {
|
||||
case 1: return String(localized: "abo.monatlich")
|
||||
case 3: return String(localized: "abo.vierteljaehrlich")
|
||||
case 6: return String(localized: "abo.halbjaehrlich")
|
||||
case 12: return String(localized: "abo.jaehrlich")
|
||||
default: return String(format: String(localized: "abo.alleNMonate"), intervalMonths)
|
||||
}
|
||||
}
|
||||
|
||||
/// Steht die nächste Zahlung in der nächsten Woche an?
|
||||
var istBaldFaellig: Bool {
|
||||
guard isActive, let tage = tageBisFaellig else { return false }
|
||||
return tage <= 7
|
||||
}
|
||||
}
|
||||
|
||||
/// Was am Kopf der Liste steht – die Zahl, wegen der man sie öffnet.
|
||||
struct Abosummen: Codable, Sendable, Hashable {
|
||||
let currency: String
|
||||
let monatlichCents: Int
|
||||
let jaehrlichCents: Int
|
||||
let anzahlAktiv: Int
|
||||
/// Währungen, die sich mangels Kurs nicht umrechnen liessen. Lieber
|
||||
/// nennen als stillschweigend zu klein summieren.
|
||||
let ohneKurs: [String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case currency
|
||||
case monatlichCents = "monatlich_cents"
|
||||
case jaehrlichCents = "jaehrlich_cents"
|
||||
case anzahlAktiv = "anzahl_aktiv"
|
||||
case ohneKurs = "ohne_kurs"
|
||||
}
|
||||
|
||||
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||
var jaehrlich: Geldbetrag { Geldbetrag(cents: jaehrlichCents, waehrung: currency) }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
|
||||
/// `TransactionOut` aus `backend/app/schemas.py`.
|
||||
///
|
||||
/// Drei Beträge an jeder Buchung, und keiner davon wird hier gerechnet:
|
||||
/// `amountCents` steht in der Kontowährung, `amountEurCents` ist der beim
|
||||
/// Speichern eingefrorene EUR-Wert, `amountBaseCents` rechnet der Server bei
|
||||
/// jedem Lesen in die Hauptwährung um. Die App formatiert nur.
|
||||
struct Buchung: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let type: Buchungsart
|
||||
let date: Tagesdatum
|
||||
/// `"14:18:15"` oder nichts. Nur zur Anzeige – gerechnet wird mit dem Tag.
|
||||
let time: String?
|
||||
let accountId: Int
|
||||
let toAccountId: Int?
|
||||
let accountCurrency: String
|
||||
let amountCents: Int
|
||||
let toAmountCents: Int?
|
||||
let fxRate: Double?
|
||||
let amountEurCents: Int
|
||||
let amountBaseCents: Int
|
||||
let categoryId: Int?
|
||||
let budgetTreatment: Budgetwirkung?
|
||||
let budgetCategoryId: Int?
|
||||
/// Der Vertrag, zu dem die Buchung gehört – von der Erkennung gesetzt.
|
||||
let subscriptionId: Int?
|
||||
let budgetPeriod: String
|
||||
let budgetPeriodManuell: Bool
|
||||
let importBatchId: Int?
|
||||
/// bank, mail, shortcut, transfer, csv, manuell.
|
||||
let herkunft: String
|
||||
let payee: String?
|
||||
let description: String?
|
||||
let notes: String?
|
||||
let tagIds: [Int]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, type, date, time, herkunft, payee, description, notes
|
||||
case accountId = "account_id"
|
||||
case toAccountId = "to_account_id"
|
||||
case accountCurrency = "account_currency"
|
||||
case amountCents = "amount_cents"
|
||||
case toAmountCents = "to_amount_cents"
|
||||
case fxRate = "fx_rate"
|
||||
case amountEurCents = "amount_eur_cents"
|
||||
case amountBaseCents = "amount_base_cents"
|
||||
case categoryId = "category_id"
|
||||
case budgetTreatment = "budget_treatment"
|
||||
case budgetCategoryId = "budget_category_id"
|
||||
case subscriptionId = "subscription_id"
|
||||
case budgetPeriod = "budget_period"
|
||||
case budgetPeriodManuell = "budget_period_manuell"
|
||||
case importBatchId = "import_batch_id"
|
||||
case tagIds = "tag_ids"
|
||||
}
|
||||
|
||||
/// In der Kontowährung.
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: accountCurrency) }
|
||||
|
||||
/// Was in der Liste als Überschrift steht. Ohne Empfänger nimmt der
|
||||
/// Verwendungszweck seinen Platz ein – eine leere Zeile hilft niemandem.
|
||||
var ueberschrift: String {
|
||||
for text in [payee, description] where !(text ?? "").isEmpty {
|
||||
return text!
|
||||
}
|
||||
return String(localized: "buchung.ohneBezeichnung")
|
||||
}
|
||||
|
||||
/// Die Zeile darunter: Verwendungszweck, falls er nicht schon oben steht.
|
||||
var unterschrift: String? {
|
||||
guard let beschreibung = description, !beschreibung.isEmpty,
|
||||
beschreibung != ueberschrift else { return nil }
|
||||
return beschreibung
|
||||
}
|
||||
|
||||
/// Ob diese Buchung Geld aus dem Haushalt herausbewegt. Überträge nicht –
|
||||
/// sie verschieben nur.
|
||||
var istAusgabe: Bool { type == .expense }
|
||||
}
|
||||
|
||||
/// `TxnType`
|
||||
enum Buchungsart: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||
case expense, income, transfer
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .expense: return String(localized: "buchungsart.ausgabe")
|
||||
case .income: return String(localized: "buchungsart.einnahme")
|
||||
case .transfer: return String(localized: "buchungsart.uebertrag")
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .expense: return "arrow.up.right"
|
||||
case .income: return "arrow.down.left"
|
||||
case .transfer: return "arrow.left.arrow.right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BudgetTreatment` – ob eine Buchung im Budget zählt oder bloss durchläuft.
|
||||
enum Budgetwirkung: String, Codable, Sendable {
|
||||
case budgeted, neutral
|
||||
}
|
||||
|
||||
/// Ein Eintrag aus `GET /api/transactions/links`: die Gegenseite einer
|
||||
/// Buchung, die mit einer zweiten zu einem Übertrag verknüpft ist.
|
||||
///
|
||||
/// Das Gegenkonto steht mit drin, weil die andere Buchung oft gar nicht in
|
||||
/// der gerade gefilterten Liste steht – und „Übertrag" ohne Angabe wohin
|
||||
/// sagt nichts.
|
||||
struct Uebertragsverknuepfung: Codable, Sendable, Hashable {
|
||||
let linkId: Int
|
||||
let otherId: Int
|
||||
/// `"raus"`: Von dieser Buchung geht das Geld weg. `"rein"`: hier kommt es an.
|
||||
let richtung: String
|
||||
let otherAccountId: Int?
|
||||
let automatisch: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case richtung, automatisch
|
||||
case linkId = "link_id"
|
||||
case otherId = "other_id"
|
||||
case otherAccountId = "other_account_id"
|
||||
}
|
||||
|
||||
var gehtRaus: Bool { richtung == "raus" }
|
||||
}
|
||||
|
||||
/// Eine Zeile der Buchungsliste: entweder eine einzelne Buchung oder zwei,
|
||||
/// die zusammen einen Übertrag ergeben.
|
||||
///
|
||||
/// Das Backend kennt zwei Mechanismen nebeneinander: einen Übertrag am Stück
|
||||
/// (eine Buchung mit `toAccountId`) und die Verknüpfung zweier getrennter
|
||||
/// Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen,
|
||||
/// hiesse dasselbe Geld zweimal zeigen. Das Zusammenfassen macht der Client –
|
||||
/// das Backend liefert nur die Paarliste.
|
||||
struct Buchungszeile: Identifiable, Hashable, Sendable {
|
||||
let buchung: Buchung
|
||||
/// Das zweite Bein, wenn es in derselben Liste steht.
|
||||
let gegenstueck: Buchung?
|
||||
/// Die Verknüpfung selbst – auch dann da, wenn das zweite Bein fehlt.
|
||||
let verknuepfung: Uebertragsverknuepfung?
|
||||
|
||||
var id: Int { buchung.id }
|
||||
|
||||
var istUebertrag: Bool { buchung.type == .transfer || verknuepfung != nil }
|
||||
|
||||
/// Von welchem Konto das Geld weggeht.
|
||||
var vonKonto: Int {
|
||||
guard let verknuepfung else { return buchung.accountId }
|
||||
if verknuepfung.gehtRaus { return buchung.accountId }
|
||||
return gegenstueck?.accountId ?? verknuepfung.otherAccountId ?? buchung.accountId
|
||||
}
|
||||
|
||||
/// Auf welchem es ankommt – `nil`, wenn es sich nicht sagen lässt.
|
||||
var nachKonto: Int? {
|
||||
guard let verknuepfung else { return buchung.toAccountId }
|
||||
if verknuepfung.gehtRaus { return gegenstueck?.accountId ?? verknuepfung.otherAccountId }
|
||||
return buchung.accountId
|
||||
}
|
||||
|
||||
/// Baut aus einer Buchungsliste und der Paarliste die Anzeigezeilen.
|
||||
///
|
||||
/// Zusammengefasst wird nur, wenn **beide** Beine in dieser Liste stehen.
|
||||
/// Fehlt eines, bleibt die Zeile für sich – sie trägt dann aber die
|
||||
/// Verknüpfung, damit sie „Übertrag nach …" sagen kann statt gar nichts.
|
||||
/// Ohne diese Vorsicht liesse ein Kontofilter Zeilen verschwinden, die
|
||||
/// sehr wohl zu diesem Konto gehören.
|
||||
static func zeilen(aus buchungen: [Buchung],
|
||||
paare: [String: Uebertragsverknuepfung]) -> [Buchungszeile] {
|
||||
let nachId = Dictionary(buchungen.map { ($0.id, $0) }, uniquingKeysWith: { erstes, _ in erstes })
|
||||
var erledigt = Set<Int>()
|
||||
var ergebnis: [Buchungszeile] = []
|
||||
|
||||
for buchung in buchungen where !erledigt.contains(buchung.id) {
|
||||
let verknuepfung = paare[String(buchung.id)]
|
||||
let anderes = verknuepfung.flatMap { nachId[$0.otherId] }
|
||||
if let anderes {
|
||||
erledigt.insert(anderes.id)
|
||||
}
|
||||
erledigt.insert(buchung.id)
|
||||
ergebnis.append(Buchungszeile(buchung: buchung, gegenstueck: anderes,
|
||||
verknuepfung: verknuepfung))
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// Die Monatsansicht aus `GET /api/budget/{label}` beziehungsweise
|
||||
/// `/budget/current` (`_build_view` in `backend/app/routers/budgets.py`).
|
||||
///
|
||||
/// Alle Beträge stehen schon in der Hauptwährung. Der Client rechnet nur,
|
||||
/// was es in der API nicht gibt: Prozente und „Rest".
|
||||
struct Budgetmonat: Codable, Sendable, Hashable {
|
||||
/// `"2026-10"`.
|
||||
let label: String
|
||||
let start: Tagesdatum
|
||||
let end: Tagesdatum
|
||||
/// Ob dieser Monat von der Vorlage abweicht.
|
||||
let customized: Bool
|
||||
let baseCurrency: String
|
||||
let prev: String
|
||||
let next: String
|
||||
let groups: [Budgetgruppe]
|
||||
let income: [GeplanteEinnahme]
|
||||
let vertraege: Vertragsreste
|
||||
let totals: Budgetsummen
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case label, start, end, customized, prev, next, groups, income, vertraege, totals
|
||||
case baseCurrency = "base_currency"
|
||||
}
|
||||
|
||||
/// Was vom geplanten Ausgabenbudget noch übrig ist. Kann negativ werden.
|
||||
var restCents: Int { totals.plannedExpense - totals.actualExpense }
|
||||
|
||||
/// Anteil des Verbrauchten, zwischen 0 und 1 gekappt – für einen Balken.
|
||||
/// Ohne Plan gibt es keinen Anteil, nicht etwa einen vollen Balken.
|
||||
var anteilVerbraucht: Double {
|
||||
guard totals.plannedExpense > 0 else { return 0 }
|
||||
return min(1, max(0, Double(totals.actualExpense) / Double(totals.plannedExpense)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Kategoriegruppe im Budget.
|
||||
struct Budgetgruppe: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let plannedCents: Int
|
||||
let actualCents: Int
|
||||
/// Überzogen. Sparen ist davon ausgenommen – siehe `good`.
|
||||
let over: Bool
|
||||
/// Alle Kategorien darin sind „Ausgaben, die kein Verlust sind" (Sparen).
|
||||
/// Dann ist Mehr-als-geplant keine Warnung.
|
||||
let good: Bool
|
||||
let ausVertraegenCents: Int
|
||||
let categories: [Budgetkategorie]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, over, good, categories
|
||||
case plannedCents = "planned_cents"
|
||||
case actualCents = "actual_cents"
|
||||
case ausVertraegenCents = "aus_vertraegen_cents"
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile im Budget.
|
||||
struct Budgetkategorie: Codable, Sendable, Identifiable, Hashable {
|
||||
let categoryId: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let plannedCents: Int
|
||||
let actualCents: Int
|
||||
let deltaCents: Int
|
||||
let over: Bool
|
||||
/// `"fest"` oder `"berechnet"`. Bei „berechnet" ergibt sich der Soll-Wert
|
||||
/// aus den Verträgen plus Zuschlag.
|
||||
let modus: String
|
||||
let zuschlagCents: Int
|
||||
/// Was die Verträge dieser Kategorie im Monat kosten. Steht auch bei
|
||||
/// „fest" da – dann als Hinweis, nicht als Summand.
|
||||
let ausVertraegenCents: Int
|
||||
let vertraegeAnzahl: Int
|
||||
let good: Bool
|
||||
|
||||
var id: Int { categoryId }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color, over, modus, good
|
||||
case categoryId = "category_id"
|
||||
case plannedCents = "planned_cents"
|
||||
case actualCents = "actual_cents"
|
||||
case deltaCents = "delta_cents"
|
||||
case zuschlagCents = "zuschlag_cents"
|
||||
case ausVertraegenCents = "aus_vertraegen_cents"
|
||||
case vertraegeAnzahl = "vertraege_anzahl"
|
||||
}
|
||||
|
||||
var istBerechnet: Bool { modus == "berechnet" }
|
||||
|
||||
var anteilVerbraucht: Double {
|
||||
guard plannedCents > 0 else { return 0 }
|
||||
return min(1, max(0, Double(actualCents) / Double(plannedCents)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine geplante Einnahme – „Gehalt", „Kindergeld".
|
||||
struct GeplanteEinnahme: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let label: String
|
||||
let categoryId: Int?
|
||||
let targetAccountId: Int?
|
||||
let plannedCents: Int
|
||||
let receivedCents: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, label
|
||||
case categoryId = "category_id"
|
||||
case targetAccountId = "target_account_id"
|
||||
case plannedCents = "planned_cents"
|
||||
case receivedCents = "received_cents"
|
||||
}
|
||||
}
|
||||
|
||||
/// Verträge, die sich nicht zuordnen liessen.
|
||||
///
|
||||
/// Ohne diese Angaben fehlt ihr Geld im Budget, ohne dass irgendwo steht,
|
||||
/// dass es fehlt. `ohneKurs` nennt die Währungen, für die kein Wechselkurs
|
||||
/// vorlag – deren Beträge fehlen in der Summe.
|
||||
struct Vertragsreste: Codable, Sendable, Hashable {
|
||||
let ohneKategorieCents: Int
|
||||
let ohneKategorieAnzahl: Int
|
||||
let ohneKurs: [String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ohneKategorieCents = "ohne_kategorie_cents"
|
||||
case ohneKategorieAnzahl = "ohne_kategorie_anzahl"
|
||||
case ohneKurs = "ohne_kurs"
|
||||
}
|
||||
|
||||
var istLeer: Bool { ohneKategorieAnzahl == 0 && ohneKurs.isEmpty }
|
||||
}
|
||||
|
||||
struct Budgetsummen: Codable, Sendable, Hashable {
|
||||
let plannedExpense: Int
|
||||
let actualExpense: Int
|
||||
let plannedIncome: Int
|
||||
let receivedIncome: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case plannedExpense = "planned_expense"
|
||||
case actualExpense = "actual_expense"
|
||||
case plannedIncome = "planned_income"
|
||||
case receivedIncome = "received_income"
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/budget/template` – das Standard-Budget hinter den Monaten.
|
||||
struct Budgetvorlage: Codable, Sendable, Hashable {
|
||||
let lines: [Vorlagenzeile]
|
||||
let income: [GeplanteEinnahme]
|
||||
}
|
||||
|
||||
struct Vorlagenzeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let categoryId: Int
|
||||
let plannedCents: Int
|
||||
let modus: String
|
||||
let zuschlagCents: Int
|
||||
|
||||
var id: Int { categoryId }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case modus
|
||||
case categoryId = "category_id"
|
||||
case plannedCents = "planned_cents"
|
||||
case zuschlagCents = "zuschlag_cents"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
// Die Karten des Dashboards. Anders als im Web ist die Auswahl hier fest –
|
||||
// ein verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn. Die
|
||||
// vier Endpunkte sind dieselben, die das Web benutzt.
|
||||
|
||||
/// `GET /api/dashboard/balances`
|
||||
struct Kontostaende: Codable, Sendable, Hashable {
|
||||
let accounts: [Kontozeile]
|
||||
/// Verfügbar, ohne gebundene und archivierte Konten.
|
||||
let total: Int
|
||||
/// Was auf gebundenen Konten liegt – Geld, an das man nicht herankommt.
|
||||
let gebunden: Int
|
||||
let currency: String
|
||||
|
||||
var summe: Geldbetrag { Geldbetrag(cents: total, waehrung: currency) }
|
||||
var gebundenBetrag: Geldbetrag { Geldbetrag(cents: gebunden, waehrung: currency) }
|
||||
|
||||
struct Kontozeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let currency: String
|
||||
let balanceCents: Int
|
||||
let balanceBaseCents: Int
|
||||
let archived: Bool
|
||||
let locked: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, currency, archived, locked
|
||||
case balanceCents = "balance_cents"
|
||||
case balanceBaseCents = "balance_base_cents"
|
||||
}
|
||||
|
||||
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/budget-progress`
|
||||
struct Budgetfortschritt: Codable, Sendable, Hashable {
|
||||
let rows: [Zeile]
|
||||
let currency: String
|
||||
let label: String
|
||||
let groupBy: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rows, currency, label
|
||||
case groupBy = "group_by"
|
||||
}
|
||||
|
||||
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let color: String
|
||||
let planned: Int
|
||||
let actual: Int
|
||||
let over: Bool
|
||||
/// Sparen: mehr als geplant ist hier keine Warnung.
|
||||
let good: Bool
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var anteil: Double {
|
||||
guard planned > 0 else { return 0 }
|
||||
return min(1, max(0, Double(actual) / Double(planned)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/income-expense`
|
||||
struct EinnahmenAusgaben: Codable, Sendable, Hashable {
|
||||
let income: Int
|
||||
let expense: Int
|
||||
let net: Int
|
||||
let currency: String
|
||||
let label: String
|
||||
|
||||
var einnahmen: Geldbetrag { Geldbetrag(cents: income, waehrung: currency) }
|
||||
var ausgaben: Geldbetrag { Geldbetrag(cents: expense, waehrung: currency) }
|
||||
var uebrig: Geldbetrag { Geldbetrag(cents: net, waehrung: currency) }
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/recent`
|
||||
struct LetzteBuchungen: Codable, Sendable, Hashable {
|
||||
let transactions: [Zeile]
|
||||
let currency: String
|
||||
|
||||
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let date: Tagesdatum
|
||||
let type: Buchungsart
|
||||
/// Schon fertig formuliert: die Kategorie, sonst der Empfänger, bei
|
||||
/// einem Übertrag „Konto A → Konto B".
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: String
|
||||
/// `"pos"`, `"neg"` oder nichts – bei einem Übertrag gibt es keine
|
||||
/// Richtung, weil kein Geld den Haushalt verlässt.
|
||||
let sign: String?
|
||||
let amountCents: Int
|
||||
let currency: String
|
||||
let amountBaseCents: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, date, type, title, icon, color, sign, currency
|
||||
case amountCents = "amount_cents"
|
||||
case amountBaseCents = "amount_base_cents"
|
||||
}
|
||||
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||
|
||||
/// Grün bei Einnahme, rot bei Ausgabe, neutral beim Übertrag.
|
||||
var vorzeichen: Vorzeichen {
|
||||
switch sign {
|
||||
case "pos": return .plus
|
||||
case "neg": return .minus
|
||||
default: return .keines
|
||||
}
|
||||
}
|
||||
|
||||
enum Vorzeichen: Sendable { case plus, minus, keines }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
|
||||
/// `CategoryGroupOut` – Kategorien stehen immer in einer Gruppe.
|
||||
struct Kategoriegruppe: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let kind: Kategorieart
|
||||
let icon: String
|
||||
let color: String
|
||||
let position: Int
|
||||
let isBuiltin: Bool
|
||||
let categories: [Kategorie]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, kind, icon, color, position, categories
|
||||
case isBuiltin = "is_builtin"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryOut`
|
||||
struct Kategorie: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let groupId: Int
|
||||
let name: String
|
||||
let type: Kategorieart
|
||||
let icon: String
|
||||
let color: String
|
||||
let position: Int
|
||||
let isBuiltin: Bool
|
||||
let isArchived: Bool
|
||||
/// Zählt in keiner Auswertung mit – etwa „Durchlaufende Posten".
|
||||
let excludedFromStats: Bool
|
||||
/// Eine Ausgabe, die kein Verlust ist: Sparen. Mehr als geplant ist hier
|
||||
/// keine Warnung.
|
||||
let spendingIsGood: Bool
|
||||
let keywordCount: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, type, icon, color, position
|
||||
case groupId = "group_id"
|
||||
case isBuiltin = "is_builtin"
|
||||
case isArchived = "is_archived"
|
||||
case excludedFromStats = "excluded_from_stats"
|
||||
case spendingIsGood = "spending_is_good"
|
||||
case keywordCount = "keyword_count"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryKind`
|
||||
enum Kategorieart: String, Codable, Sendable {
|
||||
case income, expense
|
||||
}
|
||||
|
||||
/// `TagOut` – ein frei vergebenes Etikett, quer zur Kategorie.
|
||||
struct Schlagwort: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let color: String
|
||||
}
|
||||
|
||||
/// Kategorien und Tags, wie die Ansichten sie brauchen: einmal geladen,
|
||||
/// danach als Nachschlagewerk.
|
||||
///
|
||||
/// Fast jede Liste will zu einer ID den Namen, das Symbol und die Farbe.
|
||||
/// Das je Zeile zu holen wäre genau das N+1, das das Backend sich selbst
|
||||
/// verbietet.
|
||||
struct Nachschlag: Codable, Sendable {
|
||||
let gruppen: [Kategoriegruppe]
|
||||
let schlagwoerter: [Schlagwort]
|
||||
let konten: [Konto]
|
||||
|
||||
private let kategorienNachId: [Int: Kategorie]
|
||||
private let schlagwortNachId: [Int: Schlagwort]
|
||||
private let kontoNachId: [Int: Konto]
|
||||
|
||||
init(gruppen: [Kategoriegruppe], schlagwoerter: [Schlagwort], konten: [Konto]) {
|
||||
self.gruppen = gruppen
|
||||
self.schlagwoerter = schlagwoerter
|
||||
self.konten = konten
|
||||
let alle = gruppen.flatMap(\.categories)
|
||||
self.kategorienNachId = Dictionary(alle.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
self.schlagwortNachId = Dictionary(schlagwoerter.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
self.kontoNachId = Dictionary(konten.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
}
|
||||
|
||||
static let leer = Nachschlag(gruppen: [], schlagwoerter: [], konten: [])
|
||||
|
||||
/// Gespeichert werden nur die drei Listen; die Nachschlagetabellen baut
|
||||
/// der Konstruktor daraus neu. Sie mitzuschreiben hiesse, dieselben Daten
|
||||
/// zweimal auf der Platte zu halten.
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case gruppen, schlagwoerter, konten
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let behaelter = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(gruppen: try behaelter.decode([Kategoriegruppe].self, forKey: .gruppen),
|
||||
schlagwoerter: try behaelter.decode([Schlagwort].self, forKey: .schlagwoerter),
|
||||
konten: try behaelter.decode([Konto].self, forKey: .konten))
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var behaelter = encoder.container(keyedBy: CodingKeys.self)
|
||||
try behaelter.encode(gruppen, forKey: .gruppen)
|
||||
try behaelter.encode(schlagwoerter, forKey: .schlagwoerter)
|
||||
try behaelter.encode(konten, forKey: .konten)
|
||||
}
|
||||
|
||||
/// Alle Ausgabekategorien, flach – für Auswahllisten.
|
||||
var ausgabekategorien: [Kategorie] {
|
||||
gruppen.filter { $0.kind == .expense }.flatMap(\.categories).filter { !$0.isArchived }
|
||||
}
|
||||
|
||||
func kategorie(_ id: Int?) -> Kategorie? { id.flatMap { kategorienNachId[$0] } }
|
||||
func schlagwort(_ id: Int) -> Schlagwort? { schlagwortNachId[id] }
|
||||
func konto(_ id: Int?) -> Konto? { id.flatMap { kontoNachId[$0] } }
|
||||
|
||||
func kontoname(_ id: Int?) -> String {
|
||||
konto(id)?.name ?? String(localized: "allgemein.unbekannt")
|
||||
}
|
||||
}
|
||||
@@ -95,4 +95,99 @@ extension CertainClient {
|
||||
func konto(_ id: Int) async throws -> Konto {
|
||||
try await hole("accounts/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Kategorien und Schlagwörter
|
||||
|
||||
func kategoriegruppen() async throws -> [Kategoriegruppe] {
|
||||
try await hole("categories")
|
||||
}
|
||||
|
||||
func schlagwoerter() async throws -> [Schlagwort] {
|
||||
try await hole("tags")
|
||||
}
|
||||
|
||||
/// Das Nachschlagewerk, das fast jede Liste braucht: Kategorien, Tags und
|
||||
/// Konten in einem Rutsch. Drei Abrufe nebeneinander statt nacheinander –
|
||||
/// sie wissen nichts voneinander.
|
||||
func nachschlag() async throws -> Nachschlag {
|
||||
async let gruppen = kategoriegruppen()
|
||||
async let tags = schlagwoerter()
|
||||
async let alleKonten = konten()
|
||||
return try await Nachschlag(gruppen: gruppen, schlagwoerter: tags, konten: alleKonten)
|
||||
}
|
||||
|
||||
// MARK: - Buchungen
|
||||
|
||||
/// - Parameter suche: Volltext über Empfänger, Verwendungszweck und Notiz.
|
||||
/// Seit der Backend-Ergänzung siebt der Server, nicht das Telefon.
|
||||
func buchungen(konto: Int? = nil, kategorie: Int? = nil, art: Buchungsart? = nil,
|
||||
von: Tagesdatum? = nil, bis: Tagesdatum? = nil,
|
||||
suche: String? = nil, schlagwort: Int? = nil, abo: Int? = nil,
|
||||
ohneKategorie: Bool = false, grenze: Int = 200) async throws -> [Buchung] {
|
||||
var abfrage: [URLQueryItem] = [URLQueryItem(name: "limit", value: String(grenze))]
|
||||
func dazu(_ name: String, _ wert: String?) {
|
||||
guard let wert, !wert.isEmpty else { return }
|
||||
abfrage.append(URLQueryItem(name: name, value: wert))
|
||||
}
|
||||
dazu("account_id", konto.map(String.init))
|
||||
dazu("category_id", kategorie.map(String.init))
|
||||
dazu("type", art?.rawValue)
|
||||
dazu("date_from", von?.text)
|
||||
dazu("date_to", bis?.text)
|
||||
dazu("q", suche)
|
||||
dazu("tag_id", schlagwort.map(String.init))
|
||||
dazu("subscription_id", abo.map(String.init))
|
||||
if ohneKategorie { dazu("uncategorized", "true") }
|
||||
return try await hole("transactions", abfrage: abfrage)
|
||||
}
|
||||
|
||||
func buchung(_ id: Int) async throws -> Buchung {
|
||||
try await hole("transactions/\(id)")
|
||||
}
|
||||
|
||||
/// Je Buchungs-ID die Gegenseite, wenn zwei Buchungen zu einem Übertrag
|
||||
/// verknüpft sind. Der Schlüssel ist die ID als Zeichenkette – JSON kennt
|
||||
/// keine Zahlen als Schlüssel.
|
||||
func uebertragsverknuepfungen() async throws -> [String: Uebertragsverknuepfung] {
|
||||
try await hole("transactions/links")
|
||||
}
|
||||
|
||||
// MARK: - Budget
|
||||
|
||||
func budget(_ label: String? = nil) async throws -> Budgetmonat {
|
||||
try await hole(label.map { "budget/\($0)" } ?? "budget/current")
|
||||
}
|
||||
|
||||
func budgetvorlage() async throws -> Budgetvorlage {
|
||||
try await hole("budget/template")
|
||||
}
|
||||
|
||||
// MARK: - Verträge
|
||||
|
||||
func abos() async throws -> Aboliste {
|
||||
try await hole("subscriptions")
|
||||
}
|
||||
|
||||
func abo(_ id: Int) async throws -> Abo {
|
||||
try await hole("subscriptions/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
func kontostaende() async throws -> Kontostaende {
|
||||
try await hole("dashboard/balances")
|
||||
}
|
||||
|
||||
func budgetfortschritt() async throws -> Budgetfortschritt {
|
||||
try await hole("dashboard/budget-progress")
|
||||
}
|
||||
|
||||
func einnahmenAusgaben() async throws -> EinnahmenAusgaben {
|
||||
try await hole("dashboard/income-expense")
|
||||
}
|
||||
|
||||
func letzteBuchungen(grenze: Int = 8) async throws -> LetzteBuchungen {
|
||||
try await hole("dashboard/recent",
|
||||
abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,3 +302,279 @@ struct ModellTests {
|
||||
#expect(fremd.istCertain == false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Überträge zusammenfassen
|
||||
|
||||
@Suite("Übertragszeilen")
|
||||
struct UebertragszeilenTests {
|
||||
|
||||
/// Eine Buchung, wie der Server sie schickt – nur die Felder, auf die es
|
||||
/// hier ankommt.
|
||||
private func buchung(_ id: Int, konto: Int, cents: Int,
|
||||
art: Buchungsart = .expense) throws -> Buchung {
|
||||
let roh = """
|
||||
{"id": \(id), "type": "\(art.rawValue)", "date": "2026-10-15", "time": null,
|
||||
"account_id": \(konto), "to_account_id": null, "account_currency": "EUR",
|
||||
"amount_cents": \(cents), "to_amount_cents": null, "fx_rate": null,
|
||||
"amount_eur_cents": \(cents), "amount_base_cents": \(cents),
|
||||
"category_id": null, "budget_treatment": null, "budget_category_id": null,
|
||||
"subscription_id": null, "budget_period": "2026-10",
|
||||
"budget_period_manuell": false, "import_batch_id": null,
|
||||
"herkunft": "bank", "payee": "Test", "description": null, "notes": null,
|
||||
"tag_ids": []}
|
||||
"""
|
||||
return try JSONDecoder().decode(Buchung.self, from: Data(roh.utf8))
|
||||
}
|
||||
|
||||
private func verknuepfung(_ anderes: Int, raus: Bool, gegenkonto: Int?) -> Uebertragsverknuepfung {
|
||||
Uebertragsverknuepfung(linkId: 1, otherId: anderes,
|
||||
richtung: raus ? "raus" : "rein",
|
||||
otherAccountId: gegenkonto, automatisch: true)
|
||||
}
|
||||
|
||||
@Test("Zwei verknüpfte Buchungen ergeben eine Zeile, nicht zwei")
|
||||
func paarWirdZusammengefasst() throws {
|
||||
let raus = try buchung(1, konto: 10, cents: -5000)
|
||||
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [raus, rein], paare: [
|
||||
"1": verknuepfung(2, raus: true, gegenkonto: 20),
|
||||
"2": verknuepfung(1, raus: false, gegenkonto: 10),
|
||||
])
|
||||
|
||||
#expect(zeilen.count == 1)
|
||||
#expect(zeilen[0].istUebertrag)
|
||||
#expect(zeilen[0].vonKonto == 10)
|
||||
#expect(zeilen[0].nachKonto == 20)
|
||||
}
|
||||
|
||||
@Test("Fehlt das zweite Bein, verschwindet die Zeile nicht")
|
||||
func einzelnesBeinBleibtStehen() throws {
|
||||
// Genau der Fall eines Kontofilters: Die Gegenbuchung liegt auf einem
|
||||
// Konto, das gerade nicht gezeigt wird.
|
||||
let raus = try buchung(1, konto: 10, cents: -5000)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [raus], paare: [
|
||||
"1": verknuepfung(2, raus: true, gegenkonto: 20),
|
||||
])
|
||||
|
||||
#expect(zeilen.count == 1)
|
||||
#expect(zeilen[0].istUebertrag)
|
||||
// Das Gegenkonto steht in der Verknüpfung, auch ohne die Buchung.
|
||||
#expect(zeilen[0].nachKonto == 20)
|
||||
}
|
||||
|
||||
@Test("Die Richtung kommt aus der Verknüpfung, nicht aus dem Vorzeichen")
|
||||
func richtungZaehlt() throws {
|
||||
// Manche Banken liefern beide Beine positiv. Wer am Vorzeichen
|
||||
// ablesen wollte, wohin das Geld geht, läge hier falsch herum.
|
||||
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [rein], paare: [
|
||||
"2": verknuepfung(1, raus: false, gegenkonto: 10),
|
||||
])
|
||||
|
||||
#expect(zeilen[0].vonKonto == 10)
|
||||
#expect(zeilen[0].nachKonto == 20)
|
||||
}
|
||||
|
||||
@Test("Ohne Verknüpfung bleibt jede Buchung ihre eigene Zeile")
|
||||
func ohneVerknuepfung() throws {
|
||||
let eins = try buchung(1, konto: 10, cents: -1000)
|
||||
let zwei = try buchung(2, konto: 10, cents: -2000)
|
||||
let zeilen = Buchungszeile.zeilen(aus: [eins, zwei], paare: [:])
|
||||
|
||||
#expect(zeilen.count == 2)
|
||||
#expect(zeilen.allSatisfy { !$0.istUebertrag })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was der Client selbst rechnet
|
||||
|
||||
@Suite("Budget-Anteile")
|
||||
struct BudgetAnteilTests {
|
||||
|
||||
private func kategorie(geplant: Int, ist: Int) throws -> Budgetkategorie {
|
||||
let roh = """
|
||||
{"category_id": 1, "name": "Test", "icon": "home", "color": "#3f51b5",
|
||||
"planned_cents": \(geplant), "actual_cents": \(ist),
|
||||
"delta_cents": \(geplant - ist), "over": \(ist > geplant),
|
||||
"modus": "fest", "zuschlag_cents": 0, "aus_vertraegen_cents": 0,
|
||||
"vertraege_anzahl": 0, "good": false}
|
||||
"""
|
||||
return try JSONDecoder().decode(Budgetkategorie.self, from: Data(roh.utf8))
|
||||
}
|
||||
|
||||
@Test("Ohne Plan gibt es keinen Anteil, keinen vollen Balken")
|
||||
func ohnePlanKeinBalken() throws {
|
||||
#expect(try kategorie(geplant: 0, ist: 0).anteilVerbraucht == 0)
|
||||
// Ausgegeben ohne Plan: Ein voller Balken sähe aus wie "genau
|
||||
// aufgebraucht" und wäre die falsche Auskunft.
|
||||
#expect(try kategorie(geplant: 0, ist: 5000).anteilVerbraucht == 0)
|
||||
}
|
||||
|
||||
@Test("Überzogen wird bei eins gekappt")
|
||||
func gekappt() throws {
|
||||
#expect(try kategorie(geplant: 10000, ist: 25000).anteilVerbraucht == 1)
|
||||
#expect(try kategorie(geplant: 10000, ist: 5000).anteilVerbraucht == 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nachschlagewerk
|
||||
|
||||
@Suite("Nachschlag")
|
||||
struct NachschlagTests {
|
||||
|
||||
private func gruppe() throws -> Kategoriegruppe {
|
||||
let roh = """
|
||||
{"id": 1, "name": "Fixkosten", "kind": "expense", "icon": "landmark",
|
||||
"color": "#3f51b5", "position": 0, "is_builtin": true,
|
||||
"categories": [{"id": 7, "group_id": 1, "name": "Wohnen", "type": "expense",
|
||||
"icon": "home", "color": "#3f51b5", "position": 0, "is_builtin": true,
|
||||
"is_archived": false, "excluded_from_stats": false,
|
||||
"spending_is_good": false, "keyword_count": 0}]}
|
||||
"""
|
||||
return try JSONDecoder().decode(Kategoriegruppe.self, from: Data(roh.utf8))
|
||||
}
|
||||
|
||||
@Test("Eine ID findet ihren Namen")
|
||||
func kategorieNachId() throws {
|
||||
let nachschlag = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
|
||||
#expect(nachschlag.kategorie(7)?.name == "Wohnen")
|
||||
#expect(nachschlag.kategorie(999) == nil)
|
||||
#expect(nachschlag.kategorie(nil) == nil)
|
||||
}
|
||||
|
||||
@Test("Ein unbekanntes Konto ergibt Text, nicht nichts")
|
||||
func unbekanntesKonto() throws {
|
||||
// Eine leere Zeile in der Liste wäre schlimmer als das Wort
|
||||
// "Unbekannt" – sie sähe aus wie ein Fehler der App.
|
||||
#expect(!Nachschlag.leer.kontoname(42).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Der Zwischenspeicher gibt dasselbe Nachschlagewerk zurück")
|
||||
func ueberlebtDenSpeicher() throws {
|
||||
let original = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
|
||||
let roh = try JSONEncoder().encode(original)
|
||||
let zurueck = try JSONDecoder().decode(Nachschlag.self, from: roh)
|
||||
// Die Nachschlagetabellen werden beim Lesen neu gebaut, nicht
|
||||
// mitgeschrieben – genau das prüft dieser Test.
|
||||
#expect(zurueck.kategorie(7)?.name == "Wohnen")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Modelle gegen echte Serverantworten
|
||||
|
||||
/// Nur da, um über `Bundle(for:)` an die Prüfdatei zu kommen. Swift Testing
|
||||
/// kennt keine Testklasse, an der sich das Bündel ablesen liesse.
|
||||
private final class Markierung {}
|
||||
|
||||
/// Entschlüsselt jedes Modell aus einer echten Antwort des Backends.
|
||||
///
|
||||
/// Das ist die Prüfung, die beim blinden Schreiben wirklich etwas fängt: Ein
|
||||
/// vertippter Feldname übersetzt anstandslos und fällt erst auf, wenn eine
|
||||
/// Liste beim Nutzer leer bleibt. `Tests/Fixtures/antworten.json` kommt aus
|
||||
/// `backend/scripts/erzeuge_ios_fixture.py`; nach jeder Schema-Änderung neu
|
||||
/// erzeugen.
|
||||
@Suite("Serverantworten")
|
||||
struct ServerantwortenTests {
|
||||
|
||||
private static let antworten: [String: Data] = {
|
||||
guard let ort = Bundle(for: Markierung.self)
|
||||
.url(forResource: "antworten", withExtension: "json"),
|
||||
let roh = try? Data(contentsOf: ort),
|
||||
let oben = try? JSONSerialization.jsonObject(with: roh) as? [String: Any]
|
||||
else { return [:] }
|
||||
return oben.compactMapValues {
|
||||
try? JSONSerialization.data(withJSONObject: $0, options: .fragmentsAllowed)
|
||||
}
|
||||
}()
|
||||
|
||||
private func lies<A: Decodable>(_ typ: A.Type, _ name: String) throws -> A {
|
||||
let daten = try #require(Self.antworten[name],
|
||||
"Prüfdatei kennt '\(name)' nicht – neu erzeugen?")
|
||||
return try JSONDecoder().decode(A.self, from: daten)
|
||||
}
|
||||
|
||||
@Test("Die Prüfdatei liegt im Testbündel")
|
||||
func dateiIstDa() {
|
||||
// Ohne diesen Test wären alle folgenden still übersprungen, wenn die
|
||||
// Datei nicht ins Ziel kopiert wird.
|
||||
#expect(!Self.antworten.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Konten, Kategorien und Schlagwörter")
|
||||
func stammdaten() throws {
|
||||
let konten = try lies([Konto].self, "konten")
|
||||
#expect(konten.count == 2)
|
||||
#expect(konten.contains { $0.isLocked })
|
||||
|
||||
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
|
||||
#expect(gruppen.contains { !$0.categories.isEmpty })
|
||||
|
||||
#expect(try !lies([Schlagwort].self, "schlagwoerter").isEmpty)
|
||||
}
|
||||
|
||||
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
|
||||
func buchungen() throws {
|
||||
let alle = try lies([Buchung].self, "buchungen")
|
||||
#expect(alle.count == 5)
|
||||
// Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
|
||||
// nur in einem Fall belegt sind.
|
||||
#expect(alle.contains { !$0.tagIds.isEmpty })
|
||||
#expect(alle.contains { $0.type == .transfer && $0.toAccountId != nil })
|
||||
#expect(alle.contains { $0.subscriptionId != nil })
|
||||
#expect(alle.contains { $0.notes?.isEmpty == false })
|
||||
}
|
||||
|
||||
@Test("Die Paarliste fasst zwei getrennte Buchungen zusammen")
|
||||
func verknuepfungen() throws {
|
||||
let paare = try lies([String: Uebertragsverknuepfung].self, "uebertragsverknuepfungen")
|
||||
#expect(paare.count == 2)
|
||||
|
||||
let alle = try lies([Buchung].self, "buchungen")
|
||||
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
|
||||
// Fünf Buchungen, zwei davon ein Paar: vier Zeilen.
|
||||
#expect(zeilen.count == 4)
|
||||
#expect(zeilen.filter(\.istUebertrag).count == 2)
|
||||
}
|
||||
|
||||
@Test("Budget-Monat, Vorlage und Verträge")
|
||||
func budget() throws {
|
||||
let monat = try lies(Budgetmonat.self, "budget")
|
||||
#expect(!monat.groups.isEmpty)
|
||||
#expect(monat.groups.contains { !$0.categories.isEmpty })
|
||||
#expect(monat.totals.plannedExpense > 0)
|
||||
// prev/next tragen die Blätterung – ohne sie steht die Ansicht still.
|
||||
#expect(!monat.prev.isEmpty && !monat.next.isEmpty)
|
||||
|
||||
let vorlage = try lies(Budgetvorlage.self, "budgetvorlage")
|
||||
// Betriebsart und Zuschlag kamen mit Backend-Ergaenzung C dazu.
|
||||
#expect(vorlage.lines.contains { $0.modus == "fest" })
|
||||
|
||||
let abos = try lies(Aboliste.self, "abos")
|
||||
#expect(abos.summary.anzahlAktiv >= 1)
|
||||
#expect(abos.subscriptions.contains { $0.intervalMonths == 12 })
|
||||
}
|
||||
|
||||
@Test("Alle vier Dashboard-Karten")
|
||||
func dashboard() throws {
|
||||
let staende = try lies(Kontostaende.self, "kontostaende")
|
||||
#expect(staende.accounts.count == 2)
|
||||
#expect(staende.gebunden != 0)
|
||||
|
||||
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
|
||||
#expect(try lies(EinnahmenAusgaben.self, "einnahmenAusgaben").income > 0)
|
||||
|
||||
let letzte = try lies(LetzteBuchungen.self, "letzteBuchungen")
|
||||
#expect(!letzte.transactions.isEmpty)
|
||||
// Ein Übertrag hat keine Richtung – sign ist dort null.
|
||||
#expect(letzte.transactions.contains { $0.vorzeichen == .keines })
|
||||
}
|
||||
|
||||
@Test("Benutzer, Einstellungen und Serverkennung")
|
||||
func zugang() throws {
|
||||
#expect(try lies(Serverkennung.self, "version").istCertain)
|
||||
#expect(try !lies(Benutzer.self, "ichSelbst").username.isEmpty)
|
||||
|
||||
let roh = try lies([Einstellung].self, "einstellungen")
|
||||
#expect(!Einstellungen(roh).basiswaehrung.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
"allgemein.nochmal" = "Nochmal versuchen";
|
||||
"allgemein.abbrechen" = "Abbrechen";
|
||||
"allgemein.abmelden" = "Abmelden";
|
||||
"allgemein.unbekannt" = "Unbekannt";
|
||||
|
||||
/* --- Reiter --- */
|
||||
"reiter.dashboard" = "Übersicht";
|
||||
"reiter.budget" = "Budget";
|
||||
"reiter.buchungen" = "Buchungen";
|
||||
"reiter.abos" = "Verträge";
|
||||
"reiter.konten" = "Konten";
|
||||
"reiter.einstellungen" = "Einstellungen";
|
||||
|
||||
@@ -102,3 +107,59 @@
|
||||
"einstellungen.budgetStarttag" = "Budget beginnt am";
|
||||
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
|
||||
"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt.";
|
||||
|
||||
/* --- Dashboard --- */
|
||||
"dashboard.bilanz" = "Dieser Monat";
|
||||
"dashboard.einnahmen" = "Einnahmen";
|
||||
"dashboard.ausgaben" = "Ausgaben";
|
||||
"dashboard.budgetfortschritt" = "Budget";
|
||||
"dashboard.keinBudget" = "Für diesen Monat ist noch nichts geplant.";
|
||||
"dashboard.kontostaende" = "Konten";
|
||||
"dashboard.letzteBuchungen" = "Zuletzt";
|
||||
|
||||
/* --- Budget --- */
|
||||
"budget.einnahmen" = "Geplante Einnahmen";
|
||||
"budget.uebrig" = "übrig";
|
||||
"budget.vonGeplant" = "%1$@ von %2$@ ausgegeben";
|
||||
"budget.vonBetrag" = "von %@";
|
||||
"budget.angepasst" = "angepasst";
|
||||
"budget.vorigerMonat" = "Voriger Monat";
|
||||
"budget.naechsterMonat" = "Nächster Monat";
|
||||
"budget.ausVertraegenHinweis" = "%1$d Verträge: %2$@";
|
||||
"budget.ausVertraegenBerechnet" = "Berechnet aus %1$d Verträgen: %2$@";
|
||||
"budget.vertraegeOhneKategorie" = "%1$d Verträge ohne Kategorie (%2$@) fehlen in diesen Summen.";
|
||||
"budget.ohneKurs" = "Kein Wechselkurs für %@ – diese Beträge fehlen in den Summen.";
|
||||
|
||||
/* --- Buchungen --- */
|
||||
"buchungen.suchen" = "Empfänger, Zweck, Notiz";
|
||||
"buchungen.konto" = "Konto";
|
||||
"buchungen.alleKonten" = "Alle Konten";
|
||||
"buchungen.art" = "Art";
|
||||
"buchungen.alleArten" = "Alle Arten";
|
||||
"buchungen.leerTitel" = "Keine Buchungen";
|
||||
"buchungen.leerErklaerung" = "Sobald etwas gebucht ist, steht es hier.";
|
||||
"buchungen.leerGefiltert" = "Zu dieser Suche gibt es nichts. Andere Filter versuchen?";
|
||||
"buchung.ohneBezeichnung" = "Ohne Bezeichnung";
|
||||
"buchung.ohneKategorie" = "Ohne Kategorie";
|
||||
"buchung.uebertrag" = "Übertrag";
|
||||
|
||||
/* --- Buchungsarten --- */
|
||||
"buchungsart.ausgabe" = "Ausgabe";
|
||||
"buchungsart.einnahme" = "Einnahme";
|
||||
"buchungsart.uebertrag" = "Übertrag";
|
||||
|
||||
/* --- Verträge --- */
|
||||
"abos.leerTitel" = "Keine Verträge";
|
||||
"abos.leerErklaerung" = "Hier stehen Abos und Verträge, sobald welche angelegt sind.";
|
||||
"abos.beendeteZeigen" = "Beendete zeigen";
|
||||
"abos.beendet" = "beendet";
|
||||
"abos.jaehrlichUndAnzahl" = "%1$@ im Jahr · %2$d aktiv";
|
||||
"abos.ohneKurs" = "Kein Wechselkurs für %@ – diese Verträge fehlen in der Summe.";
|
||||
"abo.monatlich" = "monatlich";
|
||||
"abo.vierteljaehrlich" = "vierteljährlich";
|
||||
"abo.halbjaehrlich" = "halbjährlich";
|
||||
"abo.jaehrlich" = "jährlich";
|
||||
"abo.alleNMonate" = "alle %d Monate";
|
||||
"abo.proMonat" = "%@ im Monat";
|
||||
"abo.naechste" = "nächste am %@";
|
||||
"abo.inTagen" = "fällig in %d Tagen";
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
"allgemein.nochmal" = "Try again";
|
||||
"allgemein.abbrechen" = "Cancel";
|
||||
"allgemein.abmelden" = "Sign out";
|
||||
"allgemein.unbekannt" = "Unknown";
|
||||
|
||||
/* --- Tabs --- */
|
||||
"reiter.dashboard" = "Overview";
|
||||
"reiter.budget" = "Budget";
|
||||
"reiter.buchungen" = "Transactions";
|
||||
"reiter.abos" = "Subscriptions";
|
||||
"reiter.konten" = "Accounts";
|
||||
"reiter.einstellungen" = "Settings";
|
||||
|
||||
@@ -102,3 +107,59 @@
|
||||
"einstellungen.budgetStarttag" = "Budget starts on";
|
||||
"einstellungen.abmeldenFrage" = "Sign out on this device?";
|
||||
"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";
|
||||
|
||||
/* --- Dashboard --- */
|
||||
"dashboard.bilanz" = "This month";
|
||||
"dashboard.einnahmen" = "Income";
|
||||
"dashboard.ausgaben" = "Expenses";
|
||||
"dashboard.budgetfortschritt" = "Budget";
|
||||
"dashboard.keinBudget" = "Nothing planned for this month yet.";
|
||||
"dashboard.kontostaende" = "Accounts";
|
||||
"dashboard.letzteBuchungen" = "Recent";
|
||||
|
||||
/* --- Budget --- */
|
||||
"budget.einnahmen" = "Planned income";
|
||||
"budget.uebrig" = "left";
|
||||
"budget.vonGeplant" = "%1$@ of %2$@ spent";
|
||||
"budget.vonBetrag" = "of %@";
|
||||
"budget.angepasst" = "adjusted";
|
||||
"budget.vorigerMonat" = "Previous month";
|
||||
"budget.naechsterMonat" = "Next month";
|
||||
"budget.ausVertraegenHinweis" = "%1$d subscriptions: %2$@";
|
||||
"budget.ausVertraegenBerechnet" = "Calculated from %1$d subscriptions: %2$@";
|
||||
"budget.vertraegeOhneKategorie" = "%1$d subscriptions without a category (%2$@) are missing from these totals.";
|
||||
"budget.ohneKurs" = "No exchange rate for %@ – those amounts are missing from the totals.";
|
||||
|
||||
/* --- Transactions --- */
|
||||
"buchungen.suchen" = "Payee, description, note";
|
||||
"buchungen.konto" = "Account";
|
||||
"buchungen.alleKonten" = "All accounts";
|
||||
"buchungen.art" = "Type";
|
||||
"buchungen.alleArten" = "All types";
|
||||
"buchungen.leerTitel" = "No transactions";
|
||||
"buchungen.leerErklaerung" = "Once something is booked, it shows up here.";
|
||||
"buchungen.leerGefiltert" = "Nothing matches this search. Try different filters?";
|
||||
"buchung.ohneBezeichnung" = "Untitled";
|
||||
"buchung.ohneKategorie" = "No category";
|
||||
"buchung.uebertrag" = "Transfer";
|
||||
|
||||
/* --- Transaction types --- */
|
||||
"buchungsart.ausgabe" = "Expense";
|
||||
"buchungsart.einnahme" = "Income";
|
||||
"buchungsart.uebertrag" = "Transfer";
|
||||
|
||||
/* --- Subscriptions --- */
|
||||
"abos.leerTitel" = "No subscriptions";
|
||||
"abos.leerErklaerung" = "Subscriptions and contracts show up here once you add them.";
|
||||
"abos.beendeteZeigen" = "Show cancelled";
|
||||
"abos.beendet" = "cancelled";
|
||||
"abos.jaehrlichUndAnzahl" = "%1$@ per year · %2$d active";
|
||||
"abos.ohneKurs" = "No exchange rate for %@ – those subscriptions are missing from the total.";
|
||||
"abo.monatlich" = "monthly";
|
||||
"abo.vierteljaehrlich" = "quarterly";
|
||||
"abo.halbjaehrlich" = "twice a year";
|
||||
"abo.jaehrlich" = "yearly";
|
||||
"abo.alleNMonate" = "every %d months";
|
||||
"abo.proMonat" = "%@ per month";
|
||||
"abo.naechste" = "next on %@";
|
||||
"abo.inTagen" = "due in %d days";
|
||||
|
||||
@@ -19,7 +19,11 @@ targets:
|
||||
sources:
|
||||
- path: Anmeldung
|
||||
- path: App
|
||||
- path: Abos
|
||||
- path: Bausteine
|
||||
- path: Buchungen
|
||||
- path: Budget
|
||||
- path: Dashboard
|
||||
- path: Einstellungen
|
||||
- path: Konten
|
||||
- path: Modelle
|
||||
@@ -52,6 +56,8 @@ targets:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
# Tests/Fixtures/antworten.json landet als Ressource im Buendel -
|
||||
# der Test "Serverantworten" liest sie von dort.
|
||||
- path: Tests
|
||||
dependencies:
|
||||
- target: Certain
|
||||
|
||||
Reference in New Issue
Block a user