diff --git a/ios/Abos/AboListe.swift b/ios/Abos/AboListe.swift new file mode 100644 index 0000000..d505ec5 --- /dev/null +++ b/ios/Abos/AboListe.swift @@ -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? + @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(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: " · ") + } +} diff --git a/ios/App/RootAnsicht.swift b/ios/App/RootAnsicht.swift index 06aa90b..9858dce 100644 --- a/ios/App/RootAnsicht.swift +++ b/ios/App/RootAnsicht.swift @@ -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() } } diff --git a/ios/Buchungen/BuchungsListe.swift b/ios/Buchungen/BuchungsListe.swift new file mode 100644 index 0000000..f40ecd9 --- /dev/null +++ b/ios/Buchungen/BuchungsListe.swift @@ -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? + @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(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 + } +} diff --git a/ios/Budget/BudgetAnsicht.swift b/ios/Budget/BudgetAnsicht.swift new file mode 100644 index 0000000..27f24cc --- /dev/null +++ b/ios/Budget/BudgetAnsicht.swift @@ -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? + + 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(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) + } +} diff --git a/ios/Certain.xcodeproj/project.pbxproj b/ios/Certain.xcodeproj/project.pbxproj index b168eab..20ae525 100644 --- a/ios/Certain.xcodeproj/project.pbxproj +++ b/ios/Certain.xcodeproj/project.pbxproj @@ -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 = ""; }; + 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = ""; }; 13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = ""; }; 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = ""; }; 1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = ""; }; 287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = ""; }; + 3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = ""; }; + 327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = ""; }; 4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = ""; }; 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 = ""; }; @@ -60,14 +73,21 @@ 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = ""; }; 78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = ""; }; 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = ""; }; + 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = ""; }; + 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = ""; }; + A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = ""; }; A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = ""; }; A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = ""; }; + B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = ""; }; + BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = ""; }; C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = ""; }; C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = ""; }; C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = ""; }; DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = ""; }; DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = ""; }; + E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = ""; }; + E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = ""; }; F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -92,6 +112,7 @@ isa = PBXGroup; children = ( 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */, + 7DD1C597D594D0F965FF1547 /* Fixtures */, ); path = Tests; sourceTree = ""; @@ -114,16 +135,37 @@ path = Bausteine; sourceTree = ""; }; + 7DD1C597D594D0F965FF1547 /* Fixtures */ = { + isa = PBXGroup; + children = ( + 3137E525D1ADE3DB2F8E45A8 /* antworten.json */, + ); + path = Fixtures; + sourceTree = ""; + }; 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 = ""; }; + AE11D36E2DFAC34130E2868F /* Dashboard */ = { + isa = PBXGroup; + children = ( + 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */, + ); + path = Dashboard; + sourceTree = ""; + }; 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 = ""; }; + C4B3E718A814E0236A12EB86 /* Budget */ = { + isa = PBXGroup; + children = ( + 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */, + ); + path = Budget; + sourceTree = ""; + }; + CB7098BB5F4AEFE48BA9BB36 /* Abos */ = { + isa = PBXGroup; + children = ( + 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */, + ); + path = Abos; + sourceTree = ""; + }; + D99101183ED9DA14C8D22722 /* Buchungen */ = { + isa = PBXGroup; + children = ( + A150CAB4967A479B506A997F /* BuchungsListe.swift */, + ); + path = Buchungen; + sourceTree = ""; + }; 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 */, diff --git a/ios/Dashboard/DashboardAnsicht.swift b/ios/Dashboard/DashboardAnsicht.swift new file mode 100644 index 0000000..11add47 --- /dev/null +++ b/ios/Dashboard/DashboardAnsicht.swift @@ -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? + + 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(schluessel: "dashboard") { + try await client.dashboard() + } + lader = neuer + await neuer.lade() + } + } +} + +// MARK: - Karten + +/// Der Rahmen, den alle Karten teilen. +private struct Karte: 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 + } + } +} diff --git a/ios/Modelle/Abo.swift b/ios/Modelle/Abo.swift new file mode 100644 index 0000000..cefaa3a --- /dev/null +++ b/ios/Modelle/Abo.swift @@ -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) } +} diff --git a/ios/Modelle/Buchung.swift b/ios/Modelle/Buchung.swift new file mode 100644 index 0000000..56bc3e9 --- /dev/null +++ b/ios/Modelle/Buchung.swift @@ -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() + 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 + } +} diff --git a/ios/Modelle/Budget.swift b/ios/Modelle/Budget.swift new file mode 100644 index 0000000..361ca2a --- /dev/null +++ b/ios/Modelle/Budget.swift @@ -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" + } +} diff --git a/ios/Modelle/Dashboard.swift b/ios/Modelle/Dashboard.swift new file mode 100644 index 0000000..4ffafd5 --- /dev/null +++ b/ios/Modelle/Dashboard.swift @@ -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 } + } +} diff --git a/ios/Modelle/Kategorie.swift b/ios/Modelle/Kategorie.swift new file mode 100644 index 0000000..e8ae68b --- /dev/null +++ b/ios/Modelle/Kategorie.swift @@ -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") + } +} diff --git a/ios/Netzwerk/Endpunkte.swift b/ios/Netzwerk/Endpunkte.swift index f50af5c..25652c1 100644 --- a/ios/Netzwerk/Endpunkte.swift +++ b/ios/Netzwerk/Endpunkte.swift @@ -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))]) + } } diff --git a/ios/Tests/GrundlagenTests.swift b/ios/Tests/GrundlagenTests.swift index 2e4f495..725cf80 100644 --- a/ios/Tests/GrundlagenTests.swift +++ b/ios/Tests/GrundlagenTests.swift @@ -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(_ 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) + } +} diff --git a/ios/Texte/de.lproj/Localizable.strings b/ios/Texte/de.lproj/Localizable.strings index c46a749..1541995 100644 --- a/ios/Texte/de.lproj/Localizable.strings +++ b/ios/Texte/de.lproj/Localizable.strings @@ -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"; diff --git a/ios/Texte/en.lproj/Localizable.strings b/ios/Texte/en.lproj/Localizable.strings index f876bc7..5f6bf09 100644 --- a/ios/Texte/en.lproj/Localizable.strings +++ b/ios/Texte/en.lproj/Localizable.strings @@ -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"; diff --git a/ios/project.yml b/ios/project.yml index 00bb058..60e27c1 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -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