diff --git a/ios/Certain.xcodeproj/project.pbxproj b/ios/Certain.xcodeproj/project.pbxproj index 7da11c5..fd85e1c 100644 --- a/ios/Certain.xcodeproj/project.pbxproj +++ b/ios/Certain.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* 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 */; }; + 18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.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 */; }; @@ -17,6 +18,7 @@ 2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; }; 2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */; }; 2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */; }; + 3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3114763A11EC90E1D38C9DEF /* Nebenraeume.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 */; }; @@ -25,6 +27,7 @@ 49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; }; 4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; }; 4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; }; + 5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777281FF90E90AC0B14D2807 /* KategorienAnsicht.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 */; }; @@ -32,8 +35,10 @@ 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 */; }; + 90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14B825905BA99472E4B289 /* BankAnsicht.swift */; }; 917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; }; 918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; }; + A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841BA87849B2A461449BAF5 /* KategorieFormular.swift */; }; A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; }; AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; }; B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; }; @@ -42,6 +47,7 @@ C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; }; C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; }; D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; }; + D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */; }; D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; }; DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; }; E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; }; @@ -69,8 +75,10 @@ 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 = ""; }; + 1F14B825905BA99472E4B289 /* BankAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankAnsicht.swift; 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 = ""; }; + 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.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 = ""; }; 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = ""; }; @@ -85,9 +93,11 @@ 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = ""; }; 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = ""; }; 776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = ""; }; + 777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorienAnsicht.swift; sourceTree = ""; }; 778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = ""; }; 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = ""; }; 78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = ""; }; + 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsAnsicht.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 = ""; }; @@ -100,6 +110,8 @@ 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 = ""; }; + C841BA87849B2A461449BAF5 /* KategorieFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorieFormular.swift; sourceTree = ""; }; + D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiterFaktorAnsicht.swift; 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 = ""; }; @@ -175,6 +187,7 @@ F8BE152C9F91584900796ADA /* Grundmodelle.swift */, B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */, 4119B85EFF3FB38E84FAA913 /* Konto.swift */, + 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */, 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */, ); path = Modelle; @@ -213,6 +226,7 @@ 56CB7B1C18136BFD83ACADBD /* Einstellungen */, 298E43BBA9248E5A9779DA98 /* Konten */, 80EFC9A43394C64E9AEEF5A1 /* Modelle */, + F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */, DB51F54CC077E85C286AD9DC /* Netzwerk */, 524FD85EDE7C4DE5ABE9A8E5 /* Tests */, 0EDDD922C06B1465AD13C782 /* Texte */, @@ -279,6 +293,18 @@ path = Netzwerk; sourceTree = ""; }; + F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */ = { + isa = PBXGroup; + children = ( + 1F14B825905BA99472E4B289 /* BankAnsicht.swift */, + C841BA87849B2A461449BAF5 /* KategorieFormular.swift */, + 777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */, + 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */, + D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */, + ); + path = Nebenraeume; + sourceTree = ""; + }; FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = { isa = PBXGroup; children = ( @@ -406,6 +432,7 @@ 2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */, 2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */, AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */, + 90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */, 4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */, 2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */, 917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */, @@ -424,16 +451,21 @@ 431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */, 8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */, D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */, + A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */, + 5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */, 3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */, 0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */, 49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */, D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */, + 3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */, B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */, B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */, 3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */, + D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */, 7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */, 25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */, 1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */, + 18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */, 4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Einstellungen/EinstellungenAnsicht.swift b/ios/Einstellungen/EinstellungenAnsicht.swift index 3a7b831..2e1ad5e 100644 --- a/ios/Einstellungen/EinstellungenAnsicht.swift +++ b/ios/Einstellungen/EinstellungenAnsicht.swift @@ -1,11 +1,15 @@ import SwiftUI -/// Stufe 1 zeigt hier nur, woran man ist, und lässt abmelden. Die echten -/// Einstellungen (Hauptwährung, Budget-Starttag, Sprache, Gerätemarken) -/// kommen in Stufe 5. +/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank, +/// zweiter Faktor. +/// +/// Sie hängen hier und nicht als eigene Reiter: Man braucht sie selten, und +/// eine Leiste mit zehn Reitern findet niemand mehr. struct EinstellungenAnsicht: View { @Environment(AppZustand.self) private var zustand + @State private var vorgang = Speichervorgang() @State private var zeigeAbmeldefrage = false + @State private var markierte: [MarkiertesWort] = [] var body: some View { Form { @@ -18,17 +22,78 @@ struct EinstellungenAnsicht: View { LabeledContent(String(localized: "einstellungen.server"), value: adresse.host() ?? adresse.absoluteString) } + NavigationLink { + ZweiterFaktorAnsicht() + } label: { + Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield") + } } header: { Text(String(localized: "einstellungen.zugang")) } footer: { Text(String(localized: "einstellungen.markeErklaerung")) } - Section(String(localized: "einstellungen.ausDemServer")) { - LabeledContent(String(localized: "einstellungen.hauptwaehrung"), - value: zustand.einstellungen.basiswaehrung) - LabeledContent(String(localized: "einstellungen.budgetStarttag"), - value: "\(zustand.einstellungen.budgetStarttag)") + Section(String(localized: "nebenraeume.titel")) { + NavigationLink { + KategorienAnsicht() + } label: { + Label(String(localized: "nebenraeume.kategorien"), systemImage: "square.grid.2x2") + } + NavigationLink { + TagsAnsicht() + } label: { + Label(String(localized: "nebenraeume.tags"), systemImage: "tag") + } + NavigationLink { + BankAnsicht() + } label: { + Label(String(localized: "nebenraeume.bank"), systemImage: "building.columns") + } + } + + if !markierte.isEmpty { + Section { + ForEach(markierte) { wort in + MarkiertesWortZeile(wort: wort, + behalten: { Task { await behalte(wort) } }, + entfernen: { Task { await entferne(wort) } }) + } + } header: { + Text(String(localized: "kategorien.markiertTitel")) + } footer: { + Text(String(localized: "kategorien.markiertErklaerung")) + } + } + + Section { + Picker(String(localized: "einstellungen.erscheinungsbild"), + selection: bindung(\.erscheinungsbild, "theme")) { + Text(String(localized: "einstellungen.system")).tag("system") + Text(String(localized: "einstellungen.hell")).tag("light") + Text(String(localized: "einstellungen.dunkel")).tag("dark") + } + Picker(String(localized: "einstellungen.hauptwaehrung"), + selection: bindung(\.basiswaehrung, "base_currency")) { + ForEach(zustand.einstellungen.aktiveWaehrungen, id: \.self) { code in + Text(code).tag(code) + } + } + Picker(String(localized: "einstellungen.budgetStarttag"), + selection: tagBindung) { + ForEach(1...28, id: \.self) { tag in + Text("\(tag).").tag(tag) + } + } + } header: { + Text(String(localized: "einstellungen.ausDemServer")) + } footer: { + // Die Hauptwährung rechnet alle Auswertungen um. Das gilt + // für jedes Gerät, nicht nur für dieses. + Text(String(localized: "einstellungen.serverweitErklaerung")) + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } } Section { @@ -40,7 +105,8 @@ struct EinstellungenAnsicht: View { } } .navigationTitle(String(localized: "reiter.einstellungen")) - .refreshable { await zustand.aktualisiereEinstellungen() } + .refreshable { await lade() } + .task { await lade() } .confirmationDialog(String(localized: "einstellungen.abmeldenFrage"), isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) { Button(String(localized: "allgemein.abmelden"), role: .destructive) { @@ -49,4 +115,87 @@ struct EinstellungenAnsicht: View { Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} } } + + // MARK: - Einstellungen schreiben + + /// Eine Bindung, die beim Setzen gleich zum Server geht. + /// + /// Ein „Sichern"-Knopf wäre hier falsch: Es ist je Zeile genau ein Wert, + /// und das Backend nimmt ihn einzeln entgegen (`PUT /settings/{key}`). + private func bindung(_ pfad: KeyPath, + _ schluessel: String) -> Binding { + Binding(get: { zustand.einstellungen[keyPath: pfad] }, + set: { neu in Task { await setze(schluessel, neu) } }) + } + + private var tagBindung: Binding { + Binding(get: { zustand.einstellungen.budgetStarttag }, + set: { neu in Task { await setze("budget_start_day", String(neu)) } }) + } + + private func setze(_ schluessel: String, _ wert: String) async { + guard let client = zustand.client else { return } + let geglueckt = await vorgang.fuehreAus { + _ = try await client.setzeEinstellung(schluessel, wert) + } + if geglueckt { await zustand.aktualisiereEinstellungen() } + } + + // MARK: - Markierte Erkennungswörter + + private func lade() async { + await zustand.aktualisiereEinstellungen() + guard let client = zustand.client else { return } + markierte = (try? await client.markierteErkennungswoerter()) ?? [] + } + + private func behalte(_ wort: MarkiertesWort) async { + guard let client = zustand.client else { return } + if await vorgang.fuehreAus({ try await client.bestaetigeErkennungswort(wort.id) }) { + await lade() + } + } + + private func entferne(_ wort: MarkiertesWort) async { + guard let client = zustand.client else { return } + if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) { + await lade() + await zustand.ladeNachschlag() + } + } +} + +/// Ein Erkennungswort, das oft wieder korrigiert wird. +private struct MarkiertesWortZeile: View { + let wort: MarkiertesWort + let behalten: () -> Void + let entfernen: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(wort.word) + .font(.callout) + Spacer() + Text(wort.categoryName) + .font(.caption2) + .foregroundStyle(.secondary) + } + Text(String(format: String(localized: "kategorien.markiertZaehler"), + wort.appliedCount, wort.overriddenCount)) + .font(.caption2) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button(String(localized: "kategorien.behalten"), action: behalten) + .buttonStyle(.bordered) + .controlSize(.small) + Button(String(localized: "kategorien.entfernen"), role: .destructive, + action: entfernen) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 2) + } } diff --git a/ios/Modelle/Nebenraeume.swift b/ios/Modelle/Nebenraeume.swift new file mode 100644 index 0000000..ef393d3 --- /dev/null +++ b/ios/Modelle/Nebenraeume.swift @@ -0,0 +1,218 @@ +import Foundation + +// MARK: - Schlagwörter + +/// `KeywordOut` – ein Wort, an dem die Erkennung eine Kategorie festmacht. +/// +/// Nicht zu verwechseln mit `Schlagwort` (einem Tag): Ein Tag klebt an einer +/// Buchung, ein Schlagwort hängt an einer **Kategorie** und wirkt nur auf +/// künftige Importe. +struct Erkennungswort: Codable, Sendable, Identifiable, Hashable { + let id: Int + let categoryId: Int + let word: String + /// Nur wenn der Betrag mitentscheiden soll – „Migros" über 200 ist + /// Haushalt, darunter Lebensmittel. + let amountCents: Int? + + private enum CodingKeys: String, CodingKey { + case id, word + case categoryId = "category_id" + case amountCents = "amount_cents" + } +} + +/// `FlaggedKeywordOut` – ein Wort, das oft wieder korrigiert wird. +/// +/// Ab fünf Anwendungen und mindestens einem Drittel Korrekturen meldet das +/// Backend es zur Prüfung. Behalten oder entfernen entscheidet der Mensch. +struct MarkiertesWort: Codable, Sendable, Identifiable, Hashable { + let id: Int + let categoryId: Int? + let categoryName: String + let word: String + let appliedCount: Int + let overriddenCount: Int + + private enum CodingKeys: String, CodingKey { + case id, word + case categoryId = "category_id" + case categoryName = "category_name" + case appliedCount = "applied_count" + case overriddenCount = "overridden_count" + } +} + +/// `KeywordCreate` +struct ErkennungswortAnlegen: Encodable, Sendable { + let word: String + var amountCents: Int? + + private enum CodingKeys: String, CodingKey { + case word + case amountCents = "amount_cents" + } +} + +// MARK: - Kategorien schreiben + +/// `CategoryCreate` +struct KategorieAnlegen: Encodable, Sendable { + let groupId: Int + let name: String + var icon = "more-horizontal" + var color = "#6b7480" + /// Zählt in keiner Auswertung mit – etwa durchlaufende Posten. + var excludedFromStats = false + /// Eine Ausgabe, die kein Verlust ist: Sparen. + var spendingIsGood = false + + private enum CodingKeys: String, CodingKey { + case name, icon, color + case groupId = "group_id" + case excludedFromStats = "excluded_from_stats" + case spendingIsGood = "spending_is_good" + } +} + +/// `CategoryUpdate` – nur die gesetzten Felder gehen mit. +struct KategorieAendern: Encodable, Sendable { + var name: String? + var groupId: Int? + var icon: String? + var color: String? + var isArchived: Bool? + var excludedFromStats: Bool? + var spendingIsGood: Bool? + + private enum CodingKeys: String, CodingKey { + case name, icon, color + case groupId = "group_id" + case isArchived = "is_archived" + case excludedFromStats = "excluded_from_stats" + case spendingIsGood = "spending_is_good" + } +} + +/// `CategoryGroupCreate` +struct GruppeAnlegen: Encodable, Sendable { + let name: String + var kind: Kategorieart = .expense + var icon = "list" + var color = "#6b7480" +} + +/// `CategoryGroupUpdate` +struct GruppeAendern: Encodable, Sendable { + var name: String? + var icon: String? + var color: String? +} + +// MARK: - Tags schreiben + +/// `TagCreate` +struct TagAnlegen: Encodable, Sendable { + let name: String + var color = "#6b7480" +} + +/// `TagUpdate` +struct TagAendern: Encodable, Sendable { + var name: String? + var color: String? +} + +// MARK: - Zweiter Faktor + +/// `TwoFactorStatusOut` +struct ZweiterFaktorStand: Codable, Sendable, Hashable { + let enabled: Bool + let recoveryCodesLeft: Int + + private enum CodingKeys: String, CodingKey { + case enabled + case recoveryCodesLeft = "recovery_codes_left" + } +} + +/// `TwoFactorSetupOut` – das noch **nicht** aktive Geheimnis. +/// +/// Gültig wird es erst, wenn ein Code daraus geprüft wurde. Wer die +/// Einrichtung abbricht, lässt einfach ein unbenutztes Geheimnis stehen. +struct ZweiterFaktorEinrichtung: Codable, Sendable, Hashable { + let secret: String + let otpauthUri: String + /// Der QR-Code als `data:`-URI. Auf dem Telefon selten gebraucht – dort + /// führt der Weg über `otpauthUri` direkt in die Authenticator-App. + let qrPng: String + + private enum CodingKeys: String, CodingKey { + case secret + case otpauthUri = "otpauth_uri" + case qrPng = "qr_png" + } +} + +/// `RecoveryCodesOut` – einmal zu sehen, danach nie wieder. +struct Rettungscodes: Codable, Sendable, Hashable { + let codes: [String] +} + +struct ZweiterFaktorCode: Encodable, Sendable { + let code: String +} + +// MARK: - Bank + +/// `BankConnectionOut` – eine bestehende Anbindung. +/// +/// Hergestellt wird sie am Rechner: Die Bank leitet fest auf +/// `{base_url}/bank/rueckkehr` zurück. Abrufen geht von hier. +struct Bankverbindung: Codable, Sendable, Identifiable, Hashable { + let id: Int + let aspspName: String + let aspspCountry: String + let validUntil: Zeitpunkt? + let lastSyncAt: Zeitpunkt? + /// Takt des automatischen Abrufs in Minuten, 0 = aus. + let pollMinutes: Int + let lastError: String? + let expiresSoon: Bool + + private enum CodingKeys: String, CodingKey { + case id + case aspspName = "aspsp_name" + case aspspCountry = "aspsp_country" + case validUntil = "valid_until" + case lastSyncAt = "last_sync_at" + case pollMinutes = "poll_minutes" + case lastError = "last_error" + case expiresSoon = "expires_soon" + } +} + +/// Was ein Abruf zurückmeldet. +struct Abrufergebnis: Codable, Sendable, Hashable { + let geholt: Int + let neu: Int + let gebucht: Int? + let vorgemerkt: Int? + let doppelt: Int? + let uebertraege: Int? + /// Die Verbindung hat kein zugeordnetes Konto – dann wurde nichts geholt. + let ohneZuordnung: Bool? + + private enum CodingKeys: String, CodingKey { + case geholt, neu, gebucht, vorgemerkt, doppelt, uebertraege + case ohneZuordnung = "ohne_zuordnung" + } + + /// Ein Satz für die Rückmeldung. + var meldung: String { + if ohneZuordnung == true { + return String(localized: "bank.ohneZuordnung") + } + return String(format: String(localized: "bank.abrufErgebnis"), geholt, neu) + } +} diff --git a/ios/Nebenraeume/BankAnsicht.swift b/ios/Nebenraeume/BankAnsicht.swift new file mode 100644 index 0000000..951375e --- /dev/null +++ b/ios/Nebenraeume/BankAnsicht.swift @@ -0,0 +1,146 @@ +import SwiftUI + +/// Die Bankanbindungen – abrufen, Salden auffrischen. +/// +/// **Herstellen geht hier nicht.** Die Bank leitet fest auf +/// `{base_url}/bank/rueckkehr` zurück, registriert bei Enable Banking; dieser +/// Weg führt zwangsläufig über den Browser am Rechner. Abrufen dagegen ist +/// genau das, was man unterwegs will. +struct BankAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var lader: Lader<[Bankverbindung]>? + @State private var vorgang = Speichervorgang() + @State private var meldung: String? + + 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: "nebenraeume.bank")) + .task { + guard lader == nil, let client = zustand.client else { return } + let neuer = Lader<[Bankverbindung]>(schluessel: "bank/connections") { + try await client.bankverbindungen() + } + lader = neuer + await neuer.lade() + } + .alert(String(localized: "bank.abrufFertig"), + isPresented: .constant(meldung != nil)) { + Button(String(localized: "allgemein.ok")) { meldung = nil } + } message: { + Text(meldung ?? "") + } + .alert(String(localized: "fehler.titel"), + isPresented: .constant(vorgang.fehler != nil)) { + Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil } + } message: { + Text(vorgang.fehler ?? "") + } + } + + @ViewBuilder + private func inhalt(_ liste: [Bankverbindung]) -> some View { + if liste.isEmpty { + LeerAnsicht(titel: String(localized: "bank.leerTitel"), + erklaerung: String(localized: "bank.leerErklaerung"), + symbol: "building.columns") + } else { + List { + ForEach(liste) { verbindung in + Section { + VerbindungsZeile(verbindung: verbindung) + + Button { + Task { await rufeAb(verbindung) } + } label: { + Label(String(localized: "bank.abrufen"), + systemImage: "arrow.down.circle") + } + Button { + Task { await frischeSalden(verbindung) } + } label: { + Label(String(localized: "bank.saldenAuffrischen"), + systemImage: "arrow.clockwise") + } + } header: { + Text(verbindung.aspspName) + } footer: { + if let fehler = verbindung.lastError { + // Der letzte Fehler steht am Server. Ihn zu + // verschweigen hiesse, den Nutzer vergeblich + // abrufen zu lassen. + Label(fehler, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { await lader?.aktualisiere() } + .disabled(vorgang.laeuft) + } + } + + private func rufeAb(_ verbindung: Bankverbindung) async { + guard let client = zustand.client else { return } + var ergebnis: Abrufergebnis? + let geglueckt = await vorgang.fuehreAus { + ergebnis = try await client.rufeBankAb(verbindung.id) + } + if geglueckt { + meldung = ergebnis?.meldung + await lader?.aktualisiere() + } + } + + private func frischeSalden(_ verbindung: Bankverbindung) async { + guard let client = zustand.client else { return } + if await vorgang.fuehreAus({ try await client.frischeSaldenAuf(verbindung.id) }) { + meldung = String(localized: "bank.saldenAufgefrischt") + await lader?.aktualisiere() + } + } +} + +private struct VerbindungsZeile: View { + let verbindung: Bankverbindung + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + LabeledContent(String(localized: "bank.land"), value: verbindung.aspspCountry) + + if let zuletzt = verbindung.lastSyncAt { + LabeledContent(String(localized: "bank.zuletzt")) { + Text(zuletzt.datum.formatted(.relative(presentation: .named))) + } + } else { + LabeledContent(String(localized: "bank.zuletzt"), + value: String(localized: "bank.nie")) + } + + if let gueltig = verbindung.validUntil { + LabeledContent(String(localized: "bank.gueltigBis")) { + Text(gueltig.datum.formatted(.dateTime.day().month().year())) + // Eine auslaufende Anbindung bricht sonst ohne + // Vorwarnung ab, und niemand weiss warum. + .foregroundStyle(verbindung.expiresSoon ? .orange : .secondary) + } + } + + if verbindung.pollMinutes > 0 { + LabeledContent(String(localized: "bank.takt")) { + Text(String(format: String(localized: "bank.alleNMinuten"), + verbindung.pollMinutes)) + } + } + } + .font(.callout) + } +} diff --git a/ios/Nebenraeume/KategorieFormular.swift b/ios/Nebenraeume/KategorieFormular.swift new file mode 100644 index 0000000..ff9636c --- /dev/null +++ b/ios/Nebenraeume/KategorieFormular.swift @@ -0,0 +1,307 @@ +import SwiftUI + +/// Eine Kategorie anlegen oder ändern. +struct KategorieFormular: View { + enum Zweck: Equatable, Identifiable { + /// Neu in dieser Gruppe. Die Art erbt sie von der Gruppe – eine + /// Einnahme-Kategorie in einer Ausgabe-Gruppe gibt es nicht. + case neu(Int, Kategorieart) + case aendern(Kategorie) + + var id: Int { + switch self { + case .neu(let gruppe, _): return -gruppe + case .aendern(let kategorie): return kategorie.id + } + } + } + + let zweck: Zweck + var fertig: () -> Void + + @Environment(AppZustand.self) private var zustand + @Environment(\.dismiss) private var schliesse + @State private var vorgang = Speichervorgang() + + @State private var name = "" + @State private var symbol = "more-horizontal" + @State private var farbe = "#6b7480" + @State private var gruppeId = 0 + @State private var istSparen = false + @State private var ohneAuswertung = false + @State private var istArchiviert = false + @State private var fragtNachLoeschen = false + @State private var vorbelegt = false + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "konto.name"), text: $name) + Picker(String(localized: "kategorien.gruppe"), selection: $gruppeId) { + ForEach(passendeGruppen) { gruppe in + Text(gruppe.name).tag(gruppe.id) + } + } + } + + Section { + SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe) + } + + if art == .expense { + Section { + Toggle(String(localized: "kategorien.istSparen"), isOn: $istSparen) + } footer: { + Text(String(localized: "kategorien.istSparenErklaerung")) + } + } + + Section { + Toggle(String(localized: "kategorien.ohneAuswertungSchalter"), + isOn: $ohneAuswertung) + } footer: { + Text(String(localized: "kategorien.ohneAuswertungErklaerung")) + } + + if case .aendern = zweck { + Section { + Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert) + Button(String(localized: "kategorien.loeschen"), role: .destructive) { + fragtNachLoeschen = true + } + } footer: { + Text(String(localized: "kategorien.archivierenStattLoeschen")) + } + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .navigationTitle(titel) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "allgemein.abbrechen")) { schliesse() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "allgemein.sichern")) { Task { await sichere() } } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty + || vorgang.laeuft) + } + } + .interactiveDismissDisabled(vorgang.laeuft) + .confirmationDialog(String(localized: "kategorien.loeschenFrage"), + isPresented: $fragtNachLoeschen, titleVisibility: .visible) { + Button(String(localized: "kategorien.loeschen"), role: .destructive) { + Task { await loesche() } + } + Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} + } message: { + Text(String(localized: "kategorien.loeschenErklaerung")) + } + .onAppear(perform: belegeVor) + } + } + + private var titel: String { + switch zweck { + case .neu: return String(localized: "kategorien.neu") + case .aendern: return String(localized: "kategorien.aendern") + } + } + + /// Welche Art diese Kategorie hat – sie steckt in der Gruppe, nicht in + /// der Kategorie selbst. + private var art: Kategorieart { + switch zweck { + case .neu(_, let kind): return kind + case .aendern(let kategorie): return kategorie.type + } + } + + /// Umhängen nur innerhalb derselben Art: Eine Ausgabe-Kategorie in einer + /// Einnahme-Gruppe wäre ein Widerspruch. + private var passendeGruppen: [Kategoriegruppe] { + zustand.nachschlag.gruppen.filter { $0.kind == art } + } + + private func belegeVor() { + guard !vorbelegt else { return } + vorbelegt = true + switch zweck { + case .neu(let gruppe, _): + gruppeId = gruppe + case .aendern(let kategorie): + name = kategorie.name + symbol = kategorie.icon + farbe = kategorie.color + gruppeId = kategorie.groupId + istSparen = kategorie.spendingIsGood + ohneAuswertung = kategorie.excludedFromStats + istArchiviert = kategorie.isArchived + } + } + + private func sichere() async { + guard let client = zustand.client else { return } + let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines) + // Sparen gibt es nur bei Ausgaben. Beim Wechsel der Art bliebe der + // Haken sonst gesetzt und wirkte still weiter. + let sparen = art == .expense && istSparen + + let geglueckt = await vorgang.fuehreAus { + switch zweck { + case .neu: + _ = try await client.legeKategorieAn(KategorieAnlegen( + groupId: gruppeId, name: sauber, icon: symbol, color: farbe, + excludedFromStats: ohneAuswertung, spendingIsGood: sparen)) + case .aendern(let kategorie): + _ = try await client.aendereKategorie(kategorie.id, KategorieAendern( + name: sauber, groupId: gruppeId, icon: symbol, color: farbe, + isArchived: istArchiviert, excludedFromStats: ohneAuswertung, + spendingIsGood: sparen)) + } + } + if geglueckt { + fertig() + schliesse() + } + } + + private func loesche() async { + guard let client = zustand.client, case .aendern(let kategorie) = zweck else { return } + if await vorgang.fuehreAus({ try await client.loescheKategorie(kategorie.id) }) { + fertig() + schliesse() + } + } +} + +/// Eine Kategoriegruppe anlegen oder ändern. +struct GruppenFormular: View { + enum Zweck: Equatable, Identifiable { + case neu + case aendern(Kategoriegruppe) + + var id: Int { + switch self { + case .neu: return 0 + case .aendern(let gruppe): return gruppe.id + } + } + } + + let zweck: Zweck + var fertig: () -> Void + + @Environment(AppZustand.self) private var zustand + @Environment(\.dismiss) private var schliesse + @State private var vorgang = Speichervorgang() + + @State private var name = "" + @State private var art: Kategorieart = .expense + @State private var symbol = "list" + @State private var farbe = "#6b7480" + @State private var fragtNachLoeschen = false + @State private var vorbelegt = false + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "konto.name"), text: $name) + if case .neu = zweck { + Picker(String(localized: "kategorien.art"), selection: $art) { + Text(String(localized: "kategorien.ausgaben")).tag(Kategorieart.expense) + Text(String(localized: "kategorien.einnahmen")).tag(Kategorieart.income) + } + .pickerStyle(.segmented) + } else { + // Die Art einer Gruppe nachträglich zu wechseln würde + // alle ihre Kategorien die Seite wechseln lassen. + LabeledContent(String(localized: "kategorien.art"), + value: art == .income + ? String(localized: "kategorien.einnahmen") + : String(localized: "kategorien.ausgaben")) + } + } + + Section { + SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe) + } + + if case .aendern = zweck { + Section { + Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) { + fragtNachLoeschen = true + } + } footer: { + Text(String(localized: "kategorien.gruppeLoeschenErklaerung")) + } + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .navigationTitle(String(localized: "kategorien.gruppe")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "allgemein.abbrechen")) { schliesse() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "allgemein.sichern")) { Task { await sichere() } } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty + || vorgang.laeuft) + } + } + .confirmationDialog(String(localized: "kategorien.gruppeLoeschenFrage"), + isPresented: $fragtNachLoeschen, titleVisibility: .visible) { + Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) { + Task { await loesche() } + } + Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} + } + .onAppear { + guard !vorbelegt else { return } + vorbelegt = true + if case .aendern(let gruppe) = zweck { + name = gruppe.name + art = gruppe.kind + symbol = gruppe.icon + farbe = gruppe.color + } + } + } + } + + private func sichere() async { + guard let client = zustand.client else { return } + let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines) + let geglueckt = await vorgang.fuehreAus { + switch zweck { + case .neu: + _ = try await client.legeGruppeAn(GruppeAnlegen( + name: sauber, kind: art, icon: symbol, color: farbe)) + case .aendern(let gruppe): + _ = try await client.aendereGruppe(gruppe.id, GruppeAendern( + name: sauber, icon: symbol, color: farbe)) + } + } + if geglueckt { + fertig() + schliesse() + } + } + + private func loesche() async { + guard let client = zustand.client, case .aendern(let gruppe) = zweck else { return } + if await vorgang.fuehreAus({ try await client.loescheGruppe(gruppe.id) }) { + fertig() + schliesse() + } + } +} diff --git a/ios/Nebenraeume/KategorienAnsicht.swift b/ios/Nebenraeume/KategorienAnsicht.swift new file mode 100644 index 0000000..a68bb69 --- /dev/null +++ b/ios/Nebenraeume/KategorienAnsicht.swift @@ -0,0 +1,250 @@ +import SwiftUI + +/// Kategorien, nach Gruppen. Antippen öffnet die Kategorie samt ihren +/// Erkennungswörtern. +struct KategorienAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var vorgang = Speichervorgang() + @State private var bearbeitet: KategorieFormular.Zweck? + @State private var gruppeBearbeiten: GruppenFormular.Zweck? + @State private var zeigeArchivierte = false + + var body: some View { + List { + if zustand.nachschlag.gruppen.isEmpty { + Section { + Text(String(localized: "kategorien.leer")) + .foregroundStyle(.secondary) + } + } + + ForEach(zustand.nachschlag.gruppen) { gruppe in + Section { + ForEach(sichtbar(gruppe)) { kategorie in + NavigationLink { + KategorieAnsicht(kategorie: kategorie) + } label: { + KategorieZeile(kategorie: kategorie) + } + .swipeActions(edge: .trailing) { + Button { + bearbeitet = .aendern(kategorie) + } label: { + Label(String(localized: "allgemein.bearbeiten"), + systemImage: "pencil") + } + .tint(.indigo) + } + } + Button { + bearbeitet = .neu(gruppe.id, gruppe.kind) + } label: { + Label(String(localized: "kategorien.neu"), systemImage: "plus") + .font(.callout) + } + } header: { + HStack(spacing: 8) { + Image(systemName: Symbole.sf(gruppe.icon)) + .foregroundStyle(Color(hex: gruppe.color)) + Text(gruppe.name) + Text(gruppe.kind == .income + ? String(localized: "kategorien.einnahmen") + : String(localized: "kategorien.ausgaben")) + .foregroundStyle(.tertiary) + Spacer() + Button { + gruppeBearbeiten = .aendern(gruppe) + } label: { + Image(systemName: "pencil") + } + } + .textCase(nil) + .font(.footnote) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nebenraeume.kategorien")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle(String(localized: "kategorien.archivierteZeigen"), + isOn: $zeigeArchivierte) + Button { + gruppeBearbeiten = .neu + } label: { + Label(String(localized: "kategorien.neueGruppe"), + systemImage: "folder.badge.plus") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .refreshable { await zustand.ladeNachschlag() } + .sheet(item: $bearbeitet) { zweck in + KategorieFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } } + .environment(zustand) + } + .sheet(item: $gruppeBearbeiten) { zweck in + GruppenFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } } + .environment(zustand) + } + } + + private func sichtbar(_ gruppe: Kategoriegruppe) -> [Kategorie] { + gruppe.categories.filter { zeigeArchivierte || !$0.isArchived } + } +} + +private struct KategorieZeile: View { + let kategorie: Kategorie + + var body: some View { + HStack(spacing: 10) { + Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30) + Text(kategorie.name) + .lineLimit(1) + Spacer(minLength: 6) + ForEach(merkmale, id: \.self) { merkmal in + Text(merkmal) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + } + if kategorie.keywordCount > 0 { + Label("\(kategorie.keywordCount)", systemImage: "text.magnifyingglass") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + /// Nur was vom Normalfall abweicht. Eine Plakette an jeder Zeile sagt + /// nichts mehr. + private var merkmale: [String] { + var liste: [String] = [] + if kategorie.isArchived { liste.append(String(localized: "konten.archiviert")) } + if kategorie.spendingIsGood { liste.append(String(localized: "kategorien.sparen")) } + if kategorie.excludedFromStats { + liste.append(String(localized: "kategorien.ohneAuswertung")) + } + return liste + } +} + +// MARK: - Eine Kategorie mit ihren Erkennungswörtern + +/// Was die Erkennung an dieser Kategorie festmacht. +/// +/// Erkennungswörter wirken **nur auf künftige Importe** – an bestehenden +/// Buchungen ändert sich nichts. Das steht auch so unter der Liste, sonst +/// wartet jemand vergeblich darauf, dass sich etwas rührt. +struct KategorieAnsicht: View { + let kategorie: Kategorie + + @Environment(AppZustand.self) private var zustand + @State private var lader: Lader<[Erkennungswort]>? + @State private var vorgang = Speichervorgang() + @State private var neuesWort = "" + + var body: some View { + Group { + if let lader { + Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { woerter in + liste(woerter) + } + } else { + ProgressView() + } + } + .navigationTitle(kategorie.name) + .navigationBarTitleDisplayMode(.inline) + .task { + guard lader == nil, let client = zustand.client else { return } + let neuer = Lader<[Erkennungswort]>(schluessel: "keywords/\(kategorie.id)") { + try await client.erkennungswoerter(kategorie: kategorie.id) + } + lader = neuer + await neuer.lade() + } + } + + @ViewBuilder + private func liste(_ woerter: [Erkennungswort]) -> some View { + List { + Section { + ForEach(woerter) { wort in + HStack { + Text(wort.word) + Spacer() + if let cents = wort.amountCents { + Text(Geldbetrag(cents: cents, + waehrung: zustand.einstellungen.basiswaehrung) + .formatiert()) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .swipeActions { + Button(role: .destructive) { + Task { await loesche(wort) } + } label: { + Label(String(localized: "allgemein.loeschen"), systemImage: "trash") + } + } + } + + HStack { + TextField(String(localized: "kategorien.wortHinzufuegen"), text: $neuesWort) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { Task { await fuegeHinzu() } } + Button { + Task { await fuegeHinzu() } + } label: { + Image(systemName: "plus.circle.fill") + } + .disabled(neuesWort.trimmingCharacters(in: .whitespaces).isEmpty + || vorgang.laeuft) + } + } header: { + Text(String(localized: "kategorien.erkennungswoerter")) + } footer: { + Text(String(localized: "kategorien.erkennungErklaerung")) + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .listStyle(.insetGrouped) + .refreshable { await lader?.aktualisiere() } + } + + private func fuegeHinzu() async { + guard let client = zustand.client else { return } + let wort = neuesWort.trimmingCharacters(in: .whitespacesAndNewlines) + guard !wort.isEmpty else { return } + let geglueckt = await vorgang.fuehreAus { + _ = try await client.legeErkennungswortAn( + kategorie: kategorie.id, ErkennungswortAnlegen(word: wort)) + } + if geglueckt { + neuesWort = "" + await lader?.aktualisiere() + // Die Kategorie führt die Anzahl mit; ohne das bleibt die + // Plakette in der Übersicht auf dem alten Stand stehen. + await zustand.ladeNachschlag() + } + } + + private func loesche(_ wort: Erkennungswort) async { + guard let client = zustand.client else { return } + if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) { + await lader?.aktualisiere() + await zustand.ladeNachschlag() + } + } +} diff --git a/ios/Nebenraeume/TagsAnsicht.swift b/ios/Nebenraeume/TagsAnsicht.swift new file mode 100644 index 0000000..acc5b7d --- /dev/null +++ b/ios/Nebenraeume/TagsAnsicht.swift @@ -0,0 +1,176 @@ +import SwiftUI + +/// Schlagwörter: frei vergebene Etiketten, quer zur Kategorie. +/// +/// Die Kategorie beantwortet „wofür war das Geld". Alles, was quer dazu +/// liegt – „Urlaub Island", „steuerlich absetzbar" –, passt in keine, weil +/// eine Buchung davon mehrere auf einmal sein kann. +struct TagsAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var vorgang = Speichervorgang() + @State private var bearbeitet: Schlagwort? + @State private var neuerName = "" + + /// Dieselbe Auswahl wie im Symbol- und Farbfeld, damit die Etiketten zu + /// den Kategorien passen. + private static let farben = ["#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", + "#c62828", "#0277bd", "#6b7480", "#5d4037"] + + var body: some View { + List { + Section { + ForEach(zustand.nachschlag.schlagwoerter) { etikett in + Button { + bearbeitet = etikett + } label: { + HStack(spacing: 10) { + Circle() + .fill(Color(hex: etikett.color)) + .frame(width: 14, height: 14) + Text(etikett.name) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .swipeActions { + Button(role: .destructive) { + Task { await loesche(etikett) } + } label: { + Label(String(localized: "allgemein.loeschen"), systemImage: "trash") + } + } + } + + HStack { + TextField(String(localized: "tags.neu"), text: $neuerName) + .onSubmit { Task { await legeAn() } } + Button { + Task { await legeAn() } + } label: { + Image(systemName: "plus.circle.fill") + } + .disabled(neuerName.trimmingCharacters(in: .whitespaces).isEmpty + || vorgang.laeuft) + } + } footer: { + Text(String(localized: "tags.erklaerung")) + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nebenraeume.tags")) + .refreshable { await zustand.ladeNachschlag() } + .sheet(item: $bearbeitet) { etikett in + TagFormular(etikett: etikett) { Task { await zustand.ladeNachschlag() } } + .environment(zustand) + } + } + + private func legeAn() async { + guard let client = zustand.client else { return } + let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + // Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach. + let farbe = Self.farben[zustand.nachschlag.schlagwoerter.count % Self.farben.count] + let geglueckt = await vorgang.fuehreAus { + _ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe)) + } + if geglueckt { + neuerName = "" + await zustand.ladeNachschlag() + } + } + + private func loesche(_ etikett: Schlagwort) async { + guard let client = zustand.client else { return } + if await vorgang.fuehreAus({ try await client.loescheTag(etikett.id) }) { + await zustand.ladeNachschlag() + } + } +} + +/// Ein Schlagwort umbenennen oder umfärben. +struct TagFormular: View { + let etikett: Schlagwort + var fertig: () -> Void + + @Environment(AppZustand.self) private var zustand + @Environment(\.dismiss) private var schliesse + @State private var vorgang = Speichervorgang() + @State private var name = "" + @State private var farbe = "#6b7480" + @State private var vorbelegt = false + + private static let farben = ["#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", + "#c62828", "#0277bd", "#6b7480", "#5d4037"] + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "konto.name"), text: $name) + } + Section { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(Self.farben, id: \.self) { wert in + Button { farbe = wert } label: { + Circle() + .fill(Color(hex: wert)) + .frame(width: 28, height: 28) + .overlay { + if wert == farbe { + Circle().strokeBorder(.primary, lineWidth: 2) + } + } + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .navigationTitle(String(localized: "nebenraeume.tags")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "allgemein.abbrechen")) { schliesse() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "allgemein.sichern")) { Task { await sichere() } } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty + || vorgang.laeuft) + } + } + .onAppear { + guard !vorbelegt else { return } + vorbelegt = true + name = etikett.name + farbe = etikett.color + } + } + } + + private func sichere() async { + guard let client = zustand.client else { return } + let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines) + let geglueckt = await vorgang.fuehreAus { + _ = try await client.aendereTag(etikett.id, + TagAendern(name: sauber, color: farbe)) + } + if geglueckt { + fertig() + schliesse() + } + } +} diff --git a/ios/Nebenraeume/ZweiterFaktorAnsicht.swift b/ios/Nebenraeume/ZweiterFaktorAnsicht.swift new file mode 100644 index 0000000..d31c258 --- /dev/null +++ b/ios/Nebenraeume/ZweiterFaktorAnsicht.swift @@ -0,0 +1,190 @@ +import SwiftUI + +/// Den zweiten Faktor einrichten. +/// +/// Bis Stufe 5 verwies die App dafür in den Browser. Drei Schritte: +/// Geheimnis anlegen, in die Authenticator-App übernehmen, mit einem Code +/// scharfschalten. +/// +/// **Abschalten geht hier nicht**, und das ist Absicht: Es verlangt das +/// Kontopasswort, und die App speichert keines. Wer es abschalten will, +/// macht das am Rechner – ein Vollzugang auf diesem Gerät soll den Schutz +/// des Kontos nicht aufheben können. +struct ZweiterFaktorAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var lader: Lader? + @State private var vorgang = Speichervorgang() + @State private var einrichtung: ZweiterFaktorEinrichtung? + @State private var code = "" + @State private var rettungscodes: [String]? + + var body: some View { + Group { + if let lader { + Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in + inhalt(stand) + } + } else { + ProgressView() + } + } + .navigationTitle(String(localized: "zweifaktor.titel")) + .navigationBarTitleDisplayMode(.inline) + .task { + guard lader == nil, let client = zustand.client else { return } + let neuer = Lader(schluessel: "auth/2fa/status") { + try await client.zweiterFaktorStand() + } + lader = neuer + await neuer.lade() + } + .sheet(item: Binding(get: { rettungscodes.map(Codeliste.init) }, + set: { if $0 == nil { rettungscodes = nil } })) { liste in + RettungscodeBlatt(codes: liste.codes) { + rettungscodes = nil + Task { await lader?.aktualisiere() } + } + } + } + + /// Nur da, um die Codes an `.sheet(item:)` zu geben. + private struct Codeliste: Identifiable { + let codes: [String] + var id: String { codes.joined() } + } + + @ViewBuilder + private func inhalt(_ stand: ZweiterFaktorStand) -> some View { + Form { + Section { + LabeledContent(String(localized: "zweifaktor.stand")) { + Text(stand.enabled ? String(localized: "zweifaktor.aktiv") + : String(localized: "zweifaktor.inaktiv")) + .foregroundStyle(stand.enabled ? .green : .secondary) + } + if stand.enabled { + LabeledContent(String(localized: "zweifaktor.rettungscodes"), + value: "\(stand.recoveryCodesLeft)") + } + } footer: { + Text(stand.enabled ? String(localized: "zweifaktor.aktivErklaerung") + : String(localized: "zweifaktor.inaktivErklaerung")) + } + + if !stand.enabled { + if let einrichtung { + Section { + // Auf dem Telefon selbst ist ein QR-Code sinnlos – die + // Authenticator-App liegt daneben. Der Link führt + // direkt hinein, das Geheimnis steht zum Abtippen da. + Link(destination: URL(string: einrichtung.otpauthUri) + ?? URL(string: "https://example.invalid")!) { + Label(String(localized: "zweifaktor.inAppOeffnen"), + systemImage: "arrow.up.forward.app") + } + LabeledContent(String(localized: "zweifaktor.geheimnis")) { + Text(einrichtung.secret) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } + } header: { + Text(String(localized: "zweifaktor.schrittEins")) + } footer: { + Text(String(localized: "zweifaktor.geheimnisErklaerung")) + } + + Section { + TextField(String(localized: "zweifaktor.code"), text: $code) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .monospaced() + Button(String(localized: "zweifaktor.aktivieren")) { + Task { await aktiviere() } + } + .disabled(code.count < 6 || vorgang.laeuft) + } header: { + Text(String(localized: "zweifaktor.schrittZwei")) + } + } else { + Section { + Button(String(localized: "zweifaktor.einrichten")) { + Task { await starte() } + } + .disabled(vorgang.laeuft) + } + } + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + } + + private func starte() async { + guard let client = zustand.client else { return } + var neu: ZweiterFaktorEinrichtung? + if await vorgang.fuehreAus({ neu = try await client.richteZweitenFaktorEin() }) { + einrichtung = neu + } + } + + private func aktiviere() async { + guard let client = zustand.client else { return } + var codes: Rettungscodes? + let geglueckt = await vorgang.fuehreAus { + codes = try await client.aktiviereZweitenFaktor(code: code) + } + if geglueckt { + code = "" + einrichtung = nil + rettungscodes = codes?.codes + } + } +} + +/// Die Rettungscodes – einmal zu sehen, danach nie wieder. +/// +/// Deshalb lässt sich dieses Blatt nicht wegwischen: Wer es versehentlich +/// schliesst, bekommt neue nur mit dem Kontopasswort am Rechner. +private struct RettungscodeBlatt: View { + let codes: [String] + let fertig: () -> Void + + @State private var abgehakt = false + + var body: some View { + NavigationStack { + List { + Section { + ForEach(codes, id: \.self) { code in + Text(code) + .font(.body.monospaced()) + .textSelection(.enabled) + } + } header: { + Text(String(localized: "zweifaktor.rettungscodes")) + } footer: { + Text(String(localized: "zweifaktor.rettungscodesErklaerung")) + } + + Section { + ShareLink(item: codes.joined(separator: "\n")) { + Label(String(localized: "zweifaktor.codesSichern"), + systemImage: "square.and.arrow.up") + } + Toggle(String(localized: "zweifaktor.codesNotiert"), isOn: $abgehakt) + } + } + .navigationTitle(String(localized: "zweifaktor.aktiv")) + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "allgemein.fertig"), action: fertig) + .disabled(!abgehakt) + } + } + } + } +} diff --git a/ios/Netzwerk/Endpunkte.swift b/ios/Netzwerk/Endpunkte.swift index 7d12c64..31cb648 100644 --- a/ios/Netzwerk/Endpunkte.swift +++ b/ios/Netzwerk/Endpunkte.swift @@ -370,4 +370,106 @@ extension CertainClient { try await schickeStumm(.post, "transactions/dubletten/behalten", koerper: ["ids": ids]) } + + // MARK: - Kategorien schreiben + + func legeKategorieAn(_ eingabe: KategorieAnlegen) async throws -> Kategorie { + try await schicke(.post, "categories", koerper: eingabe) + } + + func aendereKategorie(_ id: Int, _ aenderung: KategorieAendern) async throws -> Kategorie { + try await schicke(.patch, "categories/\(id)", koerper: aenderung) + } + + /// Geht nur, solange keine Buchung daran hängt – sonst 400. Archivieren + /// ist der Weg für alles, was schon benutzt wurde. + func loescheKategorie(_ id: Int) async throws { + try await schickeStumm(.delete, "categories/\(id)") + } + + func legeGruppeAn(_ eingabe: GruppeAnlegen) async throws -> Kategoriegruppe { + try await schicke(.post, "categories/groups", koerper: eingabe) + } + + func aendereGruppe(_ id: Int, _ aenderung: GruppeAendern) async throws -> Kategoriegruppe { + try await schicke(.patch, "categories/groups/\(id)", koerper: aenderung) + } + + func loescheGruppe(_ id: Int) async throws { + try await schickeStumm(.delete, "categories/groups/\(id)") + } + + // MARK: - Schlagwörter (Tags) schreiben + + func legeTagAn(_ eingabe: TagAnlegen) async throws -> Schlagwort { + try await schicke(.post, "tags", koerper: eingabe) + } + + func aendereTag(_ id: Int, _ aenderung: TagAendern) async throws -> Schlagwort { + try await schicke(.patch, "tags/\(id)", koerper: aenderung) + } + + func loescheTag(_ id: Int) async throws { + try await schickeStumm(.delete, "tags/\(id)") + } + + // MARK: - Erkennungswörter + + func erkennungswoerter(kategorie: Int) async throws -> [Erkennungswort] { + try await hole("categories/\(kategorie)/keywords") + } + + func legeErkennungswortAn(kategorie: Int, + _ eingabe: ErkennungswortAnlegen) async throws -> Erkennungswort { + try await schicke(.post, "categories/\(kategorie)/keywords", koerper: eingabe) + } + + func loescheErkennungswort(_ id: Int) async throws { + try await schickeStumm(.delete, "categories/keywords/\(id)") + } + + /// Wörter, die oft wieder korrigiert werden – zur Prüfung gemeldet. + func markierteErkennungswoerter() async throws -> [MarkiertesWort] { + try await hole("categories/keywords/flagged") + } + + /// „Behalten": Das Wort stimmt, die Korrekturen waren Einzelfälle. + func bestaetigeErkennungswort(_ id: Int) async throws { + try await schickeStumm(.post, "categories/keywords/\(id)/confirm") + } + + // MARK: - Zweiter Faktor + + func zweiterFaktorStand() async throws -> ZweiterFaktorStand { + try await hole("auth/2fa/status") + } + + /// Legt ein noch **nicht** aktives Geheimnis an. Gültig wird es erst mit + /// `aktiviereZweitenFaktor`. + func richteZweitenFaktorEin() async throws -> ZweiterFaktorEinrichtung { + try await schicke(.post, "auth/2fa/setup") + } + + /// Prüft einen Code aus der Authenticator-App und schaltet scharf. + /// Die Rettungscodes kommen dabei **einmal** – danach nie wieder. + func aktiviereZweitenFaktor(code: String) async throws -> Rettungscodes { + try await schicke(.post, "auth/2fa/activate", + koerper: ZweiterFaktorCode(code: code)) + } + + // MARK: - Bank + + func bankverbindungen() async throws -> [Bankverbindung] { + try await hole("bank/connections") + } + + /// Holt die Umsätze dieser Verbindung in den Eingang. + func rufeBankAb(_ verbindung: Int) async throws -> Abrufergebnis { + try await schicke(.post, "bank/connections/\(verbindung)/sync") + } + + /// Nur die Salden auffrischen, ohne Umsätze zu holen. + func frischeSaldenAuf(_ verbindung: Int) async throws { + try await schickeStumm(.post, "bank/connections/\(verbindung)/salden") + } } diff --git a/ios/Tests/GrundlagenTests.swift b/ios/Tests/GrundlagenTests.swift index 83bb7ac..9220720 100644 --- a/ios/Tests/GrundlagenTests.swift +++ b/ios/Tests/GrundlagenTests.swift @@ -744,3 +744,77 @@ extension ServerantwortenTests { #expect(ids.count == vorschlaege.vorschlaege.count) } } + +// MARK: - Nebenraeume + +@Suite("Nebenraeume") +struct NebenraeumeTests { + + private func felder(_ wert: A) throws -> [String: Any] { + let roh = try JSONEncoder().encode(wert) + return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any]) + } + + @Test("Eine Teilaenderung an Kategorie und Tag bleibt teilweise") + func teilaenderungen() throws { + // Dieselbe Falle wie bei Buchung und Konto: Ein nicht angefasstes + // Feld als null zu schicken loeschte Symbol, Farbe und Gruppe mit. + #expect(try felder(KategorieAendern(isArchived: true)).keys.sorted() + == ["is_archived"]) + #expect(try felder(TagAendern(color: "#2a78d6")).keys.sorted() == ["color"]) + } + + @Test("Die Feldnamen der Kategorie sind die des Backends") + func kategorieFeldnamen() throws { + let neu = try felder(KategorieAnlegen(groupId: 2, name: "Spartopf", + spendingIsGood: true)) + #expect(neu["group_id"] as? Int == 2) + #expect(neu["spending_is_good"] as? Bool == true) + #expect(neu["excluded_from_stats"] as? Bool == false) + } + + @Test("Ein Abruf ohne zugeordnetes Konto sagt das auch") + func abrufOhneZuordnung() throws { + // Sonst meldet die App "0 Umsaetze geholt" und der Nutzer sucht den + // Fehler bei der Bank statt bei der fehlenden Zuordnung. + let roh = """ + {"geholt": 0, "neu": 0, "saldi": {}, "ohne_zuordnung": true} + """ + let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8)) + #expect(ergebnis.ohneZuordnung == true) + #expect(ergebnis.meldung != String(format: String(localized: "bank.abrufErgebnis"), 0, 0)) + } + + @Test("Ein voller Abruf liest sich trotz der vielen Felder") + func vollerAbruf() throws { + let roh = """ + {"uebertraege": 1, "geholt": 42, "neu": 7, "gebucht": 5, "vorgemerkt": 2, + "doppelt": 0, "saldi": {}, "ab": "2026-10-01", "ohne_zuordnung": false} + """ + let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8)) + #expect(ergebnis.geholt == 42 && ergebnis.neu == 7) + #expect(ergebnis.uebertraege == 1) + } +} + +extension ServerantwortenTests { + + @Test("Erkennungswoerter, zweiter Faktor und Bankverbindungen") + func nebenraeume() throws { + let woerter = try lies([Erkennungswort].self, "schlagwoerter_der_kategorie") + #expect(!woerter.isEmpty) + #expect(woerter.allSatisfy { !$0.word.isEmpty }) + + // Leer, solange niemand oft genug korrigiert hat - der Typ muss sich + // trotzdem lesen lassen. + _ = try lies([MarkiertesWort].self, "markierteSchlagwoerter") + + let faktor = try lies(ZweiterFaktorStand.self, "zweiterFaktor") + #expect(faktor.enabled == false) + + // Bleibt leer: Eine Anbindung laesst sich ohne echten Anbieter nicht + // herstellen. Die Felder von Bankverbindung deckt dieser Test NICHT + // ab - wer sie aendert, prueft von Hand. + #expect(try lies([Bankverbindung].self, "bankverbindungen").isEmpty) + } +} diff --git a/ios/Texte/de.lproj/Localizable.strings b/ios/Texte/de.lproj/Localizable.strings index 48022a8..cbe4e4a 100644 --- a/ios/Texte/de.lproj/Localizable.strings +++ b/ios/Texte/de.lproj/Localizable.strings @@ -11,6 +11,9 @@ "allgemein.unbekannt" = "Unbekannt"; "allgemein.sichern" = "Sichern"; "allgemein.ok" = "OK"; +"allgemein.fertig" = "Fertig"; +"allgemein.bearbeiten" = "Bearbeiten"; +"allgemein.loeschen" = "Löschen"; /* --- Reiter --- */ "reiter.dashboard" = "Übersicht"; @@ -271,3 +274,87 @@ "eingang.stand.offen" = "offen"; "eingang.stand.unklar" = "unklar"; "eingang.stand.verworfen" = "verworfen"; + +/* --- Nebenräume --- */ +"nebenraeume.titel" = "Verwalten"; +"nebenraeume.kategorien" = "Kategorien"; +"nebenraeume.tags" = "Schlagwörter"; +"nebenraeume.bank" = "Bankanbindung"; + +/* --- Kategorien --- */ +"kategorien.leer" = "Noch keine Kategorien."; +"kategorien.neu" = "Neue Kategorie"; +"kategorien.aendern" = "Kategorie ändern"; +"kategorien.neueGruppe" = "Neue Gruppe"; +"kategorien.gruppe" = "Gruppe"; +"kategorien.art" = "Art"; +"kategorien.ausgaben" = "Ausgaben"; +"kategorien.einnahmen" = "Einnahmen"; +"kategorien.archivierteZeigen" = "Archivierte zeigen"; +"kategorien.sparen" = "Sparen"; +"kategorien.ohneAuswertung" = "ohne Auswertung"; +"kategorien.istSparen" = "Ausgabe, die kein Verlust ist"; +"kategorien.istSparenErklaerung" = "Für Sparen und Rücklagen. Mehr auszugeben als geplant ist hier keine Warnung, sondern gut."; +"kategorien.ohneAuswertungSchalter" = "Aus Auswertungen heraushalten"; +"kategorien.ohneAuswertungErklaerung" = "Für durchlaufende Posten. Die Buchungen bleiben, zählen aber in keinem Diagramm und keiner Summe mit."; +"kategorien.loeschen" = "Kategorie löschen"; +"kategorien.loeschenFrage" = "Diese Kategorie löschen?"; +"kategorien.loeschenErklaerung" = "Das geht nur, solange keine Buchung daran hängt."; +"kategorien.archivierenStattLoeschen" = "Archivieren blendet die Kategorie aus und behält ihre Buchungen. Löschen geht nur, solange keine daran hängt."; +"kategorien.gruppeLoeschen" = "Gruppe löschen"; +"kategorien.gruppeLoeschenFrage" = "Diese Gruppe löschen?"; +"kategorien.gruppeLoeschenErklaerung" = "Das geht nur, solange keine Kategorie mehr darin steht."; +"kategorien.erkennungswoerter" = "Erkennungswörter"; +"kategorien.wortHinzufuegen" = "Wort hinzufügen"; +"kategorien.erkennungErklaerung" = "Trifft eines dieser Wörter auf Empfänger oder Verwendungszweck zu, ordnet der Import diese Kategorie zu. Das längste Wort gewinnt. Wirkt nur auf künftige Importe - an bestehenden Buchungen ändert sich nichts."; +"kategorien.markiertTitel" = "Wörter, die oft korrigiert werden"; +"kategorien.markiertErklaerung" = "Diese Wörter ordnen häufig falsch zu. Behalten, wenn es Einzelfälle waren - entfernen, wenn das Wort nicht taugt."; +"kategorien.markiertZaehler" = "%1$d-mal angewendet, %2$d-mal korrigiert"; +"kategorien.behalten" = "Behalten"; +"kategorien.entfernen" = "Entfernen"; + +/* --- Schlagwörter (Tags) --- */ +"tags.neu" = "Neues Schlagwort"; +"tags.erklaerung" = "Schlagwörter liegen quer zur Kategorie: Eine Buchung kann mehrere tragen. Gut für alles, was sich nicht einordnen lässt - Urlaub Island, steuerlich absetzbar, zurückfordern."; + +/* --- Einstellungen --- */ +"einstellungen.erscheinungsbild" = "Erscheinungsbild"; +"einstellungen.system" = "System"; +"einstellungen.hell" = "Hell"; +"einstellungen.dunkel" = "Dunkel"; +"einstellungen.serverweitErklaerung" = "Diese Einstellungen gelten für dein Konto, nicht nur für dieses Gerät - auch im Browser."; + +/* --- Zweiter Faktor einrichten --- */ +"zweifaktor.stand" = "Zweiter Faktor"; +"zweifaktor.aktiv" = "Aktiv"; +"zweifaktor.inaktiv" = "Nicht eingerichtet"; +"zweifaktor.aktivErklaerung" = "Beim Anmelden im Browser wird zusätzlich ein Code verlangt. Abschalten geht nur am Rechner - es verlangt dein Passwort, und das speichert diese App bewusst nicht."; +"zweifaktor.inaktivErklaerung" = "Ein zweiter Faktor schützt dein Konto, falls jemand dein Passwort kennt."; +"zweifaktor.einrichten" = "Einrichten"; +"zweifaktor.schrittEins" = "Schritt 1: In die Authenticator-App übernehmen"; +"zweifaktor.schrittZwei" = "Schritt 2: Mit einem Code bestätigen"; +"zweifaktor.inAppOeffnen" = "In der Authenticator-App öffnen"; +"zweifaktor.geheimnis" = "Geheimnis"; +"zweifaktor.geheimnisErklaerung" = "Der Knopf öffnet die App direkt. Geht das nicht, trägst du das Geheimnis von Hand ein."; +"zweifaktor.code" = "Sechsstelliger Code"; +"zweifaktor.aktivieren" = "Scharfschalten"; +"zweifaktor.rettungscodes" = "Rettungscodes"; +"zweifaktor.rettungscodesErklaerung" = "Jeder Code lässt sich einmal statt eines App-Codes verwenden. Sie stehen genau hier und nie wieder - sichere sie jetzt."; +"zweifaktor.codesSichern" = "Codes sichern"; +"zweifaktor.codesNotiert" = "Ich habe sie gesichert"; + +/* --- Bank --- */ +"bank.leerTitel" = "Keine Bankanbindung"; +"bank.leerErklaerung" = "Eine Bank verbinden geht nur am Rechner - die Bank leitet dorthin zurück. Abrufen kannst du sie danach von hier."; +"bank.land" = "Land"; +"bank.zuletzt" = "Zuletzt abgerufen"; +"bank.nie" = "noch nie"; +"bank.gueltigBis" = "Gültig bis"; +"bank.takt" = "Automatisch"; +"bank.alleNMinuten" = "alle %d Minuten"; +"bank.abrufen" = "Jetzt abrufen"; +"bank.saldenAuffrischen" = "Nur Salden auffrischen"; +"bank.saldenAufgefrischt" = "Die Salden sind aufgefrischt."; +"bank.abrufFertig" = "Abruf fertig"; +"bank.abrufErgebnis" = "%1$d Umsätze geholt, davon %2$d neu."; +"bank.ohneZuordnung" = "Dieser Anbindung ist noch kein Konto zugeordnet. Das geht am Rechner."; diff --git a/ios/Texte/en.lproj/Localizable.strings b/ios/Texte/en.lproj/Localizable.strings index 2a113d1..33f7f07 100644 --- a/ios/Texte/en.lproj/Localizable.strings +++ b/ios/Texte/en.lproj/Localizable.strings @@ -11,6 +11,9 @@ "allgemein.unbekannt" = "Unknown"; "allgemein.sichern" = "Save"; "allgemein.ok" = "OK"; +"allgemein.fertig" = "Done"; +"allgemein.bearbeiten" = "Edit"; +"allgemein.loeschen" = "Delete"; /* --- Tabs --- */ "reiter.dashboard" = "Overview"; @@ -271,3 +274,87 @@ "eingang.stand.offen" = "pending"; "eingang.stand.unklar" = "unclear"; "eingang.stand.verworfen" = "discarded"; + +/* --- Manage --- */ +"nebenraeume.titel" = "Manage"; +"nebenraeume.kategorien" = "Categories"; +"nebenraeume.tags" = "Tags"; +"nebenraeume.bank" = "Bank connection"; + +/* --- Categories --- */ +"kategorien.leer" = "No categories yet."; +"kategorien.neu" = "New category"; +"kategorien.aendern" = "Edit category"; +"kategorien.neueGruppe" = "New group"; +"kategorien.gruppe" = "Group"; +"kategorien.art" = "Kind"; +"kategorien.ausgaben" = "Expenses"; +"kategorien.einnahmen" = "Income"; +"kategorien.archivierteZeigen" = "Show archived"; +"kategorien.sparen" = "saving"; +"kategorien.ohneAuswertung" = "not counted"; +"kategorien.istSparen" = "Spending that is not a loss"; +"kategorien.istSparenErklaerung" = "For savings and reserves. Spending more than planned is no warning here - it is good news."; +"kategorien.ohneAuswertungSchalter" = "Keep out of reports"; +"kategorien.ohneAuswertungErklaerung" = "For pass-through items. The transactions stay but count in no chart and no total."; +"kategorien.loeschen" = "Delete category"; +"kategorien.loeschenFrage" = "Delete this category?"; +"kategorien.loeschenErklaerung" = "Only possible while no transaction is attached."; +"kategorien.archivierenStattLoeschen" = "Archiving hides the category and keeps its transactions. Deleting only works while none are attached."; +"kategorien.gruppeLoeschen" = "Delete group"; +"kategorien.gruppeLoeschenFrage" = "Delete this group?"; +"kategorien.gruppeLoeschenErklaerung" = "Only possible while no category is left in it."; +"kategorien.erkennungswoerter" = "Match words"; +"kategorien.wortHinzufuegen" = "Add a word"; +"kategorien.erkennungErklaerung" = "If one of these words appears in the payee or description, the importer assigns this category. The longest word wins. Applies to future imports only - existing transactions stay as they are."; +"kategorien.markiertTitel" = "Words that get corrected a lot"; +"kategorien.markiertErklaerung" = "These words often assign the wrong category. Keep them if those were one-offs - remove them if the word does not work."; +"kategorien.markiertZaehler" = "applied %1$d times, corrected %2$d times"; +"kategorien.behalten" = "Keep"; +"kategorien.entfernen" = "Remove"; + +/* --- Tags --- */ +"tags.neu" = "New tag"; +"tags.erklaerung" = "Tags sit across categories: one transaction can carry several. Good for everything that does not fit a category - Iceland trip, tax deductible, to be reimbursed."; + +/* --- Settings --- */ +"einstellungen.erscheinungsbild" = "Appearance"; +"einstellungen.system" = "System"; +"einstellungen.hell" = "Light"; +"einstellungen.dunkel" = "Dark"; +"einstellungen.serverweitErklaerung" = "These settings belong to your account, not just this device - the browser follows them too."; + +/* --- Two-factor setup --- */ +"zweifaktor.stand" = "Two-factor"; +"zweifaktor.aktiv" = "Active"; +"zweifaktor.inaktiv" = "Not set up"; +"zweifaktor.aktivErklaerung" = "Signing in through the browser asks for a code as well. Turning it off only works on the desktop - it needs your password, and this app deliberately stores none."; +"zweifaktor.inaktivErklaerung" = "A second factor protects your account even if someone knows your password."; +"zweifaktor.einrichten" = "Set up"; +"zweifaktor.schrittEins" = "Step 1: add it to your authenticator app"; +"zweifaktor.schrittZwei" = "Step 2: confirm with a code"; +"zweifaktor.inAppOeffnen" = "Open in authenticator app"; +"zweifaktor.geheimnis" = "Secret"; +"zweifaktor.geheimnisErklaerung" = "The button opens the app directly. If that does not work, type the secret in by hand."; +"zweifaktor.code" = "Six-digit code"; +"zweifaktor.aktivieren" = "Turn on"; +"zweifaktor.rettungscodes" = "Recovery codes"; +"zweifaktor.rettungscodesErklaerung" = "Each code works once instead of an app code. They appear here and nowhere else - save them now."; +"zweifaktor.codesSichern" = "Save codes"; +"zweifaktor.codesNotiert" = "I have saved them"; + +/* --- Bank --- */ +"bank.leerTitel" = "No bank connection"; +"bank.leerErklaerung" = "Connecting a bank only works on the desktop - the bank redirects there. You can fetch from here afterwards."; +"bank.land" = "Country"; +"bank.zuletzt" = "Last fetched"; +"bank.nie" = "never"; +"bank.gueltigBis" = "Valid until"; +"bank.takt" = "Automatically"; +"bank.alleNMinuten" = "every %d minutes"; +"bank.abrufen" = "Fetch now"; +"bank.saldenAuffrischen" = "Refresh balances only"; +"bank.saldenAufgefrischt" = "Balances refreshed."; +"bank.abrufFertig" = "Fetch complete"; +"bank.abrufErgebnis" = "Fetched %1$d transactions, %2$d of them new."; +"bank.ohneZuordnung" = "This connection has no account assigned yet. That is done on the desktop."; diff --git a/ios/project.yml b/ios/project.yml index 8324cdc..c84b466 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -28,6 +28,7 @@ targets: - path: Einstellungen - path: Konten - path: Modelle + - path: Nebenraeume - path: Netzwerk # Deutsch und Englisch, zeilengleich gehalten. - path: Texte