From 6b86a6bf91013bd9aa2641b95eec4c3dade894c0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 20 Oct 2026 16:20:03 +0200 Subject: [PATCH] feat: Erste Stufe der iOS-App - Anmeldung und Kontenliste Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage, Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste Fachansicht die Kontenliste. Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen. Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt. Co-Authored-By: Claude Opus 5 --- ios/Anmeldung/AnmeldeAnsicht.swift | 79 +++++ ios/Anmeldung/PasswortWechselAnsicht.swift | 93 ++++++ ios/Anmeldung/ServerAnsicht.swift | 58 ++++ ios/Anmeldung/ZweiFaktorAnsicht.swift | 123 ++++++++ ios/App/AppZustand.swift | 255 ++++++++++++++++ ios/App/CertainApp.swift | 24 ++ ios/App/RootAnsicht.swift | 53 ++++ ios/Bausteine/Darstellung.swift | 109 +++++++ ios/Bausteine/Geldbetrag.swift | 118 +++++++ ios/Bausteine/Zustandsansichten.swift | 94 ++++++ ios/Einstellungen/EinstellungenAnsicht.swift | 52 ++++ ios/Konten/KontenListe.swift | 155 ++++++++++ ios/Modelle/Grundmodelle.swift | 145 +++++++++ ios/Modelle/Konto.swift | 87 ++++++ ios/Modelle/Zeitangaben.swift | 127 ++++++++ ios/Netzwerk/APIFehler.swift | 106 +++++++ ios/Netzwerk/Ablage.swift | 126 ++++++++ ios/Netzwerk/CertainClient.swift | 235 ++++++++++++++ ios/Netzwerk/Endpunkte.swift | 98 ++++++ ios/Netzwerk/Lader.swift | 57 ++++ ios/Netzwerk/Zwischenspeicher.swift | 83 +++++ ios/README.md | 119 ++++++++ ios/Tests/GrundlagenTests.swift | 304 +++++++++++++++++++ ios/Texte/de.lproj/Localizable.strings | 104 +++++++ ios/Texte/en.lproj/Localizable.strings | 104 +++++++ 25 files changed, 2908 insertions(+) create mode 100644 ios/Anmeldung/AnmeldeAnsicht.swift create mode 100644 ios/Anmeldung/PasswortWechselAnsicht.swift create mode 100644 ios/Anmeldung/ServerAnsicht.swift create mode 100644 ios/Anmeldung/ZweiFaktorAnsicht.swift create mode 100644 ios/App/AppZustand.swift create mode 100644 ios/App/CertainApp.swift create mode 100644 ios/App/RootAnsicht.swift create mode 100644 ios/Bausteine/Darstellung.swift create mode 100644 ios/Bausteine/Geldbetrag.swift create mode 100644 ios/Bausteine/Zustandsansichten.swift create mode 100644 ios/Einstellungen/EinstellungenAnsicht.swift create mode 100644 ios/Konten/KontenListe.swift create mode 100644 ios/Modelle/Grundmodelle.swift create mode 100644 ios/Modelle/Konto.swift create mode 100644 ios/Modelle/Zeitangaben.swift create mode 100644 ios/Netzwerk/APIFehler.swift create mode 100644 ios/Netzwerk/Ablage.swift create mode 100644 ios/Netzwerk/CertainClient.swift create mode 100644 ios/Netzwerk/Endpunkte.swift create mode 100644 ios/Netzwerk/Lader.swift create mode 100644 ios/Netzwerk/Zwischenspeicher.swift create mode 100644 ios/README.md create mode 100644 ios/Tests/GrundlagenTests.swift create mode 100644 ios/Texte/de.lproj/Localizable.strings create mode 100644 ios/Texte/en.lproj/Localizable.strings diff --git a/ios/Anmeldung/AnmeldeAnsicht.swift b/ios/Anmeldung/AnmeldeAnsicht.swift new file mode 100644 index 0000000..009c307 --- /dev/null +++ b/ios/Anmeldung/AnmeldeAnsicht.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Benutzername und Passwort – einmal. Danach trägt die Gerätemarke, und +/// das Passwort wird nirgends gespeichert. +struct AnmeldeAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var benutzer = Ablage.benutzername ?? "" + @State private var passwort = "" + @FocusState private var feld: Feld? + + private enum Feld { case benutzer, passwort } + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "anmeldung.benutzer"), text: $benutzer) + .textContentType(.username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($feld, equals: .benutzer) + .submitLabel(.next) + .onSubmit { feld = .passwort } + + SecureField(String(localized: "anmeldung.passwort"), text: $passwort) + .textContentType(.password) + .focused($feld, equals: .passwort) + .submitLabel(.go) + .onSubmit { melde() } + } footer: { + Text(String(localized: "anmeldung.erklaerung")) + } + + if let meldung = zustand.meldung { + Section { + Label(meldung, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + + Section { + Button(action: melde) { + if zustand.beschaeftigt { + ProgressView() + } else { + Text(String(localized: "anmeldung.anmelden")) + } + } + .disabled(bereit == false || zustand.beschaeftigt) + } + + Section { + Button(String(localized: "anmeldung.andererServer"), role: .destructive) { + Task { await zustand.meldeAb(adresseBehalten: false) } + } + } footer: { + if let adresse = zustand.serverAdresse { + Text(String(format: String(localized: "anmeldung.aktuellerServer"), + adresse.host() ?? adresse.absoluteString)) + } + } + } + .navigationTitle(String(localized: "anmeldung.titel")) + .onAppear { feld = benutzer.isEmpty ? .benutzer : .passwort } + } + } + + private var bereit: Bool { + !benutzer.trimmingCharacters(in: .whitespaces).isEmpty && !passwort.isEmpty + } + + private func melde() { + guard bereit else { return } + let name = benutzer.trimmingCharacters(in: .whitespaces) + let geheim = passwort + passwort = "" + Task { await zustand.melde(benutzer: name, passwort: geheim) } + } +} diff --git a/ios/Anmeldung/PasswortWechselAnsicht.swift b/ios/Anmeldung/PasswortWechselAnsicht.swift new file mode 100644 index 0000000..55ccd66 --- /dev/null +++ b/ios/Anmeldung/PasswortWechselAnsicht.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// Pflicht-Passwortwechsel. Solange der offen ist, sperrt das Backend jeden +/// fachlichen Endpunkt mit 403 – auch das Anlegen der Gerätemarke. Deshalb +/// steht dieser Bildschirm vor allem anderen. +/// +/// Die Richtlinie des Servers (Mindestlänge, Zeichenarten) prüfen wir hier +/// nicht nach. Das Backend kennt sie, und sie doppelt zu pflegen hiesse, sie +/// irgendwann unterschiedlich zu haben. Es meldet den Grund im Klartext. +struct PasswortWechselAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var alt = "" + @State private var neu = "" + @State private var wiederholung = "" + @FocusState private var feld: Feld? + + private enum Feld { case alt, neu, wiederholung } + + var body: some View { + NavigationStack { + Form { + Section { + SecureField(String(localized: "passwort.bisher"), text: $alt) + .textContentType(.password) + .focused($feld, equals: .alt) + .submitLabel(.next) + .onSubmit { feld = .neu } + } footer: { + Text(String(localized: "passwort.erklaerung")) + } + + Section { + SecureField(String(localized: "passwort.neu"), text: $neu) + .textContentType(.newPassword) + .focused($feld, equals: .neu) + .submitLabel(.next) + .onSubmit { feld = .wiederholung } + + SecureField(String(localized: "passwort.wiederholen"), text: $wiederholung) + .textContentType(.newPassword) + .focused($feld, equals: .wiederholung) + .submitLabel(.go) + .onSubmit { wechsle() } + } footer: { + if !wiederholung.isEmpty, neu != wiederholung { + Text(String(localized: "passwort.stimmtNichtUeberein")) + .foregroundStyle(.red) + } + } + + if let meldung = zustand.meldung { + Section { + Label(meldung, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + + Section { + Button(action: wechsle) { + if zustand.beschaeftigt { + ProgressView() + } else { + Text(String(localized: "passwort.speichern")) + } + } + .disabled(bereit == false || zustand.beschaeftigt) + } + + Section { + Button(String(localized: "allgemein.abmelden"), role: .destructive) { + Task { await zustand.meldeAb() } + } + } + } + .navigationTitle(String(localized: "passwort.titel")) + .onAppear { feld = .alt } + } + } + + private var bereit: Bool { + !alt.isEmpty && !neu.isEmpty && neu == wiederholung + } + + private func wechsle() { + guard bereit else { return } + let bisher = alt + let frisch = neu + Task { + await zustand.wechslePasswort(alt: bisher, neu: frisch) + alt = ""; neu = ""; wiederholung = "" + } + } +} diff --git a/ios/Anmeldung/ServerAnsicht.swift b/ios/Anmeldung/ServerAnsicht.swift new file mode 100644 index 0000000..4901cd1 --- /dev/null +++ b/ios/Anmeldung/ServerAnsicht.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// Der allererste Bildschirm: Wo steht dein Certain? +/// +/// Die Adresse wird nicht blind übernommen, sondern mit `GET /api/version` +/// geprüft. Das erspart die Sorte Fehlersuche, bei der man sich eine Stunde +/// fragt, warum die Anmeldung nicht geht, und am Ende ein Buchstabe fehlte. +struct ServerAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var eingabe = "" + @FocusState private var imFeld: Bool + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "server.platzhalter"), text: $eingabe) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($imFeld) + .submitLabel(.go) + .onSubmit { pruefe() } + } header: { + Text(String(localized: "server.ueberschrift")) + } footer: { + Text(String(localized: "server.erklaerung")) + } + + if let meldung = zustand.meldung { + Section { + Label(meldung, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + + Section { + Button(action: pruefe) { + if zustand.beschaeftigt { + ProgressView() + } else { + Text(String(localized: "server.verbinden")) + } + } + .disabled(eingabe.trimmingCharacters(in: .whitespaces).isEmpty + || zustand.beschaeftigt) + } + } + .navigationTitle(String(localized: "server.titel")) + .onAppear { imFeld = true } + } + } + + private func pruefe() { + Task { _ = await zustand.pruefeUndMerkeServer(eingabe) } + } +} diff --git a/ios/Anmeldung/ZweiFaktorAnsicht.swift b/ios/Anmeldung/ZweiFaktorAnsicht.swift new file mode 100644 index 0000000..250ad8a --- /dev/null +++ b/ios/Anmeldung/ZweiFaktorAnsicht.swift @@ -0,0 +1,123 @@ +import SwiftUI + +/// Der zweite Faktor. Entweder sechs Ziffern aus der Authenticator-App oder +/// ein Wiederherstellungscode der Form `ABCDE-FGHIJ`. +/// +/// Fünf Fehlversuche, dann ist die Zwischensitzung verbraucht und es geht +/// zurück zur Anmeldung – so hält es das Backend +/// (`backend/app/services/sessions.py`, `MAX_2FA_ATTEMPTS`). +struct ZweiFaktorAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var code = "" + @State private var mitWiederherstellung = false + @FocusState private var imFeld: Bool + + var body: some View { + NavigationStack { + Form { + Section { + TextField(platzhalter, text: $code) + .textContentType(mitWiederherstellung ? .none : .oneTimeCode) + .keyboardType(mitWiederherstellung ? .asciiCapable : .numberPad) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .monospaced() + .focused($imFeld) + .submitLabel(.go) + .onSubmit { pruefe() } + .onChange(of: code) { _, neu in + // Sechs Ziffern aus der App: direkt absenden, das + // spart den Griff zur Tastatur. + if !mitWiederherstellung, neu.count == 6, neu.allSatisfy(\.isNumber) { + pruefe() + } + } + } header: { + Text(String(localized: "zweifaktor.ueberschrift")) + } footer: { + Text(mitWiederherstellung + ? String(localized: "zweifaktor.erklaerungWiederherstellung") + : String(localized: "zweifaktor.erklaerungCode")) + } + + if let meldung = zustand.meldung { + Section { + Label(meldung, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + + Section { + Button(action: pruefe) { + if zustand.beschaeftigt { + ProgressView() + } else { + Text(String(localized: "zweifaktor.bestaetigen")) + } + } + .disabled(code.isEmpty || zustand.beschaeftigt) + + Button(mitWiederherstellung + ? String(localized: "zweifaktor.dochCode") + : String(localized: "zweifaktor.dochWiederherstellung")) { + mitWiederherstellung.toggle() + code = "" + imFeld = true + } + } + + Section { + Button(String(localized: "zweifaktor.abbrechen"), role: .destructive) { + Task { await zustand.meldeAb() } + } + } + } + .navigationTitle(String(localized: "zweifaktor.titel")) + .onAppear { imFeld = true } + } + } + + private var platzhalter: String { + mitWiederherstellung + ? String(localized: "zweifaktor.platzhalterWiederherstellung") + : String(localized: "zweifaktor.platzhalterCode") + } + + private func pruefe() { + let eingabe = code.trimmingCharacters(in: .whitespaces) + guard !eingabe.isEmpty else { return } + Task { + await zustand.pruefeZweitenFaktor( + code: eingabe, wiederherstellung: mitWiederherstellung) + code = "" + } + } +} + +/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen. +/// Einrichten geht in Stufe 1 noch nicht in der App – also sagen wir das +/// klar, statt den Nutzer gegen eine Wand laufen zu lassen. +struct ZweitenFaktorEinrichtenHinweis: View { + @Environment(AppZustand.self) private var zustand + + var body: some View { + NavigationStack { + ContentUnavailableView { + Label(String(localized: "zweifaktorEinrichten.titel"), + systemImage: "lock.shield") + } description: { + Text(String(localized: "zweifaktorEinrichten.erklaerung")) + } actions: { + if let adresse = zustand.serverAdresse { + Link(String(localized: "zweifaktorEinrichten.imBrowser"), + destination: adresse) + .buttonStyle(.borderedProminent) + } + Button(String(localized: "allgemein.abmelden"), role: .destructive) { + Task { await zustand.meldeAb() } + } + } + .navigationTitle(String(localized: "zweifaktorEinrichten.titel")) + } + } +} diff --git a/ios/App/AppZustand.swift b/ios/App/AppZustand.swift new file mode 100644 index 0000000..c5789ee --- /dev/null +++ b/ios/App/AppZustand.swift @@ -0,0 +1,255 @@ +import Foundation +import Observation +#if canImport(UIKit) +import UIKit +#endif + +/// Wo die App gerade steht – und damit, welcher Bildschirm zu sehen ist. +@MainActor +@Observable +final class AppZustand { + + enum Lage: Equatable { + /// Beim Start: Gibt es eine Adresse, gibt es eine Marke, gilt sie noch? + case startet + /// Keine Server-Adresse hinterlegt. + case brauchtServer + /// Adresse steht, aber keine gültige Marke. + case brauchtAnmeldung + /// Passwort stimmt, der Code fehlt noch. + case brauchtZweitenFaktor + /// Das Konto muss erst sein Passwort wechseln. + case brauchtPasswortwechsel + /// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen. + /// Einrichten geht in Stufe 1 noch nicht in der App. + case brauchtZweitenFaktorEinrichtung + /// Alles gut. + case bereit(Benutzer) + } + + private(set) var lage: Lage = .startet + private(set) var einstellungen = Einstellungen() + private(set) var client: CertainClient? + + /// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht, + /// sondern eine Zeile unter dem Formular. + var meldung: String? + /// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe. + private(set) var beschaeftigt = false + + var serverAdresse: URL? { Ablage.serverAdresse } + + // MARK: - Start + + func starte() async { + guard let adresse = Ablage.serverAdresse else { + lage = .brauchtServer + return + } + let neuer = CertainClient(basis: adresse, marke: Ablage.marke) + client = neuer + guard Ablage.marke != nil else { + lage = .brauchtAnmeldung + return + } + await ladeEigenenZustand() + } + + /// Fragt den Server, wer wir sind. Das ist zugleich die Prüfung, ob die + /// Marke noch gilt. + private func ladeEigenenZustand() async { + guard let client else { return } + do { + let ich = try await client.ichSelbst() + einstellungen = (try? await client.einstellungen()) ?? Einstellungen() + lage = .bereit(ich) + } catch APIFehler.nichtAngemeldet { + // Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten. + Ablage.marke = nil + await client.setzeMarke(nil) + lage = .brauchtAnmeldung + } catch APIFehler.pflichtOffen(.passwortWechseln) { + lage = .brauchtPasswortwechsel + } catch APIFehler.pflichtOffen(.zweiterFaktor) { + lage = .brauchtZweitenFaktorEinrichtung + } catch { + // Offline beim Start: Wir lassen die App herein und zeigen in den + // Ansichten den letzten Stand. Die Marke ist ja da. + if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") { + lage = .bereit(gemerkt.wert) + } else { + meldung = error.localizedDescription + lage = .brauchtAnmeldung + } + } + } + + // MARK: - Server festlegen + + /// Nimmt, was der Nutzer tippt, und macht daraus eine brauchbare Adresse. + /// `budget.example.com` wird zu `https://budget.example.com`. + /// + /// `nonisolated`, weil es reine Textarbeit ist – so lässt es sich ohne + /// Hauptthread testen. + nonisolated static func deuteAdresse(_ eingabe: String) -> URL? { + var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if !text.contains("://") { text = "https://" + text } + // Ein angehängter Pfad hilft nicht – die API hängt immer unter /api. + guard var teile = URLComponents(string: text), teile.host?.isEmpty == false else { + return nil + } + teile.path = "" + teile.query = nil + teile.fragment = nil + return teile.url + } + + func pruefeUndMerkeServer(_ eingabe: String) async -> Bool { + guard let adresse = Self.deuteAdresse(eingabe) else { + meldung = String(localized: "server.keineAdresse") + return false + } + beschaeftigt = true + defer { beschaeftigt = false } + meldung = nil + + let versuch = CertainClient(basis: adresse) + do { + _ = try await versuch.serverkennung() + Ablage.serverAdresse = adresse + client = versuch + lage = .brauchtAnmeldung + return true + } catch { + meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription + return false + } + } + + // MARK: - Anmelden + + func melde(benutzer: String, passwort: String) async { + guard let client else { return } + beschaeftigt = true + defer { beschaeftigt = false } + meldung = nil + do { + let antwort = try await client.melde(benutzer: benutzer, passwort: passwort) + Ablage.benutzername = benutzer + if antwort.brauchtZweitenFaktor { + lage = .brauchtZweitenFaktor + return + } + await nachDerAnmeldung(antwort.user) + } catch { + meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription + } + } + + func pruefeZweitenFaktor(code: String, wiederherstellung: Bool) async { + guard let client else { return } + beschaeftigt = true + defer { beschaeftigt = false } + meldung = nil + do { + let antwort = try await client.pruefeZweitenFaktor( + code: code, wiederherstellung: wiederherstellung) + await nachDerAnmeldung(antwort.user) + } catch APIFehler.nichtAngemeldet { + // Zu viele Fehlversuche: Das Zwischen-Cookie ist weg. + meldung = String(localized: "zweifaktor.verbraucht") + lage = .brauchtAnmeldung + } catch { + meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription + } + } + + /// Nach geglückter Anmeldung: Marke holen und das Cookie wegwerfen. + private func nachDerAnmeldung(_ benutzer: Benutzer?) async { + guard let client else { return } + + // Offene Pflichten zuerst – mit ihnen lässt sich keine Marke anlegen, + // das Backend sperrt den Endpunkt (`enforce_account_state`). + if let benutzer, benutzer.mustChangePassword { + lage = .brauchtPasswortwechsel + return + } + if let benutzer, benutzer.mfaSetupRequired { + lage = .brauchtZweitenFaktorEinrichtung + return + } + + do { + let marke = try await client.legeAppMarkeAn(name: Self.geraetename) + guard let klartext = marke.token else { + meldung = String(localized: "anmeldung.markeOhneSchluessel") + return + } + Ablage.marke = klartext + await client.setzeMarke(klartext) + // Ab hier trägt die Marke. Das Cookie hat seinen Zweck erfüllt. + await client.vergissCookies() + await ladeEigenenZustand() + } catch { + meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription + } + } + + func wechslePasswort(alt: String, neu: String) async { + guard let client else { return } + beschaeftigt = true + defer { beschaeftigt = false } + meldung = nil + do { + let ich = try await client.wechslePasswort(alt: alt, neu: neu) + await nachDerAnmeldung(ich) + } catch { + meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription + } + } + + // MARK: - Abmelden + + /// Meldet ab und räumt auf. Die Marke wird dabei am Server widerrufen – + /// sonst bliebe sie dort stehen, bis jemand sie von Hand entfernt. + func meldeAb(adresseBehalten: Bool = true) async { + if let client, Ablage.marke != nil { + // Welche der Marken unsere ist, lässt sich nicht am Klartext + // ablesen – am Server liegt nur der Hash. Also über den Namen, + // unter dem wir sie angelegt haben. + let meine = (try? await client.marken())?.first { + $0.scope == "app" && $0.name == Self.geraetename + } + if let meine { try? await client.widerrufeMarke(id: meine.id) } + } + let adresse = adresseBehalten ? Ablage.serverAdresse : nil + Ablage.raeumeAuf() + Ablage.serverAdresse = adresse + if let adresse { + client = CertainClient(basis: adresse) + lage = .brauchtAnmeldung + } else { + client = nil + lage = .brauchtServer + } + } + + func aktualisiereEinstellungen() async { + guard let client else { return } + if let frisch = try? await client.einstellungen() { einstellungen = frisch } + } + + // MARK: - Kleinkram + + /// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar + /// machen, welches Gerät man widerruft. + static var geraetename: String { + #if canImport(UIKit) + let roh = UIDevice.current.name + return roh.isEmpty ? "iPhone" : roh + #else + return "iOS-Gerät" + #endif + } +} diff --git a/ios/App/CertainApp.swift b/ios/App/CertainApp.swift new file mode 100644 index 0000000..d31c2e6 --- /dev/null +++ b/ios/App/CertainApp.swift @@ -0,0 +1,24 @@ +import SwiftUI + +@main +struct CertainApp: App { + @State private var zustand = AppZustand() + + var body: some Scene { + // Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App + // und Weboberfläche gleich aussehen. „system" heisst: nichts vorgeben. + let schema: ColorScheme? + switch zustand.einstellungen.erscheinungsbild { + case "light": schema = .light + case "dark": schema = .dark + default: schema = nil + } + + return WindowGroup { + RootAnsicht() + .environment(zustand) + .task { await zustand.starte() } + .preferredColorScheme(schema) + } + } +} diff --git a/ios/App/RootAnsicht.swift b/ios/App/RootAnsicht.swift new file mode 100644 index 0000000..06aa90b --- /dev/null +++ b/ios/App/RootAnsicht.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Welcher Bildschirm zu sehen ist, entscheidet allein `AppZustand.lage`. +/// Jede Ansicht darunter darf davon ausgehen, dass es einen angemeldeten +/// Client gibt. +struct RootAnsicht: View { + @Environment(AppZustand.self) private var zustand + + var body: some View { + switch zustand.lage { + case .startet: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .brauchtServer: + ServerAnsicht() + + case .brauchtAnmeldung: + AnmeldeAnsicht() + + case .brauchtZweitenFaktor: + ZweiFaktorAnsicht() + + case .brauchtPasswortwechsel: + PasswortWechselAnsicht() + + case .brauchtZweitenFaktorEinrichtung: + ZweitenFaktorEinrichtenHinweis() + + case .bereit: + Hauptnavigation() + } + } +} + +/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das +/// erledigt `.sidebarAdaptable` von selbst. +/// +/// Stufe 1 hat erst zwei Einträge. Die weiteren kommen hier dazu, die +/// Anordnung bleibt. +struct Hauptnavigation: View { + var body: some View { + TabView { + Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") { + NavigationStack { KontenListe() } + } + Tab(String(localized: "reiter.einstellungen"), systemImage: "gearshape") { + NavigationStack { EinstellungenAnsicht() } + } + } + .tabViewStyle(.sidebarAdaptable) + } +} diff --git a/ios/Bausteine/Darstellung.swift b/ios/Bausteine/Darstellung.swift new file mode 100644 index 0000000..d1a17f3 --- /dev/null +++ b/ios/Bausteine/Darstellung.swift @@ -0,0 +1,109 @@ +import SwiftUI + +extension Color { + /// Die Farben aus der Datenbank sind Hex-Zeichenketten wie `#3f51b5`. + /// Alles, was nicht passt, wird zum neutralen Grau – eine kaputte Farbe + /// darf keine Ansicht leer lassen. + init(hex: String) { + var text = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("#") { text.removeFirst() } + // Das Backend erlaubt acht Stellen (#rrggbbaa); die Durchsicht nehmen + // wir mit, der Rest wird ignoriert. + guard text.count == 6 || text.count == 8, + let zahl = UInt64(text, radix: 16) else { + self = Color(red: 0.42, green: 0.45, blue: 0.50) + return + } + let hatAlpha = text.count == 8 + let r = Double((zahl >> (hatAlpha ? 24 : 16)) & 0xFF) / 255 + let g = Double((zahl >> (hatAlpha ? 16 : 8)) & 0xFF) / 255 + let b = Double((zahl >> (hatAlpha ? 8 : 0)) & 0xFF) / 255 + let a = hatAlpha ? Double(zahl & 0xFF) / 255 : 1 + self = Color(.sRGB, red: r, green: g, blue: b, opacity: a) + } +} + +/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols. +/// +/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes +/// SVG-Verzeichnis (`web/src/components/Icon.jsx`, knapp 600 Zeilen). Nativ +/// wäre das Unsinn: iOS bringt die Symbole mit, sie passen zur Systemschrift +/// und können fett, farbig und in jeder Grösse. Nur die Namen muss jemand +/// zuordnen – das ist diese Tabelle. +/// +/// Die Liste deckt `ACCOUNT_ICONS` und `CATEGORY_ICONS` aus +/// `web/src/components/IconPicker.jsx` ab. Unbekanntes wird zum Platzhalter, +/// nie zu einer leeren Stelle. +enum Symbole { + static let platzhalter = "circle.dashed" + + private static let tabelle: [String: String] = [ + // Konten + "wallet": "wallet.bifold", "landmark": "building.columns", + "piggy-bank": "banknote", "vault": "lock.square.stack", + "credit-card": "creditcard", "trending-up": "chart.line.uptrend.xyaxis", + "banknote": "banknote", "coins": "centsign.circle", + // Wohnen + "home": "house", "bed": "bed.double", "zap": "bolt", + "droplet": "drop", "flame": "flame", "wifi": "wifi", + "monitor": "display", "cloud": "cloud", + // Alltag + "shopping-cart": "cart", "shopping-bag": "bag", "store": "storefront", + "utensils": "fork.knife", "coffee": "cup.and.saucer", + "wine": "wineglass", "shirt": "tshirt", "scissors": "scissors", + "sparkle": "sparkles", + // Unterwegs + "car": "car", "fuel": "fuelpump", "bus": "bus", "bike": "bicycle", + "plane": "airplane", "ticket": "ticket", + // Gesundheit + "heart-pulse": "heart.text.square", "pill": "pills", + "dumbbell": "figure.strengthtraining.traditional", + // Freizeit + "film": "film", "music": "music.note", "book-open": "book", + "newspaper": "newspaper", "graduation-cap": "graduationcap", + "paw-print": "pawprint", + // Familie + "sprout": "leaf", "cake": "birthday.cake", "gift": "gift", + "baby": "figure.and.child.holdinghands", "heart": "heart", + // Vorsorge + "umbrella": "umbrella", "life-buoy": "lifepreserver", + "shield": "shield", "scale": "scalemass", + // Sonstiges + "briefcase": "briefcase", "wrench": "wrench.adjustable", + "hammer": "hammer", "phone": "phone", "refresh-ccw": "arrow.triangle.2.circlepath", + "percent": "percent", "receipt": "receipt", + "list": "list.bullet", "more-horizontal": "ellipsis", + // In der Oberfläche benutzt, nicht in der Auswahl + "tags": "tag", "inbox": "tray", "settings": "gearshape", + "arrow-left-right": "arrow.left.arrow.right", "pie-chart": "chart.pie", + "dashboard": "square.grid.2x2", "download": "square.and.arrow.down", + "calendar": "calendar", "search": "magnifyingglass", + "edit": "pencil", "trash": "trash", "plus": "plus", "check": "checkmark", + "close": "xmark", "chevron-down": "chevron.down", + ] + + static func sf(_ name: String?) -> String { + guard let name, let treffer = tabelle[name] else { return platzhalter } + return treffer + } +} + +/// Ein farbiges Symbolplättchen – das native Gegenstück zum `IconChip` im Web. +struct Symbolplakette: View { + let symbol: String? + let farbe: String + var groesse: CGFloat = 34 + + var body: some View { + let farbwert = Color(hex: farbe) + RoundedRectangle(cornerRadius: groesse * 0.28, style: .continuous) + .fill(farbwert.opacity(0.16)) + .frame(width: groesse, height: groesse) + .overlay { + Image(systemName: Symbole.sf(symbol)) + .font(.system(size: groesse * 0.46, weight: .medium)) + .foregroundStyle(farbwert) + } + .accessibilityHidden(true) + } +} diff --git a/ios/Bausteine/Geldbetrag.swift b/ios/Bausteine/Geldbetrag.swift new file mode 100644 index 0000000..ce43794 --- /dev/null +++ b/ios/Bausteine/Geldbetrag.swift @@ -0,0 +1,118 @@ +import Foundation + +/// Geld, so wie das Backend es führt: ganze Cent plus Währungscode. +/// +/// Nirgends Fliesskomma. Die API kennt ausschliesslich Cent-Ganzzahlen +/// (einzige Ausnahme ist der Kurzbefehl-Endpunkt), und das bleibt hier so – +/// ein `Double` hätte bei Summen über viele Buchungen Rundungsfehler. +struct Geldbetrag: Hashable, Sendable { + let cents: Int + let waehrung: String + + init(cents: Int, waehrung: String) { + self.cents = cents + self.waehrung = waehrung.uppercased() + } + + var dezimal: Decimal { Decimal(cents) / Decimal(100) } + var istNull: Bool { cents == 0 } + var istNegativ: Bool { cents < 0 } + + /// Für die Anzeige, in der Sprache des Geräts. + /// + /// `vorzeichen: .immer` schreibt auch bei positiven Beträgen ein Plus – + /// das braucht die Buchungsliste, wo Einnahme und Ausgabe nebeneinander + /// stehen und das Vorzeichen die einzige Unterscheidung ist. + func formatiert(vorzeichen: Vorzeichenstil = .nurMinus) -> String { + let wert = vorzeichen == .ohne ? abs(dezimal) : dezimal + var text = wert.formatted(.currency(code: waehrung).locale(.autoupdatingCurrent)) + if vorzeichen == .immer, cents > 0 { + text = "+" + text + } + return text + } + + enum Vorzeichenstil: Sendable { + /// Minus bei negativen Beträgen, sonst nichts. Der Normalfall. + case nurMinus + /// Auch bei positiven ein Plus. + case immer + /// Immer ohne – wenn die Richtung schon woanders steht. + case ohne + } + + /// Ohne Nachkommastellen, für enge Stellen wie Diagramm-Achsen. + var gerundet: String { + (dezimal as NSDecimalNumber).doubleValue.formatted( + .currency(code: waehrung).precision(.fractionLength(0))) + } + + static func + (links: Geldbetrag, rechts: Geldbetrag) -> Geldbetrag { + precondition(links.waehrung == rechts.waehrung, + "Betraege verschiedener Waehrungen lassen sich nicht addieren") + return Geldbetrag(cents: links.cents + rechts.cents, waehrung: links.waehrung) + } + + static prefix func - (betrag: Geldbetrag) -> Geldbetrag { + Geldbetrag(cents: -betrag.cents, waehrung: betrag.waehrung) + } +} + +extension Geldbetrag { + /// Was der Nutzer tippt, in Cent. + /// + /// Nimmt Komma und Punkt als Dezimaltrenner – auf einer deutschen Tastatur + /// tippt man „12,50", auf der Zehnertastatur „12.50", und beides meint + /// dasselbe. Tausendertrenner und Währungszeichen fliegen raus. + static func ausEingabe(_ eingabe: String, waehrung: String) -> Geldbetrag? { + var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + let negativ = text.hasPrefix("-") + text = text.filter { $0.isNumber || $0 == "," || $0 == "." } + // Ohne eine einzige Ziffer ist das keine Zahl, sondern ein Vertipper. + // Sonst käme aus "abc" ein Betrag von 0,00 heraus. + guard text.contains(where: \.isNumber) else { return nil } + + // Steht beides drin, ist das hintere der Dezimaltrenner und das + // vordere der Tausendertrenner ("1.234,50" wie "1,234.50"). + let letztesKomma = text.lastIndex(of: ",") + let letzterPunkt = text.lastIndex(of: ".") + let trenner: Character? + switch (letztesKomma, letzterPunkt) { + case (nil, nil): trenner = nil + case (_?, nil): trenner = "," + case (nil, _?): trenner = "." + case (let komma?, let punkt?): trenner = komma > punkt ? "," : "." + } + + var ganze = text + var bruch = "" + if let trenner, let stelle = text.lastIndex(of: trenner) { + ganze = String(text[text.startIndex.. 2 { + ganze += bruch + bruch = "" + } + bruch = String((bruch + "00").prefix(2)) + + guard let vorne = Int(ganze.isEmpty ? "0" : ganze), let hinten = Int(bruch) else { + return nil + } + let summe = vorne * 100 + hinten + return Geldbetrag(cents: negativ ? -summe : summe, waehrung: waehrung) + } + + /// Zum Vorbelegen eines Eingabefelds: „12.50", ohne Währung und Trenner. + var alsEingabe: String { + let betrag = abs(cents) + return "\(betrag / 100),\(String(format: "%02d", betrag % 100))" + } +} diff --git a/ios/Bausteine/Zustandsansichten.swift b/ios/Bausteine/Zustandsansichten.swift new file mode 100644 index 0000000..202670c --- /dev/null +++ b/ios/Bausteine/Zustandsansichten.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// Der Hinweis über einer Liste, deren Daten aus dem Zwischenspeicher kommen. +/// +/// Wichtiger als er aussieht: Ohne ihn zeigt die App im Flugmodus alte Zahlen, +/// die aussehen wie frische. Mit Datum, weil „offline" allein nicht sagt, ob +/// der Stand von vor fünf Minuten oder von vor drei Wochen ist. +struct StandHinweis: View { + let geholtAm: Date + + var body: some View { + Label { + Text(String(format: String(localized: "stand.hinweis"), + geholtAm.formatted(.relative(presentation: .named)))) + } icon: { + Image(systemName: "wifi.slash") + } + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.4)) + } +} + +/// Wenn gar nichts da ist: Fehler zeigen und, wo es Sinn ergibt, einen +/// Knopf zum Nochmalversuchen. +struct FehlerAnsicht: View { + let fehler: APIFehler + var nochmal: (() -> Void)? + + var body: some View { + ContentUnavailableView { + Label(String(localized: "fehler.titel"), systemImage: symbol) + } description: { + Text(fehler.errorDescription ?? String(localized: "fehler.unbekannt")) + } actions: { + if let nochmal, fehler.lohntWiederholung { + Button(String(localized: "allgemein.nochmal"), action: nochmal) + .buttonStyle(.borderedProminent) + } + } + } + + private var symbol: String { + switch fehler { + case .keineVerbindung: return "wifi.slash" + case .nichtAngemeldet: return "person.crop.circle.badge.exclamationmark" + case .nichtGefunden: return "questionmark.folder" + default: return "exclamationmark.triangle" + } + } +} + +/// Eine Liste, die noch nichts enthält – aber nicht wegen eines Fehlers. +struct LeerAnsicht: View { + let titel: String + let erklaerung: String + var symbol: String = "tray" + + var body: some View { + ContentUnavailableView { + Label(titel, systemImage: symbol) + } description: { + Text(erklaerung) + } + } +} + +/// Setzt die drei Zustände für eine Liste zusammen, damit das nicht jede +/// Ansicht neu baut. +struct Inhalt: View { + let zustand: Ladezustand + var nochmal: (() -> Void)? + @ViewBuilder let darstellung: (A) -> Darstellung + + var body: some View { + switch zustand { + case .laedt: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .da(let wert): + darstellung(wert) + case .ausSpeicher(let wert, let wann, _): + VStack(spacing: 0) { + StandHinweis(geholtAm: wann) + darstellung(wert) + } + case .fehler(let fehler): + FehlerAnsicht(fehler: fehler, nochmal: nochmal) + } + } +} diff --git a/ios/Einstellungen/EinstellungenAnsicht.swift b/ios/Einstellungen/EinstellungenAnsicht.swift new file mode 100644 index 0000000..3a7b831 --- /dev/null +++ b/ios/Einstellungen/EinstellungenAnsicht.swift @@ -0,0 +1,52 @@ +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. +struct EinstellungenAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var zeigeAbmeldefrage = false + + var body: some View { + Form { + Section { + if case .bereit(let benutzer) = zustand.lage { + LabeledContent(String(localized: "einstellungen.angemeldetAls"), + value: benutzer.anzeigename) + } + if let adresse = zustand.serverAdresse { + LabeledContent(String(localized: "einstellungen.server"), + value: adresse.host() ?? adresse.absoluteString) + } + } 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 { + Button(String(localized: "allgemein.abmelden"), role: .destructive) { + zeigeAbmeldefrage = true + } + } footer: { + Text(String(localized: "einstellungen.abmeldenErklaerung")) + } + } + .navigationTitle(String(localized: "reiter.einstellungen")) + .refreshable { await zustand.aktualisiereEinstellungen() } + .confirmationDialog(String(localized: "einstellungen.abmeldenFrage"), + isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) { + Button(String(localized: "allgemein.abmelden"), role: .destructive) { + Task { await zustand.meldeAb() } + } + Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} + } + } +} diff --git a/ios/Konten/KontenListe.swift b/ios/Konten/KontenListe.swift new file mode 100644 index 0000000..a973407 --- /dev/null +++ b/ios/Konten/KontenListe.swift @@ -0,0 +1,155 @@ +import SwiftUI + +/// Die Kontenübersicht – die erste Fachansicht und zugleich die Probe, ob +/// Netzwerk, Modelle, Zwischenspeicher und Geldformatierung zusammenpassen. +struct KontenListe: View { + @Environment(AppZustand.self) private var zustand + @State private var lader: Lader<[Konto]>? + @State private var zeigeArchivierte = false + + var body: some View { + Group { + if let lader { + Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { konten in + liste(konten) + } + } else { + ProgressView() + } + } + .navigationTitle(String(localized: "konten.titel")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle(String(localized: "konten.archivierteZeigen"), + isOn: $zeigeArchivierte) + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + } + } + .task { + guard lader == nil, let client = zustand.client else { return } + let neuer = Lader<[Konto]>(schluessel: "accounts") { + try await client.konten() + } + lader = neuer + await neuer.lade() + } + } + + @ViewBuilder + private func liste(_ alle: [Konto]) -> some View { + let sichtbar = alle.filter { zeigeArchivierte || !$0.isArchived } + + if sichtbar.isEmpty { + LeerAnsicht(titel: String(localized: "konten.leerTitel"), + erklaerung: String(localized: "konten.leerErklaerung"), + symbol: "wallet.bifold") + } else { + List { + Section { + ForEach(sichtbar) { konto in + KontoZeile(konto: konto) + } + } footer: { + Gesamtsumme(konten: sichtbar, + waehrung: zustand.einstellungen.basiswaehrung) + } + } + .listStyle(.insetGrouped) + .refreshable { await lader?.aktualisiere() } + } + } +} + +private struct KontoZeile: View { + let konto: Konto + + var body: some View { + HStack(spacing: 12) { + Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 36) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(konto.name) + .font(.body) + .lineLimit(1) + if konto.isLocked { + // „Gebundenes Geld": zählt nicht zum verfügbaren Betrag. + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel(String(localized: "konten.gebunden")) + } + if konto.isArchived { + Text(String(localized: "konten.archiviert")) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + } + } + Text(konto.type.name) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { + Text(konto.saldo.formatiert()) + .font(.body.monospacedDigit()) + .foregroundStyle(konto.balanceCents < 0 ? .red : .primary) + if konto.weichtVonDerBankAb, let bank = konto.bankBalanceCents { + // Nur melden, nicht korrigieren – welcher Wert stimmt, + // entscheidet der Mensch. + Label(Geldbetrag(cents: bank, waehrung: konto.currency).formatiert(), + systemImage: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + .accessibilityLabel(String(localized: "konten.bankWeichtAb")) + } + } + } + .padding(.vertical, 2) + } +} + +/// Die Summe unten. Gebundene und archivierte Konten bleiben aussen vor – +/// genauso rechnet das Dashboard im Backend (`routers/dashboard.py`). +private struct Gesamtsumme: View { + let konten: [Konto] + let waehrung: String + + var body: some View { + let verfuegbar = konten + .filter { !$0.isLocked && !$0.isArchived } + .reduce(0) { $0 + $1.balanceBaseCents } + let gebunden = konten + .filter { $0.isLocked && !$0.isArchived } + .reduce(0) { $0 + $1.balanceBaseCents } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(String(localized: "konten.verfuegbar")) + Spacer() + Text(Geldbetrag(cents: verfuegbar, waehrung: waehrung).formatiert()) + .monospacedDigit() + } + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + + if gebunden != 0 { + HStack { + Text(String(localized: "konten.gebundenSumme")) + Spacer() + Text(Geldbetrag(cents: gebunden, waehrung: waehrung).formatiert()) + .monospacedDigit() + } + .font(.caption) + } + } + .padding(.top, 6) + } +} diff --git a/ios/Modelle/Grundmodelle.swift b/ios/Modelle/Grundmodelle.swift new file mode 100644 index 0000000..df2eadf --- /dev/null +++ b/ios/Modelle/Grundmodelle.swift @@ -0,0 +1,145 @@ +import Foundation + +// Die Typen hier bilden die Antwortschemas aus `backend/app/schemas.py` nach. +// Feldnamen stimmen eins zu eins überein – deshalb braucht keiner von ihnen +// `CodingKeys`. Wer ein Feld umbenennt, bricht genau hier. + +/// `GET /api/version` – die Prüfung, ob unter einer Adresse ein Certain läuft. +struct Serverkennung: Codable, Sendable { + let name: String + let version: String + let gitSha: String? + + private enum CodingKeys: String, CodingKey { + case name, version + case gitSha = "git_sha" + } + + /// Der Name, den ein echter Server trägt (`backend/app/branding.py`). + var istCertain: Bool { name.localizedCaseInsensitiveContains("certain") } +} + +/// `UserOut` +struct Benutzer: Codable, Sendable, Identifiable, Hashable { + let id: Int + let username: String + let role: Rolle + let displayName: String? + let isActive: Bool + let mustChangePassword: Bool + let mustChangePasswordReason: String? + let totpEnabled: Bool + let createdAt: Zeitpunkt + let mfaSetupRequired: Bool + + private enum CodingKeys: String, CodingKey { + case id, username, role + case displayName = "display_name" + case isActive = "is_active" + case mustChangePassword = "must_change_password" + case mustChangePasswordReason = "must_change_password_reason" + case totpEnabled = "totp_enabled" + case createdAt = "created_at" + case mfaSetupRequired = "mfa_setup_required" + } + + enum Rolle: String, Codable, Sendable { + case admin, user + } + + var anzeigename: String { displayName?.isEmpty == false ? displayName! : username } +} + +/// `LoginOut` – `status` ist `"ok"` oder `"2fa_required"`. +struct AnmeldeAntwort: Codable, Sendable { + let status: String + let user: Benutzer? + + var brauchtZweitenFaktor: Bool { status == "2fa_required" } +} + +/// `TwoFactorVerify` +struct ZweiterFaktorEingabe: Codable, Sendable { + let code: String + let recovery: Bool +} + +/// `PasswordChange` +struct PasswortWechsel: Codable, Sendable { + let currentPassword: String + let newPassword: String + + private enum CodingKeys: String, CodingKey { + case currentPassword = "current_password" + case newPassword = "new_password" + } +} + +/// `ApiTokenCreate` +struct MarkeAnlegen: Codable, Sendable { + let name: String + let scope: String + let defaultAccountId: Int? + + private enum CodingKeys: String, CodingKey { + case name, scope + case defaultAccountId = "default_account_id" + } + + /// Die Marke, mit der sich die App anmeldet. + static func fuerApp(name: String) -> MarkeAnlegen { + MarkeAnlegen(name: name, scope: "app", defaultAccountId: nil) + } +} + +/// `ApiTokenOut` beziehungsweise `ApiTokenCreated`. +/// `token` trägt den Klartext – und zwar genau einmal, beim Anlegen. +struct Marke: Codable, Sendable, Identifiable, Hashable { + let id: Int + let name: String + let scope: String + let defaultAccountId: Int? + let lastUsedAt: Zeitpunkt? + let createdAt: Zeitpunkt? + let token: String? + + private enum CodingKeys: String, CodingKey { + case id, name, scope, token + case defaultAccountId = "default_account_id" + case lastUsedAt = "last_used_at" + case createdAt = "created_at" + } +} + +/// Ein Eintrag aus `GET /api/settings`. Werte sind immer Zeichenketten. +struct Einstellung: Codable, Sendable, Hashable { + let key: String + let value: String +} + +/// Die Einstellungen als benutzbares Ganzes. +struct Einstellungen: Sendable, Hashable { + var basiswaehrung: String = "EUR" + var budgetStarttag: Int = 1 + var sprache: String = "de" + var erscheinungsbild: String = "system" + var datumsformat: String = "de" + var aktiveWaehrungen: [String] = ["EUR", "CHF", "USD"] + + init() {} + + init(_ eintraege: [Einstellung]) { + var nach: [String: String] = [:] + for eintrag in eintraege { nach[eintrag.key] = eintrag.value } + basiswaehrung = nach["base_currency"] ?? basiswaehrung + budgetStarttag = min(31, max(1, Int(nach["budget_start_day"] ?? "") ?? 1)) + sprache = nach["locale"] ?? sprache + erscheinungsbild = nach["theme"] ?? erscheinungsbild + datumsformat = nach["date_format"] ?? datumsformat + if let roh = nach["active_currencies"] { + aktiveWaehrungen = roh.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).uppercased() } + .filter { !$0.isEmpty } + } + } +} diff --git a/ios/Modelle/Konto.swift b/ios/Modelle/Konto.swift new file mode 100644 index 0000000..5fb0f97 --- /dev/null +++ b/ios/Modelle/Konto.swift @@ -0,0 +1,87 @@ +import Foundation + +/// `AccountOut` aus `backend/app/schemas.py`. +struct Konto: Codable, Sendable, Identifiable, Hashable { + let id: Int + let name: String + let type: Kontoart + let currency: String + let icon: String + let color: String + let openingBalanceCents: Int + let openingDate: Tagesdatum? + let creditLimitCents: Int? + let isLocked: Bool + let isArchived: Bool + let position: Int + let savingsCategoryId: Int? + let identifiers: [Kontokennung] + /// Saldo in der Währung des Kontos. + let balanceCents: Int + /// Derselbe Saldo in der Hauptwährung – vom Server gerechnet. + let balanceBaseCents: Int + /// Was die Bank selbst meldet. Nur bei angebundenen Konten gesetzt. + let bankBalanceCents: Int? + let bankBalanceAt: Zeitpunkt? + /// Nur ein gebuchter Banksaldo lässt sich mit unserer Rechnung + /// vergleichen; ein verfügbarer enthält Vorgemerktes. + let bankBalanceVergleichbar: Bool + + private enum CodingKeys: String, CodingKey { + case id, name, type, currency, icon, color, position, identifiers + case openingBalanceCents = "opening_balance_cents" + case openingDate = "opening_date" + case creditLimitCents = "credit_limit_cents" + case isLocked = "is_locked" + case isArchived = "is_archived" + case savingsCategoryId = "savings_category_id" + case balanceCents = "balance_cents" + case balanceBaseCents = "balance_base_cents" + case bankBalanceCents = "bank_balance_cents" + case bankBalanceAt = "bank_balance_at" + case bankBalanceVergleichbar = "bank_balance_vergleichbar" + } + + var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) } + + /// Ob der gemeldete Banksaldo von unserer Rechnung abweicht – nur dann ist + /// ein Hinweis angebracht. + var weichtVonDerBankAb: Bool { + guard bankBalanceVergleichbar, let bank = bankBalanceCents else { return false } + return bank != balanceCents + } +} + +/// `AccountType` +enum Kontoart: String, Codable, Sendable, CaseIterable { + case girokonto, sparkonto, bargeld, kreditkarte, depot, sonstiges + + var name: String { + switch self { + case .girokonto: return String(localized: "kontoart.girokonto") + case .sparkonto: return String(localized: "kontoart.sparkonto") + case .bargeld: return String(localized: "kontoart.bargeld") + case .kreditkarte: return String(localized: "kontoart.kreditkarte") + case .depot: return String(localized: "kontoart.depot") + case .sonstiges: return String(localized: "kontoart.sonstiges") + } + } + + /// Ein Übertrag auf diese Konten zählt im Budget als Sparen, nicht als + /// neutrale Umbuchung (`SAVINGS_ACCOUNT_TYPES` im Backend). + var giltAlsSparen: Bool { self == .sparkonto || self == .depot } +} + +/// `AccountIdentifierOut` +struct Kontokennung: Codable, Sendable, Identifiable, Hashable { + let id: Int + let kind: Art + let value: String + + enum Art: String, Codable, Sendable { + case iban + case accountNo = "account_no" + case cardNo = "card_no" + case text + } +} diff --git a/ios/Modelle/Zeitangaben.swift b/ios/Modelle/Zeitangaben.swift new file mode 100644 index 0000000..6d7ac70 --- /dev/null +++ b/ios/Modelle/Zeitangaben.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Ein Kalendertag ohne Uhrzeit, so wie das Backend ihn schickt: `"2026-07-14"`. +/// +/// Warum ein eigener Typ und nicht `Date` mit einer Decoder-Strategie: Das +/// Backend mischt zwei Formate. Buchungsdaten sind reine Tage, `created_at` +/// und Konsorten sind volle Zeitstempel mit Zone. Eine einzige +/// `dateDecodingStrategy` müsste beides raten und würde mal das eine, mal das +/// andere zerlegen. Zwei saubere Typen sind hier weniger Arbeit als ein +/// cleverer Decoder. +struct Tagesdatum: Codable, Hashable, Comparable, Sendable, CustomStringConvertible { + /// Mitternacht dieses Tages, in der Zeitzone des Geräts. + let datum: Date + /// Die Form, in der es kam und wieder hinausgeht. + let text: String + + private static let leser: DateFormatter = { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = .current + f.dateFormat = "yyyy-MM-dd" + return f + }() + + init?(_ text: String) { + guard let geparst = Tagesdatum.leser.date(from: text) else { return nil } + self.datum = geparst + self.text = text + } + + init(_ datum: Date) { + self.datum = datum + self.text = Tagesdatum.leser.string(from: datum) + } + + static var heute: Tagesdatum { Tagesdatum(Date()) } + + init(from decoder: Decoder) throws { + let roh = try decoder.singleValueContainer().decode(String.self) + guard let fertig = Tagesdatum(roh) else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Kein Tagesdatum: \(roh)")) + } + self = fertig + } + + func encode(to encoder: Encoder) throws { + var behaelter = encoder.singleValueContainer() + try behaelter.encode(text) + } + + static func < (links: Tagesdatum, rechts: Tagesdatum) -> Bool { + links.datum < rechts.datum + } + + var description: String { text } + + /// Für die Anzeige, in der Sprache des Geräts. + func formatiert(_ stil: Date.FormatStyle.DateStyle = .abbreviated) -> String { + datum.formatted(.dateTime.day().month(.defaultDigits).year()) + .replacingOccurrences(of: "\u{202F}", with: " ") + } +} + +/// Ein voller Zeitstempel, wie ihn `created_at`, `last_used_at` und +/// `bank_balance_at` tragen. +/// +/// Das Backend schickt ISO 8601, mal mit Sekundenbruchteilen, mal ohne, mal +/// mit `Z`, mal mit Offset. Deshalb zwei Leser hintereinander statt einem. +struct Zeitpunkt: Codable, Hashable, Comparable, Sendable { + let datum: Date + + private static let mitBruchteilen: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let ohneBruchteile: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// Letzter Ausweg: Python schreibt ohne Zone, wenn keine gesetzt ist. + private static let ohneZone: DateFormatter = { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return f + }() + + init(_ datum: Date) { self.datum = datum } + + init?(_ text: String) { + let ohneMikro = text.replacingOccurrences( + of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression) + if let d = Zeitpunkt.mitBruchteilen.date(from: ohneMikro) { self.datum = d; return } + if let d = Zeitpunkt.ohneBruchteile.date(from: text) { self.datum = d; return } + let gekuerzt = String(text.prefix(19)) + if let d = Zeitpunkt.ohneZone.date(from: gekuerzt) { self.datum = d; return } + return nil + } + + init(from decoder: Decoder) throws { + let roh = try decoder.singleValueContainer().decode(String.self) + guard let fertig = Zeitpunkt(roh) else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Kein Zeitpunkt: \(roh)")) + } + self = fertig + } + + func encode(to encoder: Encoder) throws { + var behaelter = encoder.singleValueContainer() + try behaelter.encode(Zeitpunkt.mitBruchteilen.string(from: datum)) + } + + static func < (links: Zeitpunkt, rechts: Zeitpunkt) -> Bool { + links.datum < rechts.datum + } +} diff --git a/ios/Netzwerk/APIFehler.swift b/ios/Netzwerk/APIFehler.swift new file mode 100644 index 0000000..2ae0dfb --- /dev/null +++ b/ios/Netzwerk/APIFehler.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Was beim Reden mit dem Certain-Backend schiefgehen kann. +/// +/// Die Fälle sind nach dem geschnitten, was die App damit *tun* muss – nicht +/// nach HTTP-Status. `pflichtOffen` führt auf einen anderen Bildschirm, +/// `konflikt` ist eine Rückfrage, die sich mit „trotzdem" wiederholen lässt, +/// alles übrige ist eine Meldung. +enum APIFehler: Error, LocalizedError, Sendable, Equatable { + /// Netz weg, Server nicht erreichbar, Zeitüberschreitung. + case keineVerbindung(String) + /// Unter der Adresse antwortet etwas, aber kein Certain. + case keinCertainServer + /// 401 – Marke ungültig oder widerrufen, Sitzung abgelaufen. + case nichtAngemeldet + /// 403 mit maschinenlesbarem Schlüssel: erst muss etwas erledigt werden. + case pflichtOffen(Pflicht) + /// 403 aus einem anderen Grund. + case verboten(String) + /// 404 – gibt es nicht, oder gehört jemand anderem. + case nichtGefunden + /// 409 – meistens eine Rückfrage, die sich mit `trotzdem = true` + /// wiederholen lässt. Der Text ist für den Nutzer geschrieben. + case konflikt(String) + /// 400 oder 422. + case ungueltig(String) + /// Alles übrige, was der Server sagt. + case server(status: Int, text: String) + /// Die Antwort passt nicht zu dem, was wir erwarten. + case antwortUnlesbar(String) + + /// Was der Benutzer erledigen muss, bevor die API ihn durchlässt. + /// Die Werte sind die Schlüssel aus `backend/app/deps.py`. + enum Pflicht: String, Sendable { + case passwortWechseln = "password_change_required" + case zweiterFaktor = "mfa_setup_required" + } + + var errorDescription: String? { + switch self { + case .keineVerbindung(let grund): + return String(format: String(localized: "fehler.keineVerbindung"), grund) + case .keinCertainServer: + return String(localized: "fehler.keinCertainServer") + case .nichtAngemeldet: + return String(localized: "fehler.nichtAngemeldet") + case .pflichtOffen(.passwortWechseln): + return String(localized: "fehler.passwortWechseln") + case .pflichtOffen(.zweiterFaktor): + return String(localized: "fehler.zweiterFaktor") + case .verboten(let text), .konflikt(let text), .ungueltig(let text): + return text + case .nichtGefunden: + return String(localized: "fehler.nichtGefunden") + case .server(let status, let text): + return text.isEmpty + ? String(format: String(localized: "fehler.server"), status) + : text + case .antwortUnlesbar(let grund): + return String(format: String(localized: "fehler.antwortUnlesbar"), grund) + } + } + + /// Ob ein erneuter Versuch überhaupt Sinn ergibt. + var lohntWiederholung: Bool { + switch self { + case .keineVerbindung, .server: + return true + default: + return false + } + } +} + +/// Der Fehlerkörper des Backends. +/// +/// `detail` ist dort mal eine Zeichenkette (`{"detail": "Nicht gefunden"}`), +/// mal eine Liste von Pydantic-Meldungen (bei 422). Beides muss hier durch, +/// sonst verliert die App bei jedem Eingabefehler die Meldung. +struct Fehlerantwort: Decodable, Sendable { + let detail: String + + private enum Schluessel: String, CodingKey { case detail } + + private struct Einzelfehler: Decodable { + let msg: String? + } + + init(from decoder: Decoder) throws { + let behaelter = try decoder.container(keyedBy: Schluessel.self) + if let text = try? behaelter.decode(String.self, forKey: .detail) { + detail = text + } else if let liste = try? behaelter.decode([Einzelfehler].self, forKey: .detail) { + detail = liste.compactMap(\.msg).joined(separator: ", ") + } else { + detail = "" + } + } + + /// Versucht, aus Antwortdaten eine Meldung zu ziehen. Schlägt das fehl, + /// kommt eine leere Zeichenkette – nie ein Fehler, denn wir sind hier + /// bereits im Fehlerfall. + static func ausDaten(_ daten: Data) -> String { + (try? JSONDecoder().decode(Fehlerantwort.self, from: daten))?.detail ?? "" + } +} diff --git a/ios/Netzwerk/Ablage.swift b/ios/Netzwerk/Ablage.swift new file mode 100644 index 0000000..89e1a26 --- /dev/null +++ b/ios/Netzwerk/Ablage.swift @@ -0,0 +1,126 @@ +import Foundation +import Security + +/// Wo die App ihre Einstellungen und ihr Geheimnis hinlegt. +/// +/// Zwei getrennte Orte, mit Absicht: Die Server-Adresse ist keine geheime +/// Angabe und liegt in den Voreinstellungen. Die Gerätemarke ist ein +/// Vollzugang und gehört in den Schlüsselbund – dort überlebt sie kein +/// unverschlüsseltes Backup und keine Gerätesicherung ohne Passcode. +enum Ablage { + /// Setze das hier auf deine App-Gruppe, sobald es Erweiterungen gibt + /// (`group.`). Solange `nil`, benutzt die App ihren + /// eigenen Bereich – das genügt, bis Teilen-Menü und Kurzbefehle dazukommen. + static let gruppe: String? = nil + + /// Dieselbe Gruppe für den Schlüsselbund. Erst nötig, wenn eine + /// Erweiterung an dieselbe Marke muss. + static let schluesselbundGruppe: String? = nil + + private static var voreinstellungen: UserDefaults { + if let gruppe, let geteilt = UserDefaults(suiteName: gruppe) { return geteilt } + return .standard + } + + private enum Schluessel { + static let serverAdresse = "certain.serverAdresse" + static let marke = "certain.marke" + static let benutzername = "certain.benutzername" + } + + // MARK: - Server-Adresse + + static var serverAdresse: URL? { + get { + guard let text = voreinstellungen.string(forKey: Schluessel.serverAdresse) else { + return nil + } + return URL(string: text) + } + set { + if let neue = newValue { + voreinstellungen.set(neue.absoluteString, forKey: Schluessel.serverAdresse) + } else { + voreinstellungen.removeObject(forKey: Schluessel.serverAdresse) + } + } + } + + /// Nur zur Anzeige („angemeldet als …"), nicht zur Prüfung. + static var benutzername: String? { + get { voreinstellungen.string(forKey: Schluessel.benutzername) } + set { + if let neuer = newValue { + voreinstellungen.set(neuer, forKey: Schluessel.benutzername) + } else { + voreinstellungen.removeObject(forKey: Schluessel.benutzername) + } + } + } + + // MARK: - Gerätemarke im Schlüsselbund + + static var marke: String? { + get { schluesselbundLesen(Schluessel.marke) } + set { + if let neue = newValue { + schluesselbundSchreiben(Schluessel.marke, neue) + } else { + schluesselbundLoeschen(Schluessel.marke) + } + } + } + + /// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt. + static func raeumeAuf() { + marke = nil + benutzername = nil + serverAdresse = nil + Zwischenspeicher.leere() + } + + // MARK: - Schlüsselbund, schmucklos + + private static func grundlage(_ konto: String) -> [String: Any] { + var abfrage: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.certain.app", + kSecAttrAccount as String: konto, + ] + if let schluesselbundGruppe { + abfrage[kSecAttrAccessGroup as String] = schluesselbundGruppe + } + return abfrage + } + + private static func schluesselbundLesen(_ konto: String) -> String? { + var abfrage = grundlage(konto) + abfrage[kSecReturnData as String] = true + abfrage[kSecMatchLimit as String] = kSecMatchLimitOne + + var ergebnis: CFTypeRef? + let status = SecItemCopyMatching(abfrage as CFDictionary, &ergebnis) + guard status == errSecSuccess, let daten = ergebnis as? Data else { return nil } + return String(data: daten, encoding: .utf8) + } + + private static func schluesselbundSchreiben(_ konto: String, _ wert: String) { + let abfrage = grundlage(konto) + let inhalt: [String: Any] = [ + kSecValueData as String: Data(wert.utf8), + // Nicht in ein Backup und nicht auf ein anderes Gerät: Die Marke + // gehoert genau diesem Telefon. + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(abfrage as CFDictionary, inhalt as CFDictionary) + if status == errSecItemNotFound { + var neu = abfrage + neu.merge(inhalt) { _, zweites in zweites } + SecItemAdd(neu as CFDictionary, nil) + } + } + + private static func schluesselbundLoeschen(_ konto: String) { + SecItemDelete(grundlage(konto) as CFDictionary) + } +} diff --git a/ios/Netzwerk/CertainClient.swift b/ios/Netzwerk/CertainClient.swift new file mode 100644 index 0000000..861dfd6 --- /dev/null +++ b/ios/Netzwerk/CertainClient.swift @@ -0,0 +1,235 @@ +import Foundation + +/// Der einzige Weg der App zum Backend. +/// +/// Ein Actor, weil Adresse und Marke sich zur Laufzeit ändern (an- und +/// abmelden) und von überall gelesen werden. +/// +/// Zwei Anmeldewege, und zwar nacheinander, nicht nebeneinander: +/// Beim Anmelden setzt das Backend ein Sitzungs-Cookie, das `URLSession` +/// von selbst mitführt. Noch in dieser Sitzung holt sich die App eine +/// Gerätemarke und benutzt danach nur noch den Header `X-Certain-Token`. +/// Das Cookie wird dann weggeworfen – siehe `vergissCookies()`. +actor CertainClient { + private(set) var basis: URL + private(set) var marke: String? + + private let sitzung: URLSession + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(basis: URL, marke: String? = nil) { + self.basis = basis + self.marke = marke + + let einstellungen = URLSessionConfiguration.ephemeral + einstellungen.httpCookieStorage = HTTPCookieStorage.shared + einstellungen.httpShouldSetCookies = true + einstellungen.httpCookieAcceptPolicy = .always + // Alle /api-Antworten tragen `Cache-Control: no-store`; den lokalen + // Zwischenspeicher führen wir selbst (siehe `Zwischenspeicher`), weil + // wir ihn auch offline und mit Datumsangabe brauchen. + einstellungen.requestCachePolicy = .reloadIgnoringLocalCacheData + einstellungen.timeoutIntervalForRequest = 30 + einstellungen.timeoutIntervalForResource = 120 + einstellungen.waitsForConnectivity = false + sitzung = URLSession(configuration: einstellungen) + } + + // MARK: - Zustand + + func setzeBasis(_ neue: URL) { + basis = neue + } + + func setzeMarke(_ neue: String?) { + marke = neue + } + + /// Nach dem Holen der Marke: Das Cookie wird nicht mehr gebraucht und soll + /// nicht länger auf dem Gerät liegen als nötig. + func vergissCookies() { + guard let speicher = sitzung.configuration.httpCookieStorage else { return } + for keks in speicher.cookies(for: basis) ?? [] { + speicher.deleteCookie(keks) + } + } + + // MARK: - Aufrufe + + enum Methode: String, Sendable { + case get = "GET" + case post = "POST" + case patch = "PATCH" + case put = "PUT" + case delete = "DELETE" + } + + /// Lesen. + func hole(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> A { + let daten = try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil) + return try entschluessele(daten) + } + + /// Schreiben ohne Nutzdaten (etwa `POST /uebertraege/suchen`). + @discardableResult + func schicke(_ methode: Methode, _ pfad: String, + abfrage: [URLQueryItem] = []) async throws -> A { + let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil) + return try entschluessele(daten) + } + + /// Schreiben mit JSON-Körper. + @discardableResult + func schicke(_ methode: Methode, _ pfad: String, + koerper: K, + abfrage: [URLQueryItem] = []) async throws -> A { + let roh = try encoder.encode(koerper) + let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh) + return try entschluessele(daten) + } + + /// Für 204-Antworten: Es kommt nichts zurück, und das ist richtig so. + func schickeStumm(_ methode: Methode, _ pfad: String, + abfrage: [URLQueryItem] = []) async throws { + _ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil) + } + + func schickeStumm(_ methode: Methode, _ pfad: String, + koerper: K, + abfrage: [URLQueryItem] = []) async throws { + let roh = try encoder.encode(koerper) + _ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh) + } + + /// Die Anmeldung geht als Formular, nicht als JSON – das Backend nutzt + /// `OAuth2PasswordRequestForm` (`backend/app/routers/auth.py`). + func schickeFormular(_ pfad: String, + felder: [String: String]) async throws -> A { + var teile = URLComponents() + teile.queryItems = felder.map { URLQueryItem(name: $0.key, value: $0.value) } + // `+` muss als Zeichen durch, sonst wird daraus beim Server ein Leerzeichen. + let text = (teile.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B") + let daten = try await fuehreAus( + .post, pfad, abfrage: [], koerper: Data(text.utf8), + inhaltstyp: "application/x-www-form-urlencoded") + return try entschluessele(daten) + } + + /// Datei hochladen (Logos, Kontoauszüge, Belege). + func schickeDatei(_ pfad: String, feldname: String = "file", + dateiname: String, inhalt: Data, + typ: String = "application/octet-stream", + weitereFelder: [String: String] = [], + abfrage: [URLQueryItem] = []) async throws -> A { + let grenze = "Grenze-\(UUID().uuidString)" + var koerper = Data() + func anhaengen(_ text: String) { koerper.append(Data(text.utf8)) } + + for (schluessel, wert) in weitereFelder { + anhaengen("--\(grenze)\r\n") + anhaengen("Content-Disposition: form-data; name=\"\(schluessel)\"\r\n\r\n") + anhaengen("\(wert)\r\n") + } + anhaengen("--\(grenze)\r\n") + anhaengen("Content-Disposition: form-data; name=\"\(feldname)\"; filename=\"\(dateiname)\"\r\n") + anhaengen("Content-Type: \(typ)\r\n\r\n") + koerper.append(inhalt) + anhaengen("\r\n--\(grenze)--\r\n") + + let daten = try await fuehreAus( + .post, pfad, abfrage: abfrage, koerper: koerper, + inhaltstyp: "multipart/form-data; boundary=\(grenze)") + return try entschluessele(daten) + } + + /// Rohe Bytes holen – für Bilder (Abo-Logos) und Dateien. + func holeBytes(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> Data { + try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil) + } + + // MARK: - Innenleben + + private func baueAdresse(_ pfad: String, _ abfrage: [URLQueryItem]) throws -> URL { + let voll = basis.appendingPathComponent("api").appendingPathComponent( + pfad.hasPrefix("/") ? String(pfad.dropFirst()) : pfad) + guard var teile = URLComponents(url: voll, resolvingAgainstBaseURL: false) else { + throw APIFehler.antwortUnlesbar(pfad) + } + // Leere Werte gar nicht erst mitschicken – das Backend liest "" sonst + // als gesetzten Filter. + let gefiltert = abfrage.filter { ($0.value ?? "").isEmpty == false } + teile.queryItems = gefiltert.isEmpty ? nil : gefiltert + guard let fertig = teile.url else { throw APIFehler.antwortUnlesbar(pfad) } + return fertig + } + + private func fuehreAus(_ methode: Methode, _ pfad: String, + abfrage: [URLQueryItem], koerper: Data?, + inhaltstyp: String = "application/json") async throws -> Data { + var anfrage = URLRequest(url: try baueAdresse(pfad, abfrage)) + anfrage.httpMethod = methode.rawValue + anfrage.setValue("application/json", forHTTPHeaderField: "Accept") + if let marke { + anfrage.setValue(marke, forHTTPHeaderField: "X-Certain-Token") + } + if let koerper { + anfrage.httpBody = koerper + anfrage.setValue(inhaltstyp, forHTTPHeaderField: "Content-Type") + } + + let daten: Data + let antwort: URLResponse + do { + (daten, antwort) = try await sitzung.data(for: anfrage) + } catch let fehler as URLError { + throw APIFehler.keineVerbindung(fehler.localizedDescription) + } catch { + throw APIFehler.keineVerbindung(error.localizedDescription) + } + + guard let http = antwort as? HTTPURLResponse else { + throw APIFehler.antwortUnlesbar("keine HTTP-Antwort") + } + guard (200..<300).contains(http.statusCode) else { + throw deute(status: http.statusCode, daten: daten) + } + return daten + } + + private func deute(status: Int, daten: Data) -> APIFehler { + let text = Fehlerantwort.ausDaten(daten) + switch status { + case 401: + return .nichtAngemeldet + case 403: + if let pflicht = APIFehler.Pflicht(rawValue: text) { + return .pflichtOffen(pflicht) + } + return .verboten(text) + case 404: + return .nichtGefunden + case 409: + return .konflikt(text) + case 400, 422: + return .ungueltig(text) + default: + return .server(status: status, text: text) + } + } + + private func entschluessele(_ daten: Data) throws -> A { + // 204 und andere leere Antworten: Wer hier `Leer` erwartet, bekommt es. + if daten.isEmpty, let leer = Leer() as? A { return leer } + do { + return try decoder.decode(A.self, from: daten) + } catch { + throw APIFehler.antwortUnlesbar(String(describing: error)) + } + } +} + +/// Platzhalter für Antworten ohne Inhalt. +struct Leer: Codable, Sendable { + init() {} +} diff --git a/ios/Netzwerk/Endpunkte.swift b/ios/Netzwerk/Endpunkte.swift new file mode 100644 index 0000000..f50af5c --- /dev/null +++ b/ios/Netzwerk/Endpunkte.swift @@ -0,0 +1,98 @@ +import Foundation + +// Alle Pfade an einer Stelle. An der Aufrufstelle steht nie eine Zeichenkette – +// wer einen Endpunkt umbenennt, bricht genau hier und nicht in zwölf Ansichten. +// +// Stufe 1: Server prüfen, anmelden, Marke holen, Konten lesen. + +extension CertainClient { + + // MARK: - Server + + /// Prüft, ob unter der Adresse überhaupt ein Certain läuft. + /// + /// Wichtig: Das Backend liefert für jeden unbekannten Pfad ausserhalb von + /// `/api` die Weboberfläche mit Status 200 aus. `/api/version` kann das + /// nicht – nur ein echter Server antwortet hier mit dem erwarteten JSON. + func serverkennung() async throws -> Serverkennung { + let kennung: Serverkennung + do { + kennung = try await hole("version") + } catch APIFehler.antwortUnlesbar { + throw APIFehler.keinCertainServer + } + guard kennung.istCertain else { throw APIFehler.keinCertainServer } + return kennung + } + + // MARK: - Anmeldung + + /// Meldet an und bekommt ein Sitzungs-Cookie. Steht der zweite Faktor + /// aus, ist `status == "2fa_required"` und das Cookie gilt nur zehn + /// Minuten für genau diesen Schritt. + func melde(benutzer: String, passwort: String) async throws -> AnmeldeAntwort { + try await schickeFormular("auth/login", + felder: ["username": benutzer, "password": passwort]) + } + + func pruefeZweitenFaktor(code: String, wiederherstellung: Bool = false) async throws -> AnmeldeAntwort { + try await schicke(.post, "auth/2fa/verify", + koerper: ZweiterFaktorEingabe(code: code, recovery: wiederherstellung)) + } + + func ichSelbst() async throws -> Benutzer { + try await hole("auth/me") + } + + /// Wechselt das Passwort. Beendet dabei alle anderen Sitzungen des Kontos + /// und setzt für den Aufrufer ein frisches Cookie. + @discardableResult + func wechslePasswort(alt: String, neu: String) async throws -> Benutzer { + try await schicke(.post, "auth/change-password", + koerper: PasswortWechsel(currentPassword: alt, newPassword: neu)) + } + + func meldeAb() async throws { + try await schickeStumm(.post, "auth/logout") + } + + // MARK: - Gerätemarken + + /// Holt die Marke, mit der die App von da an arbeitet. Geht nur aus einer + /// echten Anmeldung heraus – eine Marke darf keine weitere ausstellen. + func legeAppMarkeAn(name: String) async throws -> Marke { + try await schicke(.post, "inbox/tokens", koerper: MarkeAnlegen.fuerApp(name: name)) + } + + func marken() async throws -> [Marke] { + try await hole("inbox/tokens") + } + + func widerrufeMarke(id: Int) async throws { + try await schickeStumm(.delete, "inbox/tokens/\(id)") + } + + // MARK: - Einstellungen + + func einstellungen() async throws -> Einstellungen { + let roh: [Einstellung] = try await hole("settings") + return Einstellungen(roh) + } + + @discardableResult + func setzeEinstellung(_ schluessel: String, _ wert: String) async throws -> Einstellung { + // Query statt Körper – so will es das Backend (`routers/settings.py`). + try await schicke(.put, "settings/\(schluessel)", + abfrage: [URLQueryItem(name: "value", value: wert)]) + } + + // MARK: - Konten + + func konten() async throws -> [Konto] { + try await hole("accounts") + } + + func konto(_ id: Int) async throws -> Konto { + try await hole("accounts/\(id)") + } +} diff --git a/ios/Netzwerk/Lader.swift b/ios/Netzwerk/Lader.swift new file mode 100644 index 0000000..1d4947b --- /dev/null +++ b/ios/Netzwerk/Lader.swift @@ -0,0 +1,57 @@ +import Foundation +import Observation + +/// Holt eine Liste, legt sie in den Zwischenspeicher und weiss, was bei einem +/// Fehlschlag noch da ist. +/// +/// Jede Listenansicht braucht dasselbe: laden, Fehler zeigen, zum Aktualisieren +/// herunterziehen, offline den letzten Stand behalten. Einmal hier, statt in +/// jeder Ansicht neu. +@MainActor +@Observable +final class Lader { + private(set) var zustand: Ladezustand = .laedt + + private let schluessel: String + private let holen: () async throws -> A + + /// - Parameter schluessel: Name im Zwischenspeicher. Der Endpunktpfad ist + /// eine gute Wahl – er ist eindeutig und beim Suchen lesbar. + init(schluessel: String, holen: @escaping () async throws -> A) { + self.schluessel = schluessel + self.holen = holen + } + + /// Lädt. Beim ersten Mal wird ein vorhandener Stand sofort angezeigt, + /// damit die Liste nicht leer aufblitzt, während der Abruf läuft. + func lade(zeigeGemerktesSofort: Bool = true) async { + if zeigeGemerktesSofort, zustand.inhalt == nil, + let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) { + zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm, + fehler: .keineVerbindung("")) + } else if zustand.inhalt == nil { + zustand = .laedt + } + + do { + let frisch = try await holen() + Zwischenspeicher.lege(frisch, unter: schluessel) + zustand = .da(frisch) + } catch let fehler as APIFehler { + if let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) { + zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm, + fehler: fehler) + } else { + zustand = .fehler(fehler) + } + } catch { + zustand = .fehler(.keineVerbindung(error.localizedDescription)) + } + } + + /// Fürs Herunterziehen: kein Zwischenzustand, die alte Liste bleibt + /// stehen, bis die neue da ist. + func aktualisiere() async { + await lade(zeigeGemerktesSofort: false) + } +} diff --git a/ios/Netzwerk/Zwischenspeicher.swift b/ios/Netzwerk/Zwischenspeicher.swift new file mode 100644 index 0000000..8ee8663 --- /dev/null +++ b/ios/Netzwerk/Zwischenspeicher.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Die letzte geglückte Antwort je Abruf, auf der Platte. +/// +/// Wozu: In der U-Bahn und im Ausland soll die App nicht mit einer +/// Fehlermeldung dastehen, sondern den letzten Stand zeigen – mit Datum, damit +/// niemand alte Zahlen für aktuelle hält. Geschrieben wird offline nicht. +/// +/// Bewusst schlicht: eine JSON-Datei je Schlüssel, kein Verfallsdatum, kein +/// Aufräumen nach Grösse. Die Antworten sind klein (die grösste, die +/// Buchungsliste, liegt im einstelligen Megabyte-Bereich), und ein Abmelden +/// räumt ohnehin alles weg. +enum Zwischenspeicher { + /// Was aus dem Speicher kam, und wann es geholt wurde. + struct Eintrag: Sendable where A: Sendable { + let wert: A + let geholtAm: Date + } + + private static let ordner: URL? = { + guard let basis = try? FileManager.default.url( + for: .cachesDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) else { return nil } + let eigener = basis.appendingPathComponent("CertainAntworten", isDirectory: true) + try? FileManager.default.createDirectory(at: eigener, withIntermediateDirectories: true) + return eigener + }() + + private static func datei(_ schluessel: String) -> URL? { + // Der Schluessel ist ein Pfad mit Abfrage; daraus muss ein gueltiger + // Dateiname werden. + let sauber = schluessel.unicodeScalars.map { zeichen -> Character in + CharacterSet.alphanumerics.contains(zeichen) ? Character(zeichen) : "_" + } + return ordner?.appendingPathComponent(String(sauber) + ".json") + } + + static func lege(_ wert: A, unter schluessel: String) { + guard let ziel = datei(schluessel), + let daten = try? JSONEncoder().encode(wert) else { return } + try? daten.write(to: ziel, options: .atomic) + } + + static func hole(_ typ: A.Type, unter schluessel: String) -> Eintrag? { + guard let quelle = datei(schluessel), + let daten = try? Data(contentsOf: quelle), + let wert = try? JSONDecoder().decode(A.self, from: daten) else { return nil } + let wann = (try? quelle.resourceValues(forKeys: [.contentModificationDateKey]))? + .contentModificationDate ?? Date() + return Eintrag(wert: wert, geholtAm: wann) + } + + static func leere() { + guard let ordner else { return } + try? FileManager.default.removeItem(at: ordner) + } +} + +/// Was eine Ansicht gerade anzeigen soll. +/// +/// Der vierte Fall ist der Grund für diesen Typ: Daten da, aber vom Band – +/// die Ansicht muss das sagen können, ohne dass jede Ansicht ihre eigene +/// Zustandsverwaltung erfindet. +enum Ladezustand: Sendable { + case laedt + case da(A) + /// Der Abruf schlug fehl, aber wir haben noch einen Stand von vorher. + case ausSpeicher(A, geholtAm: Date, fehler: APIFehler) + case fehler(APIFehler) + + var inhalt: A? { + switch self { + case .da(let wert): return wert + case .ausSpeicher(let wert, _, _): return wert + case .laedt, .fehler: return nil + } + } + + var laeuft: Bool { + if case .laedt = self { return true } + return false + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..7df8a0b --- /dev/null +++ b/ios/README.md @@ -0,0 +1,119 @@ +# Certain für iOS + +Native App zum Certain-Backend. Der Code hier ist **nie übersetzt worden** — auf +der Maschine, auf der er entstanden ist, gibt es kein Xcode. Rechne beim ersten +Bauen mit Fehlern und schick sie mir; das ist eingeplant. + +## Projekt anlegen + +1. Xcode → neues Projekt → **App**, Schnittstelle **SwiftUI**, Sprache **Swift**. +2. Mindestversion auf **iOS 26** stellen. +3. Als Geräte **iPhone und iPad** ankreuzen. +4. Die Datei `ContentView.swift`, die Xcode anlegt, **löschen** — `App/` bringt + einen eigenen Einstiegspunkt mit. +5. Die Ordner aus `ios/` per Ziehen ins Projekt übernehmen, mit *Create groups* + (nicht *folder references*). +6. `Texte/de.lproj` und `Texte/en.lproj` übernehmen und unter + *Project → Info → Localizations* **Deutsch** und **Englisch** eintragen. + Deutsch ist die Grundsprache. + +Der Ordner `Tests/` gehört in ein Test-Ziel, nicht in das App-Ziel. + +## Was wohin gehört + +| Ordner | Inhalt | +|---|---| +| `App/` | Einstiegspunkt, Navigation, globaler Zustand | +| `Netzwerk/` | Client, Endpunkte, Fehler, Offline-Ablage | +| `Modelle/` | Codable-Typen, eins zu eins zu den Backend-Schemas | +| `Bausteine/` | Wiederverwendetes: Geld, Farben, Symbole, Zustandsansichten | +| `Anmeldung/` | Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel | +| `Konten/` | Kontenliste (die erste Fachansicht) | +| `Texte/` | Übersetzungen | +| `Tests/` | Reine Logiktests, ohne Netz | + +## Wie die Anmeldung funktioniert + +Das Backend kennt für die Weboberfläche nur ein Sitzungs-Cookie. Für die App +wurde eine **Gerätemarke der Reichweite `app`** ergänzt +(`backend/app/models.py`, `TokenScope`). Der Ablauf: + +1. Nutzer gibt die Server-Adresse ein. Die App prüft mit `GET /api/version`, ob + dort überhaupt ein Certain läuft. +2. Nutzer meldet sich mit Benutzername und Passwort an — über das normale + Cookie, inklusive zweitem Faktor und Pflicht-Passwortwechsel, falls fällig. +3. Noch in derselben Sitzung legt die App über `POST /api/inbox/tokens` mit + `scope: "app"` eine Marke an und legt deren Klartext im **Schlüsselbund** ab. +4. Danach läuft **alles** über den Header `X-Certain-Token`. Das Cookie wird + verworfen. + +Damit gibt es kein Passwort auf dem Gerät und keinen Ablauf nach 30 Tagen. Die +Marke lässt sich am Rechner unter Einstellungen → Gerätemarken einzeln +widerrufen; danach sperrt sie sofort. + +Eine App-Marke kann **keine weitere Marke ausstellen** — das geht nur mit einer +echten Anmeldung. Sonst hätte ein Dieb sich Ersatzschlüssel ausgestellt und das +Widerrufen wäre wirkungslos. + +## Später: Erweiterungen + +Teilen-Menü und Kurzbefehle kommen in Stufe 6. Beide brauchen Zugriff auf +dieselbe Marke wie die App. Dafür ist im Projekt nötig: + +- eine **App-Gruppe** (`group.`) für die Server-Adresse, +- eine **geteilte Schlüsselbund-Gruppe** für die Marke selbst. + +`Netzwerk/Ablage.swift` liest beides bereits aus Konstanten, die du an einer +Stelle setzt (`Ablage.gruppe`). Solange keine Erweiterung existiert, funktioniert +alles auch ohne App-Gruppe. + +## Was die App bewusst nicht kann + +- **Bankkonten verbinden.** Die Bank leitet auf eine feste Web-Adresse zurück, + die bei Enable Banking registriert ist. Abrufen und Zuordnen geht nativ, das + Herstellen der Verbindung bleibt im Browser. +- **Postfach einrichten** und **Sicherung/Wiederherstellung.** Gehört an den + Rechner. +- **Benutzerverwaltung.** Administration für Mehrbenutzerbetrieb. + +## Was geprüft ist — und was nicht + +**Geprüft:** Jedes Feld der Modelle wurde gegen echte Antworten eines +laufenden Backends verglichen (`/api/version`, `/api/auth/me`, `/api/accounts`, +`/api/settings`, `/api/inbox/tokens`). Alle Namen stimmen überein. Dabei kam +heraus, dass `created_at` ohne Zeitzone und mit Mikrosekunden kommt +(`2026-10-20T14:18:15.780705`) — dafür hat `Zeitpunkt` einen dritten Leser, +und `GrundlagenTests` deckt den Fall ab. + +**Nicht geprüft:** Ob das Ganze übersetzt. Kein Xcode auf der Maschine, auf +der es entstanden ist. + +### Wo es beim ersten Bauen am ehesten klemmt + +Nach abnehmender Wahrscheinlichkeit: + +1. **`@testable import Certain`** in `Tests/GrundlagenTests.swift` — muss auf + den Namen deines App-Ziels zeigen. Heisst es anders, anpassen. +2. **`Lader` ist eine generische `@Observable`-Klasse.** Das geht, ist aber + die ungewöhnlichste Konstruktion hier. Falls der Übersetzer murrt: Die + Klasse wird bisher nur an einer Stelle benutzt (`KontenListe`). +3. **Nebenläufigkeit.** `AppZustand` ist `@MainActor`, `CertainClient` ein + Actor. Unter strikter Nebenläufigkeitsprüfung kann es an den Übergängen + Warnungen setzen. +4. **`Tab` und `.tabViewStyle(.sidebarAdaptable)`** in `RootAnsicht` setzen + iOS 18 voraus. Bei iOS 26 als Mindestversion kein Problem. +5. **Die Tests nutzen Swift Testing** (`import Testing`, `@Test`, `#expect`), + nicht XCTest. Xcode 16 und neuer kann beides im selben Ziel. + +Schick mir die Fehler, dann räumen wir sie weg. + +## Stand + +Stufe 1: Gerüst, Anmeldung samt zweitem Faktor und Passwortwechsel, +Kontenliste. 21 Swift-Dateien, rund 2600 Zeilen. Siehe den Plan im Repository +für die weiteren Stufen. + +Noch nicht in der App, obwohl in Stufe 1 erreichbar: Einen zweiten Faktor +**einrichten** geht nur im Browser. Wer ein Konto hat, für das die Richtlinie +MFA verlangt, sieht deshalb einen Hinweis mit Link statt eines Einrichtungs- +Assistenten. Das kommt mit den Einstellungen in Stufe 5. diff --git a/ios/Tests/GrundlagenTests.swift b/ios/Tests/GrundlagenTests.swift new file mode 100644 index 0000000..2e4f495 --- /dev/null +++ b/ios/Tests/GrundlagenTests.swift @@ -0,0 +1,304 @@ +import Testing +import Foundation +@testable import Certain + +// Reine Logik, kein Netz. Das ist alles, was sich ohne laufenden Server +// prüfen lässt – und zugleich das, was beim blinden Schreiben am ehesten +// still danebengeht. +// +// Hinweis: `@testable import Certain` muss auf den Namen deines App-Ziels +// zeigen. Heisst es anders, hier anpassen. + +// MARK: - Geldbeträge + +@Suite("Geldbetrag") +struct GeldbetragTests { + + @Test("Komma und Punkt meinen dasselbe") + func trennerSindAustauschbar() { + #expect(Geldbetrag.ausEingabe("12,50", waehrung: "CHF")?.cents == 1250) + #expect(Geldbetrag.ausEingabe("12.50", waehrung: "CHF")?.cents == 1250) + #expect(Geldbetrag.ausEingabe("12", waehrung: "CHF")?.cents == 1200) + #expect(Geldbetrag.ausEingabe("0,05", waehrung: "CHF")?.cents == 5) + } + + @Test("Bei beiden Zeichen ist das hintere der Dezimaltrenner") + func tausenderUndDezimal() { + #expect(Geldbetrag.ausEingabe("1.234,50", waehrung: "EUR")?.cents == 123450) + #expect(Geldbetrag.ausEingabe("1,234.50", waehrung: "EUR")?.cents == 123450) + } + + @Test("Drei Stellen hinter dem Trenner sind Tausender, keine Rappen") + func dreiStellenSindKeinBruch() { + // "1.234" meint tausendzweihundertvierunddreissig, nicht 1,23. + #expect(Geldbetrag.ausEingabe("1.234", waehrung: "EUR")?.cents == 123400) + } + + @Test("Vorzeichen bleibt erhalten") + func negativ() { + #expect(Geldbetrag.ausEingabe("-5", waehrung: "EUR")?.cents == -500) + #expect(Geldbetrag.ausEingabe("-12,34", waehrung: "EUR")?.cents == -1234) + } + + @Test("Unsinn ergibt keinen Betrag") + func unsinn() { + #expect(Geldbetrag.ausEingabe("", waehrung: "EUR") == nil) + #expect(Geldbetrag.ausEingabe(" ", waehrung: "EUR") == nil) + // Der wichtige Fall: Ohne diese Prüfung käme hier 0,00 heraus und die + // App buchte stillschweigend einen Nullbetrag. + #expect(Geldbetrag.ausEingabe("abc", waehrung: "EUR") == nil) + #expect(Geldbetrag.ausEingabe(",", waehrung: "EUR") == nil) + } + + @Test("Hin und zurück durchs Eingabefeld") + func rundlauf() { + let betrag = Geldbetrag(cents: 123450, waehrung: "CHF") + #expect(betrag.alsEingabe == "1234,50") + #expect(Geldbetrag.ausEingabe(betrag.alsEingabe, waehrung: "CHF")?.cents == 123450) + + let krumm = Geldbetrag(cents: 5, waehrung: "CHF") + #expect(krumm.alsEingabe == "0,05") + #expect(Geldbetrag.ausEingabe(krumm.alsEingabe, waehrung: "CHF")?.cents == 5) + } + + @Test("Die Formatierung nennt Betrag und Währung") + func formatierung() { + let text = Geldbetrag(cents: 1250, waehrung: "CHF").formatiert() + // Kein Vergleich auf die genaue Zeichenkette: Trennzeichen und Stellung + // des Währungszeichens hängen an der Sprache des Geräts. + #expect(text.contains("12")) + #expect(text.contains("50")) + } + + @Test("Plus nur, wenn ausdrücklich gewünscht") + func vorzeichenstil() { + let positiv = Geldbetrag(cents: 500, waehrung: "EUR") + #expect(positiv.formatiert(vorzeichen: .immer).hasPrefix("+")) + #expect(positiv.formatiert().hasPrefix("+") == false) + } + + @Test("Summieren geht nur in derselben Währung") + func summieren() { + let a = Geldbetrag(cents: 100, waehrung: "EUR") + let b = Geldbetrag(cents: 250, waehrung: "EUR") + #expect((a + b).cents == 350) + } +} + +// MARK: - Zeitangaben + +@Suite("Zeitangaben") +struct ZeitangabenTests { + + @Test("Ein Tagesdatum überlebt Hin- und Rückweg") + func tagesdatum() throws { + let tag = try #require(Tagesdatum("2026-07-14")) + #expect(tag.text == "2026-07-14") + #expect(Tagesdatum(tag.datum).text == "2026-07-14") + } + + @Test("Tage lassen sich vergleichen") + func reihenfolge() throws { + let frueher = try #require(Tagesdatum("2026-01-31")) + let spaeter = try #require(Tagesdatum("2026-02-01")) + #expect(frueher < spaeter) + } + + @Test("Kaputte Tagesangaben ergeben nichts") + func kaputterTag() { + #expect(Tagesdatum("14.07.2026") == nil) + #expect(Tagesdatum("") == nil) + #expect(Tagesdatum("2026-13-45") == nil) + } + + @Test("Zeitstempel mit und ohne Sekundenbruchteile") + func zeitpunkte() { + // Das Backend schickt beide Formen, je nach Feld. + #expect(Zeitpunkt("2026-07-14T10:11:12Z") != nil) + #expect(Zeitpunkt("2026-07-14T10:11:12.345Z") != nil) + #expect(Zeitpunkt("2026-07-14T10:11:12+02:00") != nil) + // Python schreibt ohne Zone, wenn keine gesetzt ist. + #expect(Zeitpunkt("2026-07-14T10:11:12") != nil) + // Mikrosekunden, wie SQLite sie ablegt. + #expect(Zeitpunkt("2026-07-14T10:11:12.345678") != nil) + #expect(Zeitpunkt("übermorgen") == nil) + } +} + +// MARK: - Server-Adresse + +@Suite("Server-Adresse") +struct AdressTests { + + @Test("Ohne Schema wird https ergänzt") + func schemaErgaenzen() throws { + let adresse = try #require(AppZustand.deuteAdresse("budget.beispiel.com")) + #expect(adresse.absoluteString == "https://budget.beispiel.com") + } + + @Test("Ein angehängter Pfad fliegt raus") + func pfadWeg() throws { + // Der Nutzer kopiert gern die Adresse aus der Browserzeile, samt Pfad. + // Die API hängt aber immer unter /api an der Wurzel. + let adresse = try #require(AppZustand.deuteAdresse("https://budget.beispiel.com/buchungen?x=1")) + #expect(adresse.absoluteString == "https://budget.beispiel.com") + } + + @Test("http bleibt http") + func schemaBleibt() throws { + let adresse = try #require(AppZustand.deuteAdresse("http://192.168.1.5:8000")) + #expect(adresse.absoluteString == "http://192.168.1.5:8000") + } + + @Test("Unsinn ergibt keine Adresse") + func unsinn() { + #expect(AppZustand.deuteAdresse("") == nil) + #expect(AppZustand.deuteAdresse(" ") == nil) + } +} + +// MARK: - Symbole + +@Suite("Symbole") +struct SymbolTests { + + @Test("Bekannte Namen werden übersetzt") + func bekannt() { + #expect(Symbole.sf("wallet") == "wallet.bifold") + #expect(Symbole.sf("shopping-cart") == "cart") + } + + @Test("Unbekanntes wird zum Platzhalter, nie zu nichts") + func unbekannt() { + // Eine leere Stelle in der Liste sähe aus wie ein Fehler. + #expect(Symbole.sf("gibtesnicht") == Symbole.platzhalter) + #expect(Symbole.sf(nil) == Symbole.platzhalter) + } +} + +// MARK: - Fehlerantworten + +@Suite("Fehlerantworten") +struct FehlerTests { + + @Test("detail als Zeichenkette") + func alsText() { + let daten = Data(#"{"detail":"Nicht gefunden"}"#.utf8) + #expect(Fehlerantwort.ausDaten(daten) == "Nicht gefunden") + } + + @Test("detail als Liste, wie bei 422") + func alsListe() { + // Genau hier gehen Meldungen verloren, wenn der Decoder nur eine + // Zeichenkette kennt. + let daten = Data(#"{"detail":[{"loc":["body","amount_cents"],"msg":"muss groesser als 0 sein","type":"value_error"}]}"#.utf8) + #expect(Fehlerantwort.ausDaten(daten) == "muss groesser als 0 sein") + } + + @Test("Was gar kein Fehlerkörper ist, ergibt leeren Text statt eines Absturzes") + func unbrauchbar() { + #expect(Fehlerantwort.ausDaten(Data("".utf8)) == "") + #expect(Fehlerantwort.ausDaten(Data()) == "") + } +} + +// MARK: - Modelle gegen echte Antworten + +@Suite("Modelle") +struct ModellTests { + + @Test("Ein Konto, wie der Server es schickt") + func konto() throws { + // Abgenommen von einer echten Antwort aus GET /api/accounts. + let roh = """ + {"id":3,"name":"Revolut CHF","type":"girokonto","currency":"CHF", + "icon":"wallet","color":"#3f51b5","opening_balance_cents":0, + "opening_date":"2026-01-01","credit_limit_cents":null, + "is_locked":false,"is_archived":false,"position":2, + "savings_category_id":null, + "identifiers":[{"id":7,"kind":"iban","value":"CH93..."}], + "balance_cents":-1043,"balance_base_cents":-1043, + "bank_balance_cents":null,"bank_balance_at":null, + "bank_balance_vergleichbar":false} + """ + let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8)) + #expect(konto.name == "Revolut CHF") + #expect(konto.type == .girokonto) + #expect(konto.saldo.cents == -1043) + #expect(konto.identifiers.first?.kind == .iban) + #expect(konto.weichtVonDerBankAb == false) + } + + @Test("Ein Banksaldo, der abweicht, wird gemeldet") + func saldoWeichtAb() throws { + let roh = """ + {"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet", + "color":"#6b7480","opening_balance_cents":0,"opening_date":null, + "credit_limit_cents":null,"is_locked":false,"is_archived":false, + "position":0,"savings_category_id":null,"identifiers":[], + "balance_cents":1000,"balance_base_cents":1000, + "bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z", + "bank_balance_vergleichbar":true} + """ + let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8)) + #expect(konto.weichtVonDerBankAb) + } + + @Test("Ein nicht vergleichbarer Banksaldo loest keinen Hinweis aus") + func saldoNichtVergleichbar() throws { + // Ein verfuegbarer Saldo enthaelt Vorgemerktes - dass er abweicht, + // ist normal und keine Meldung wert. + let roh = """ + {"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet", + "color":"#6b7480","opening_balance_cents":0,"opening_date":null, + "credit_limit_cents":null,"is_locked":false,"is_archived":false, + "position":0,"savings_category_id":null,"identifiers":[], + "balance_cents":1000,"balance_base_cents":1000, + "bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z", + "bank_balance_vergleichbar":false} + """ + let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8)) + #expect(konto.weichtVonDerBankAb == false) + } + + @Test("Einstellungen mit Lücken fallen auf sinnvolle Werte zurück") + func einstellungen() { + let leer = Einstellungen([]) + #expect(leer.basiswaehrung == "EUR") + #expect(leer.budgetStarttag == 1) + + let gesetzt = Einstellungen([ + Einstellung(key: "base_currency", value: "CHF"), + Einstellung(key: "budget_start_day", value: "25"), + Einstellung(key: "active_currencies", value: "EUR, CHF ,USD"), + ]) + #expect(gesetzt.basiswaehrung == "CHF") + #expect(gesetzt.budgetStarttag == 25) + #expect(gesetzt.aktiveWaehrungen == ["EUR", "CHF", "USD"]) + } + + @Test("Ein unsinniger Starttag wird eingefangen") + func starttagGrenzen() { + // Das Backend begrenzt auf 1..31; die App darf sich nicht darauf + // verlassen, dass in der Einstellung nichts anderes steht. + #expect(Einstellungen([Einstellung(key: "budget_start_day", value: "99")]) + .budgetStarttag == 31) + #expect(Einstellungen([Einstellung(key: "budget_start_day", value: "0")]) + .budgetStarttag == 1) + #expect(Einstellungen([Einstellung(key: "budget_start_day", value: "weiss nicht")]) + .budgetStarttag == 1) + } + + @Test("Die Serverkennung erkennt einen fremden Server") + func serverkennung() throws { + let echt = try JSONDecoder().decode( + Serverkennung.self, + from: Data(#"{"name":"Certain Finance","version":"0.1.0","git_sha":"abc"}"#.utf8)) + #expect(echt.istCertain) + + let fremd = try JSONDecoder().decode( + Serverkennung.self, + from: Data(#"{"name":"Nextcloud","version":"29","git_sha":null}"#.utf8)) + #expect(fremd.istCertain == false) + } +} diff --git a/ios/Texte/de.lproj/Localizable.strings b/ios/Texte/de.lproj/Localizable.strings new file mode 100644 index 0000000..c46a749 --- /dev/null +++ b/ios/Texte/de.lproj/Localizable.strings @@ -0,0 +1,104 @@ +/* Certain für iOS – Deutsch (Grundsprache) + + Die Schlüssel sind nach Bereich benannt. Bitte beim Ergänzen die englische + Fassung gleich mitpflegen; im Web stehen de.json und en.json zeilengleich, + und das ist der Grund, warum dort nie etwas fehlt. */ + +/* --- Allgemein --- */ +"allgemein.nochmal" = "Nochmal versuchen"; +"allgemein.abbrechen" = "Abbrechen"; +"allgemein.abmelden" = "Abmelden"; + +/* --- Reiter --- */ +"reiter.konten" = "Konten"; +"reiter.einstellungen" = "Einstellungen"; + +/* --- Fehler --- */ +"fehler.titel" = "Das hat nicht geklappt"; +"fehler.unbekannt" = "Unbekannter Fehler."; +"fehler.keineVerbindung" = "Keine Verbindung zum Server: %@"; +"fehler.keinCertainServer" = "Unter dieser Adresse antwortet zwar etwas, aber kein Certain."; +"fehler.nichtAngemeldet" = "Die Anmeldung dieses Geräts gilt nicht mehr. Vermutlich wurde sie am Rechner widerrufen."; +"fehler.passwortWechseln" = "Bevor es weitergeht, muss das Passwort gewechselt werden."; +"fehler.zweiterFaktor" = "Dieses Konto braucht einen zweiten Faktor."; +"fehler.nichtGefunden" = "Gibt es nicht."; +"fehler.server" = "Der Server meldet einen Fehler (%d)."; +"fehler.antwortUnlesbar" = "Die Antwort des Servers passt nicht zu dem, was die App erwartet: %@"; + +/* --- Offline --- */ +"stand.hinweis" = "Stand von %@ – der Server ist gerade nicht erreichbar."; + +/* --- Server-Adresse --- */ +"server.titel" = "Mit Certain verbinden"; +"server.ueberschrift" = "Adresse"; +"server.platzhalter" = "budget.beispiel.com"; +"server.erklaerung" = "Dieselbe Adresse, unter der du Certain im Browser öffnest. Ohne https davor geht auch."; +"server.verbinden" = "Verbinden"; +"server.keineAdresse" = "Das sieht nicht nach einer Adresse aus."; + +/* --- Anmeldung --- */ +"anmeldung.titel" = "Anmelden"; +"anmeldung.benutzer" = "Benutzername"; +"anmeldung.passwort" = "Passwort"; +"anmeldung.anmelden" = "Anmelden"; +"anmeldung.erklaerung" = "Nur dieses eine Mal. Danach merkt sich das Gerät einen eigenen Schlüssel, und dein Passwort wird nirgends gespeichert."; +"anmeldung.andererServer" = "Anderen Server wählen"; +"anmeldung.aktuellerServer" = "Verbunden mit %@"; +"anmeldung.markeOhneSchluessel" = "Der Server hat keinen Schlüssel zurückgegeben. Bitte nochmal versuchen."; + +/* --- Zweiter Faktor --- */ +"zweifaktor.titel" = "Zweiter Faktor"; +"zweifaktor.ueberschrift" = "Code"; +"zweifaktor.platzhalterCode" = "123456"; +"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ"; +"zweifaktor.erklaerungCode" = "Die sechs Ziffern aus deiner Authenticator-App."; +"zweifaktor.erklaerungWiederherstellung" = "Einer der Codes, die du beim Einrichten bekommen hast. Jeder gilt nur einmal."; +"zweifaktor.bestaetigen" = "Bestätigen"; +"zweifaktor.dochWiederherstellung" = "Stattdessen einen Wiederherstellungscode benutzen"; +"zweifaktor.dochCode" = "Stattdessen den Code aus der App benutzen"; +"zweifaktor.abbrechen" = "Anmeldung abbrechen"; +"zweifaktor.verbraucht" = "Zu viele Fehlversuche. Bitte von vorn anmelden."; + +/* --- Zweiter Faktor fehlt noch --- */ +"zweifaktorEinrichten.titel" = "Zweiter Faktor nötig"; +"zweifaktorEinrichten.erklaerung" = "Für dieses Konto verlangt die Richtlinie einen zweiten Faktor. Einrichten lässt er sich bisher nur im Browser – in der App kommt das noch."; +"zweifaktorEinrichten.imBrowser" = "Im Browser öffnen"; + +/* --- Passwortwechsel --- */ +"passwort.titel" = "Passwort wechseln"; +"passwort.bisher" = "Bisheriges Passwort"; +"passwort.neu" = "Neues Passwort"; +"passwort.wiederholen" = "Noch einmal"; +"passwort.erklaerung" = "Für dieses Konto steht ein Passwortwechsel an. Vorher lässt der Server nichts anderes zu."; +"passwort.stimmtNichtUeberein" = "Die beiden stimmen nicht überein."; +"passwort.speichern" = "Passwort wechseln"; + +/* --- Konten --- */ +"konten.titel" = "Konten"; +"konten.leerTitel" = "Noch keine Konten"; +"konten.leerErklaerung" = "Lege dein erstes Konto im Browser an – in der App kommt das in einer späteren Stufe."; +"konten.archivierteZeigen" = "Archivierte zeigen"; +"konten.archiviert" = "archiviert"; +"konten.gebunden" = "Gebundenes Geld"; +"konten.gebundenSumme" = "Davon gebunden"; +"konten.verfuegbar" = "Verfügbar"; +"konten.bankWeichtAb" = "Die Bank meldet einen anderen Saldo"; + +/* --- Kontoarten --- */ +"kontoart.girokonto" = "Girokonto"; +"kontoart.sparkonto" = "Sparkonto"; +"kontoart.bargeld" = "Bargeld"; +"kontoart.kreditkarte" = "Kreditkarte"; +"kontoart.depot" = "Depot"; +"kontoart.sonstiges" = "Sonstiges"; + +/* --- Einstellungen --- */ +"einstellungen.zugang" = "Zugang"; +"einstellungen.angemeldetAls" = "Angemeldet als"; +"einstellungen.server" = "Server"; +"einstellungen.markeErklaerung" = "Dieses Gerät hat einen eigenen Schlüssel. Du kannst ihn am Rechner unter Einstellungen → Gerätemarken jederzeit widerrufen; dann meldet sich die App sofort ab."; +"einstellungen.ausDemServer" = "Vom Server"; +"einstellungen.hauptwaehrung" = "Hauptwährung"; +"einstellungen.budgetStarttag" = "Budget beginnt am"; +"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?"; +"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt."; diff --git a/ios/Texte/en.lproj/Localizable.strings b/ios/Texte/en.lproj/Localizable.strings new file mode 100644 index 0000000..f876bc7 --- /dev/null +++ b/ios/Texte/en.lproj/Localizable.strings @@ -0,0 +1,104 @@ +/* Certain for iOS – English + + Keys mirror de.lproj line for line. Keep them that way: in the web app + de.json and en.json are the same length, and that is why nothing is ever + missing there. */ + +/* --- General --- */ +"allgemein.nochmal" = "Try again"; +"allgemein.abbrechen" = "Cancel"; +"allgemein.abmelden" = "Sign out"; + +/* --- Tabs --- */ +"reiter.konten" = "Accounts"; +"reiter.einstellungen" = "Settings"; + +/* --- Errors --- */ +"fehler.titel" = "That did not work"; +"fehler.unbekannt" = "Unknown error."; +"fehler.keineVerbindung" = "Cannot reach the server: %@"; +"fehler.keinCertainServer" = "Something answers at this address, but it is not Certain."; +"fehler.nichtAngemeldet" = "This device is no longer signed in. It was probably revoked on the desktop."; +"fehler.passwortWechseln" = "The password has to be changed before anything else."; +"fehler.zweiterFaktor" = "This account needs a second factor."; +"fehler.nichtGefunden" = "Does not exist."; +"fehler.server" = "The server reports an error (%d)."; +"fehler.antwortUnlesbar" = "The server's answer does not match what the app expects: %@"; + +/* --- Offline --- */ +"stand.hinweis" = "As of %@ – the server cannot be reached right now."; + +/* --- Server address --- */ +"server.titel" = "Connect to Certain"; +"server.ueberschrift" = "Address"; +"server.platzhalter" = "budget.example.com"; +"server.erklaerung" = "The same address you open Certain with in your browser. Leaving off https is fine."; +"server.verbinden" = "Connect"; +"server.keineAdresse" = "That does not look like an address."; + +/* --- Sign in --- */ +"anmeldung.titel" = "Sign in"; +"anmeldung.benutzer" = "Username"; +"anmeldung.passwort" = "Password"; +"anmeldung.anmelden" = "Sign in"; +"anmeldung.erklaerung" = "Just this once. Afterwards the device keeps a key of its own, and your password is stored nowhere."; +"anmeldung.andererServer" = "Choose a different server"; +"anmeldung.aktuellerServer" = "Connected to %@"; +"anmeldung.markeOhneSchluessel" = "The server returned no key. Please try again."; + +/* --- Second factor --- */ +"zweifaktor.titel" = "Second factor"; +"zweifaktor.ueberschrift" = "Code"; +"zweifaktor.platzhalterCode" = "123456"; +"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ"; +"zweifaktor.erklaerungCode" = "The six digits from your authenticator app."; +"zweifaktor.erklaerungWiederherstellung" = "One of the codes you got when setting this up. Each works once."; +"zweifaktor.bestaetigen" = "Confirm"; +"zweifaktor.dochWiederherstellung" = "Use a recovery code instead"; +"zweifaktor.dochCode" = "Use the code from the app instead"; +"zweifaktor.abbrechen" = "Cancel sign-in"; +"zweifaktor.verbraucht" = "Too many wrong attempts. Please start over."; + +/* --- Second factor missing --- */ +"zweifaktorEinrichten.titel" = "Second factor required"; +"zweifaktorEinrichten.erklaerung" = "The policy requires a second factor for this account. Setting one up still only works in the browser; the app will get it later."; +"zweifaktorEinrichten.imBrowser" = "Open in browser"; + +/* --- Password change --- */ +"passwort.titel" = "Change password"; +"passwort.bisher" = "Current password"; +"passwort.neu" = "New password"; +"passwort.wiederholen" = "Once more"; +"passwort.erklaerung" = "This account has to change its password. The server allows nothing else until then."; +"passwort.stimmtNichtUeberein" = "These two do not match."; +"passwort.speichern" = "Change password"; + +/* --- Accounts --- */ +"konten.titel" = "Accounts"; +"konten.leerTitel" = "No accounts yet"; +"konten.leerErklaerung" = "Create your first account in the browser; the app will get this in a later stage."; +"konten.archivierteZeigen" = "Show archived"; +"konten.archiviert" = "archived"; +"konten.gebunden" = "Money set aside"; +"konten.gebundenSumme" = "Of which set aside"; +"konten.verfuegbar" = "Available"; +"konten.bankWeichtAb" = "The bank reports a different balance"; + +/* --- Account types --- */ +"kontoart.girokonto" = "Current account"; +"kontoart.sparkonto" = "Savings account"; +"kontoart.bargeld" = "Cash"; +"kontoart.kreditkarte" = "Credit card"; +"kontoart.depot" = "Securities account"; +"kontoart.sonstiges" = "Other"; + +/* --- Settings --- */ +"einstellungen.zugang" = "Access"; +"einstellungen.angemeldetAls" = "Signed in as"; +"einstellungen.server" = "Server"; +"einstellungen.markeErklaerung" = "This device holds a key of its own. You can revoke it any time on the desktop under Settings → Device keys; the app signs out immediately."; +"einstellungen.ausDemServer" = "From the server"; +"einstellungen.hauptwaehrung" = "Main currency"; +"einstellungen.budgetStarttag" = "Budget starts on"; +"einstellungen.abmeldenFrage" = "Sign out on this device?"; +"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";