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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a7545bf5b
commit
6b86a6bf91
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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..<stelle])
|
||||
bruch = String(text[text.index(after: stelle)...])
|
||||
}
|
||||
ganze = ganze.filter(\.isNumber)
|
||||
bruch = bruch.filter(\.isNumber)
|
||||
|
||||
// Mehr als zwei Nachkommastellen war keine Dezimalangabe, sondern ein
|
||||
// Tausendertrenner ("1.234" sind 1234, nicht 1.23).
|
||||
if bruch.count > 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))"
|
||||
}
|
||||
}
|
||||
@@ -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<A: Sendable, Darstellung: View>: View {
|
||||
let zustand: Ladezustand<A>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ?? ""
|
||||
}
|
||||
}
|
||||
@@ -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.<deine-bundle-id>`). 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)
|
||||
}
|
||||
}
|
||||
@@ -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<A: Decodable>(_ 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<A: Decodable>(_ 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<A: Decodable, K: Encodable>(_ 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<K: Encodable>(_ 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<A: Decodable>(_ 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<A: Decodable>(_ 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<A: Decodable>(_ 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() {}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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<A: Codable & Sendable> {
|
||||
private(set) var zustand: Ladezustand<A> = .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)
|
||||
}
|
||||
}
|
||||
@@ -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<A>: 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<A: Encodable>(_ 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<A: Decodable & Sendable>(_ typ: A.Type, unter schluessel: String) -> Eintrag<A>? {
|
||||
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<A: Sendable>: 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
|
||||
}
|
||||
}
|
||||
+119
@@ -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.<deine-bundle-id>`) 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.
|
||||
@@ -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("<html>".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)
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
@@ -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.";
|
||||
Reference in New Issue
Block a user