From d290930759c52315f71edb665da5c8882c457217 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 21 Oct 2026 22:03:28 +0200 Subject: [PATCH] feat: Fuenfte Stufe der iOS-App - Kategorien, Schlagwoerter, Bank, zweiter Faktor Die Nebenraeume haengen unter Einstellungen, nicht als eigene Reiter: Man braucht sie selten, und eine Leiste mit zehn Reitern findet niemand mehr. - Kategorien: anlegen, aendern, archivieren, loeschen - samt Gruppen. Eine Kategorie oeffnet ihre Erkennungswoerter, die sich dort hinzufuegen und entfernen lassen. Darunter steht, was sonst niemand merkt: Sie wirken nur auf kuenftige Importe. - Woerter, die oft korrigiert werden, stehen als Pruefmeldung in den Einstellungen - behalten oder entfernen. - Schlagwoerter: anlegen, umbenennen, umfaerben, loeschen. Neue bekommen reihum eine Farbe, damit nicht alle grau sind. - Einstellungen: Erscheinungsbild, Hauptwaehrung und Budget-Starttag lassen sich jetzt aendern. Ohne Sichern-Knopf - es ist je Zeile genau ein Wert, und das Backend nimmt ihn einzeln entgegen. Dazu der Hinweis, dass sie dem Konto gehoeren und nicht dem Geraet. - Bank: abrufen und Salden auffrischen. Verbinden bleibt am Rechner, die Bank leitet fest dorthin zurueck. Der letzte Fehler der Verbindung steht dabei, sonst ruft man vergeblich ab. - Zweiter Faktor: einrichten in drei Schritten. Auf dem Telefon fuehrt der Weg ueber die otpauth-Adresse direkt in die Authenticator-App statt ueber einen QR-Code, den man sich selbst zeigen muesste. Die Rettungscodes erscheinen einmal; das Blatt laesst sich nicht wegwischen und erst schliessen, wenn man bestaetigt hat, sie gesichert zu haben. Abschalten des zweiten Faktors bleibt bewusst draussen: Es verlangt das Kontopasswort, und die App speichert keines. Ein Vollzugang auf diesem Geraet soll den Schutz des Kontos nicht aufheben koennen. 59 Tests gruen. Texte in Deutsch und Englisch, zeilengleich. Co-Authored-By: Claude Opus 5 --- ios/Certain.xcodeproj/project.pbxproj | 32 ++ ios/Einstellungen/EinstellungenAnsicht.swift | 167 +++++++++- ios/Modelle/Nebenraeume.swift | 218 +++++++++++++ ios/Nebenraeume/BankAnsicht.swift | 146 +++++++++ ios/Nebenraeume/KategorieFormular.swift | 307 +++++++++++++++++++ ios/Nebenraeume/KategorienAnsicht.swift | 250 +++++++++++++++ ios/Nebenraeume/TagsAnsicht.swift | 176 +++++++++++ ios/Nebenraeume/ZweiterFaktorAnsicht.swift | 190 ++++++++++++ ios/Netzwerk/Endpunkte.swift | 102 ++++++ ios/Tests/GrundlagenTests.swift | 74 +++++ ios/Texte/de.lproj/Localizable.strings | 87 ++++++ ios/Texte/en.lproj/Localizable.strings | 87 ++++++ ios/project.yml | 1 + 13 files changed, 1828 insertions(+), 9 deletions(-) create mode 100644 ios/Modelle/Nebenraeume.swift create mode 100644 ios/Nebenraeume/BankAnsicht.swift create mode 100644 ios/Nebenraeume/KategorieFormular.swift create mode 100644 ios/Nebenraeume/KategorienAnsicht.swift create mode 100644 ios/Nebenraeume/TagsAnsicht.swift create mode 100644 ios/Nebenraeume/ZweiterFaktorAnsicht.swift 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