feat: Fuenfte Stufe der iOS-App - Kategorien, Schlagwoerter, Bank, zweiter Faktor

Die Nebenraeume haengen unter Einstellungen, nicht als eigene Reiter: Man
braucht sie selten, und eine Leiste mit zehn Reitern findet niemand mehr.

- Kategorien: anlegen, aendern, archivieren, loeschen - samt Gruppen. Eine
  Kategorie oeffnet ihre Erkennungswoerter, die sich dort hinzufuegen und
  entfernen lassen. Darunter steht, was sonst niemand merkt: Sie wirken nur
  auf kuenftige Importe.
- Woerter, die oft korrigiert werden, stehen als Pruefmeldung in den
  Einstellungen - behalten oder entfernen.
- Schlagwoerter: anlegen, umbenennen, umfaerben, loeschen. Neue bekommen
  reihum eine Farbe, damit nicht alle grau sind.
- Einstellungen: Erscheinungsbild, Hauptwaehrung und Budget-Starttag lassen
  sich jetzt aendern. Ohne Sichern-Knopf - es ist je Zeile genau ein Wert,
  und das Backend nimmt ihn einzeln entgegen. Dazu der Hinweis, dass sie dem
  Konto gehoeren und nicht dem Geraet.
- Bank: abrufen und Salden auffrischen. Verbinden bleibt am Rechner, die
  Bank leitet fest dorthin zurueck. Der letzte Fehler der Verbindung steht
  dabei, sonst ruft man vergeblich ab.
- Zweiter Faktor: einrichten in drei Schritten. Auf dem Telefon fuehrt der
  Weg ueber die otpauth-Adresse direkt in die Authenticator-App statt ueber
  einen QR-Code, den man sich selbst zeigen muesste. Die Rettungscodes
  erscheinen einmal; das Blatt laesst sich nicht wegwischen und erst
  schliessen, wenn man bestaetigt hat, sie gesichert zu haben.

Abschalten des zweiten Faktors bleibt bewusst draussen: Es verlangt das
Kontopasswort, und die App speichert keines. Ein Vollzugang auf diesem Geraet
soll den Schutz des Kontos nicht aufheben koennen.

59 Tests gruen. Texte in Deutsch und Englisch, zeilengleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-21 22:03:28 +02:00
co-authored by Claude Opus 5
parent bab5cc5060
commit d290930759
13 changed files with 1828 additions and 9 deletions
+218
View File
@@ -0,0 +1,218 @@
import Foundation
// MARK: - Schlagwörter
/// `KeywordOut` – ein Wort, an dem die Erkennung eine Kategorie festmacht.
///
/// Nicht zu verwechseln mit `Schlagwort` (einem Tag): Ein Tag klebt an einer
/// Buchung, ein Schlagwort hängt an einer **Kategorie** und wirkt nur auf
/// künftige Importe.
struct Erkennungswort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let categoryId: Int
let word: String
/// Nur wenn der Betrag mitentscheiden soll – „Migros" über 200 ist
/// Haushalt, darunter Lebensmittel.
let amountCents: Int?
private enum CodingKeys: String, CodingKey {
case id, word
case categoryId = "category_id"
case amountCents = "amount_cents"
}
}
/// `FlaggedKeywordOut` – ein Wort, das oft wieder korrigiert wird.
///
/// Ab fünf Anwendungen und mindestens einem Drittel Korrekturen meldet das
/// Backend es zur Prüfung. Behalten oder entfernen entscheidet der Mensch.
struct MarkiertesWort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let categoryId: Int?
let categoryName: String
let word: String
let appliedCount: Int
let overriddenCount: Int
private enum CodingKeys: String, CodingKey {
case id, word
case categoryId = "category_id"
case categoryName = "category_name"
case appliedCount = "applied_count"
case overriddenCount = "overridden_count"
}
}
/// `KeywordCreate`
struct ErkennungswortAnlegen: Encodable, Sendable {
let word: String
var amountCents: Int?
private enum CodingKeys: String, CodingKey {
case word
case amountCents = "amount_cents"
}
}
// MARK: - Kategorien schreiben
/// `CategoryCreate`
struct KategorieAnlegen: Encodable, Sendable {
let groupId: Int
let name: String
var icon = "more-horizontal"
var color = "#6b7480"
/// Zählt in keiner Auswertung mit – etwa durchlaufende Posten.
var excludedFromStats = false
/// Eine Ausgabe, die kein Verlust ist: Sparen.
var spendingIsGood = false
private enum CodingKeys: String, CodingKey {
case name, icon, color
case groupId = "group_id"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
}
}
/// `CategoryUpdate` – nur die gesetzten Felder gehen mit.
struct KategorieAendern: Encodable, Sendable {
var name: String?
var groupId: Int?
var icon: String?
var color: String?
var isArchived: Bool?
var excludedFromStats: Bool?
var spendingIsGood: Bool?
private enum CodingKeys: String, CodingKey {
case name, icon, color
case groupId = "group_id"
case isArchived = "is_archived"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
}
}
/// `CategoryGroupCreate`
struct GruppeAnlegen: Encodable, Sendable {
let name: String
var kind: Kategorieart = .expense
var icon = "list"
var color = "#6b7480"
}
/// `CategoryGroupUpdate`
struct GruppeAendern: Encodable, Sendable {
var name: String?
var icon: String?
var color: String?
}
// MARK: - Tags schreiben
/// `TagCreate`
struct TagAnlegen: Encodable, Sendable {
let name: String
var color = "#6b7480"
}
/// `TagUpdate`
struct TagAendern: Encodable, Sendable {
var name: String?
var color: String?
}
// MARK: - Zweiter Faktor
/// `TwoFactorStatusOut`
struct ZweiterFaktorStand: Codable, Sendable, Hashable {
let enabled: Bool
let recoveryCodesLeft: Int
private enum CodingKeys: String, CodingKey {
case enabled
case recoveryCodesLeft = "recovery_codes_left"
}
}
/// `TwoFactorSetupOut` – das noch **nicht** aktive Geheimnis.
///
/// Gültig wird es erst, wenn ein Code daraus geprüft wurde. Wer die
/// Einrichtung abbricht, lässt einfach ein unbenutztes Geheimnis stehen.
struct ZweiterFaktorEinrichtung: Codable, Sendable, Hashable {
let secret: String
let otpauthUri: String
/// Der QR-Code als `data:`-URI. Auf dem Telefon selten gebraucht – dort
/// führt der Weg über `otpauthUri` direkt in die Authenticator-App.
let qrPng: String
private enum CodingKeys: String, CodingKey {
case secret
case otpauthUri = "otpauth_uri"
case qrPng = "qr_png"
}
}
/// `RecoveryCodesOut` – einmal zu sehen, danach nie wieder.
struct Rettungscodes: Codable, Sendable, Hashable {
let codes: [String]
}
struct ZweiterFaktorCode: Encodable, Sendable {
let code: String
}
// MARK: - Bank
/// `BankConnectionOut` – eine bestehende Anbindung.
///
/// Hergestellt wird sie am Rechner: Die Bank leitet fest auf
/// `{base_url}/bank/rueckkehr` zurück. Abrufen geht von hier.
struct Bankverbindung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let aspspName: String
let aspspCountry: String
let validUntil: Zeitpunkt?
let lastSyncAt: Zeitpunkt?
/// Takt des automatischen Abrufs in Minuten, 0 = aus.
let pollMinutes: Int
let lastError: String?
let expiresSoon: Bool
private enum CodingKeys: String, CodingKey {
case id
case aspspName = "aspsp_name"
case aspspCountry = "aspsp_country"
case validUntil = "valid_until"
case lastSyncAt = "last_sync_at"
case pollMinutes = "poll_minutes"
case lastError = "last_error"
case expiresSoon = "expires_soon"
}
}
/// Was ein Abruf zurückmeldet.
struct Abrufergebnis: Codable, Sendable, Hashable {
let geholt: Int
let neu: Int
let gebucht: Int?
let vorgemerkt: Int?
let doppelt: Int?
let uebertraege: Int?
/// Die Verbindung hat kein zugeordnetes Konto – dann wurde nichts geholt.
let ohneZuordnung: Bool?
private enum CodingKeys: String, CodingKey {
case geholt, neu, gebucht, vorgemerkt, doppelt, uebertraege
case ohneZuordnung = "ohne_zuordnung"
}
/// Ein Satz für die Rückmeldung.
var meldung: String {
if ohneZuordnung == true {
return String(localized: "bank.ohneZuordnung")
}
return String(format: String(localized: "bank.abrufErgebnis"), geholt, neu)
}
}