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:
Scarriffle
2026-10-20 16:20:03 +02:00
co-authored by Claude Opus 5
parent 8a7545bf5b
commit 6b86a6bf91
25 changed files with 2908 additions and 0 deletions
+79
View File
@@ -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 = ""
}
}
}
+58
View File
@@ -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) }
}
}
+123
View File
@@ -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"))
}
}
}
+255
View File
@@ -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
}
}
+24
View File
@@ -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)
}
}
}
+53
View File
@@ -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)
}
}
+109
View File
@@ -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)
}
}
+118
View File
@@ -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))"
}
}
+94
View File
@@ -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) {}
}
}
}
+155
View File
@@ -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)
}
}
+145
View File
@@ -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 }
}
}
}
+87
View File
@@ -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
}
}
+127
View File
@@ -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
}
}
+106
View File
@@ -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 ?? ""
}
}
+126
View File
@@ -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)
}
}
+235
View File
@@ -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() {}
}
+98
View File
@@ -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)")
}
}
+57
View File
@@ -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)
}
}
+83
View File
@@ -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
View File
@@ -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.
+304
View File
@@ -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)
}
}
+104
View File
@@ -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.";
+104
View File
@@ -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.";