feat: Erste Stufe der iOS-App - Anmeldung und Kontenliste
Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage, Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste Fachansicht die Kontenliste. Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen. Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a7545bf5b
commit
6b86a6bf91
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
// Die Typen hier bilden die Antwortschemas aus `backend/app/schemas.py` nach.
|
||||
// Feldnamen stimmen eins zu eins überein – deshalb braucht keiner von ihnen
|
||||
// `CodingKeys`. Wer ein Feld umbenennt, bricht genau hier.
|
||||
|
||||
/// `GET /api/version` – die Prüfung, ob unter einer Adresse ein Certain läuft.
|
||||
struct Serverkennung: Codable, Sendable {
|
||||
let name: String
|
||||
let version: String
|
||||
let gitSha: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, version
|
||||
case gitSha = "git_sha"
|
||||
}
|
||||
|
||||
/// Der Name, den ein echter Server trägt (`backend/app/branding.py`).
|
||||
var istCertain: Bool { name.localizedCaseInsensitiveContains("certain") }
|
||||
}
|
||||
|
||||
/// `UserOut`
|
||||
struct Benutzer: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let role: Rolle
|
||||
let displayName: String?
|
||||
let isActive: Bool
|
||||
let mustChangePassword: Bool
|
||||
let mustChangePasswordReason: String?
|
||||
let totpEnabled: Bool
|
||||
let createdAt: Zeitpunkt
|
||||
let mfaSetupRequired: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, username, role
|
||||
case displayName = "display_name"
|
||||
case isActive = "is_active"
|
||||
case mustChangePassword = "must_change_password"
|
||||
case mustChangePasswordReason = "must_change_password_reason"
|
||||
case totpEnabled = "totp_enabled"
|
||||
case createdAt = "created_at"
|
||||
case mfaSetupRequired = "mfa_setup_required"
|
||||
}
|
||||
|
||||
enum Rolle: String, Codable, Sendable {
|
||||
case admin, user
|
||||
}
|
||||
|
||||
var anzeigename: String { displayName?.isEmpty == false ? displayName! : username }
|
||||
}
|
||||
|
||||
/// `LoginOut` – `status` ist `"ok"` oder `"2fa_required"`.
|
||||
struct AnmeldeAntwort: Codable, Sendable {
|
||||
let status: String
|
||||
let user: Benutzer?
|
||||
|
||||
var brauchtZweitenFaktor: Bool { status == "2fa_required" }
|
||||
}
|
||||
|
||||
/// `TwoFactorVerify`
|
||||
struct ZweiterFaktorEingabe: Codable, Sendable {
|
||||
let code: String
|
||||
let recovery: Bool
|
||||
}
|
||||
|
||||
/// `PasswordChange`
|
||||
struct PasswortWechsel: Codable, Sendable {
|
||||
let currentPassword: String
|
||||
let newPassword: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case currentPassword = "current_password"
|
||||
case newPassword = "new_password"
|
||||
}
|
||||
}
|
||||
|
||||
/// `ApiTokenCreate`
|
||||
struct MarkeAnlegen: Codable, Sendable {
|
||||
let name: String
|
||||
let scope: String
|
||||
let defaultAccountId: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, scope
|
||||
case defaultAccountId = "default_account_id"
|
||||
}
|
||||
|
||||
/// Die Marke, mit der sich die App anmeldet.
|
||||
static func fuerApp(name: String) -> MarkeAnlegen {
|
||||
MarkeAnlegen(name: name, scope: "app", defaultAccountId: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ApiTokenOut` beziehungsweise `ApiTokenCreated`.
|
||||
/// `token` trägt den Klartext – und zwar genau einmal, beim Anlegen.
|
||||
struct Marke: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let scope: String
|
||||
let defaultAccountId: Int?
|
||||
let lastUsedAt: Zeitpunkt?
|
||||
let createdAt: Zeitpunkt?
|
||||
let token: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, scope, token
|
||||
case defaultAccountId = "default_account_id"
|
||||
case lastUsedAt = "last_used_at"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Eintrag aus `GET /api/settings`. Werte sind immer Zeichenketten.
|
||||
struct Einstellung: Codable, Sendable, Hashable {
|
||||
let key: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
/// Die Einstellungen als benutzbares Ganzes.
|
||||
struct Einstellungen: Sendable, Hashable {
|
||||
var basiswaehrung: String = "EUR"
|
||||
var budgetStarttag: Int = 1
|
||||
var sprache: String = "de"
|
||||
var erscheinungsbild: String = "system"
|
||||
var datumsformat: String = "de"
|
||||
var aktiveWaehrungen: [String] = ["EUR", "CHF", "USD"]
|
||||
|
||||
init() {}
|
||||
|
||||
init(_ eintraege: [Einstellung]) {
|
||||
var nach: [String: String] = [:]
|
||||
for eintrag in eintraege { nach[eintrag.key] = eintrag.value }
|
||||
basiswaehrung = nach["base_currency"] ?? basiswaehrung
|
||||
budgetStarttag = min(31, max(1, Int(nach["budget_start_day"] ?? "") ?? 1))
|
||||
sprache = nach["locale"] ?? sprache
|
||||
erscheinungsbild = nach["theme"] ?? erscheinungsbild
|
||||
datumsformat = nach["date_format"] ?? datumsformat
|
||||
if let roh = nach["active_currencies"] {
|
||||
aktiveWaehrungen = roh.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).uppercased() }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
/// `AccountOut` aus `backend/app/schemas.py`.
|
||||
struct Konto: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let type: Kontoart
|
||||
let currency: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let openingBalanceCents: Int
|
||||
let openingDate: Tagesdatum?
|
||||
let creditLimitCents: Int?
|
||||
let isLocked: Bool
|
||||
let isArchived: Bool
|
||||
let position: Int
|
||||
let savingsCategoryId: Int?
|
||||
let identifiers: [Kontokennung]
|
||||
/// Saldo in der Währung des Kontos.
|
||||
let balanceCents: Int
|
||||
/// Derselbe Saldo in der Hauptwährung – vom Server gerechnet.
|
||||
let balanceBaseCents: Int
|
||||
/// Was die Bank selbst meldet. Nur bei angebundenen Konten gesetzt.
|
||||
let bankBalanceCents: Int?
|
||||
let bankBalanceAt: Zeitpunkt?
|
||||
/// Nur ein gebuchter Banksaldo lässt sich mit unserer Rechnung
|
||||
/// vergleichen; ein verfügbarer enthält Vorgemerktes.
|
||||
let bankBalanceVergleichbar: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, type, currency, icon, color, position, identifiers
|
||||
case openingBalanceCents = "opening_balance_cents"
|
||||
case openingDate = "opening_date"
|
||||
case creditLimitCents = "credit_limit_cents"
|
||||
case isLocked = "is_locked"
|
||||
case isArchived = "is_archived"
|
||||
case savingsCategoryId = "savings_category_id"
|
||||
case balanceCents = "balance_cents"
|
||||
case balanceBaseCents = "balance_base_cents"
|
||||
case bankBalanceCents = "bank_balance_cents"
|
||||
case bankBalanceAt = "bank_balance_at"
|
||||
case bankBalanceVergleichbar = "bank_balance_vergleichbar"
|
||||
}
|
||||
|
||||
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
|
||||
|
||||
/// Ob der gemeldete Banksaldo von unserer Rechnung abweicht – nur dann ist
|
||||
/// ein Hinweis angebracht.
|
||||
var weichtVonDerBankAb: Bool {
|
||||
guard bankBalanceVergleichbar, let bank = bankBalanceCents else { return false }
|
||||
return bank != balanceCents
|
||||
}
|
||||
}
|
||||
|
||||
/// `AccountType`
|
||||
enum Kontoart: String, Codable, Sendable, CaseIterable {
|
||||
case girokonto, sparkonto, bargeld, kreditkarte, depot, sonstiges
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .girokonto: return String(localized: "kontoart.girokonto")
|
||||
case .sparkonto: return String(localized: "kontoart.sparkonto")
|
||||
case .bargeld: return String(localized: "kontoart.bargeld")
|
||||
case .kreditkarte: return String(localized: "kontoart.kreditkarte")
|
||||
case .depot: return String(localized: "kontoart.depot")
|
||||
case .sonstiges: return String(localized: "kontoart.sonstiges")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Übertrag auf diese Konten zählt im Budget als Sparen, nicht als
|
||||
/// neutrale Umbuchung (`SAVINGS_ACCOUNT_TYPES` im Backend).
|
||||
var giltAlsSparen: Bool { self == .sparkonto || self == .depot }
|
||||
}
|
||||
|
||||
/// `AccountIdentifierOut`
|
||||
struct Kontokennung: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let kind: Art
|
||||
let value: String
|
||||
|
||||
enum Art: String, Codable, Sendable {
|
||||
case iban
|
||||
case accountNo = "account_no"
|
||||
case cardNo = "card_no"
|
||||
case text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Kalendertag ohne Uhrzeit, so wie das Backend ihn schickt: `"2026-07-14"`.
|
||||
///
|
||||
/// Warum ein eigener Typ und nicht `Date` mit einer Decoder-Strategie: Das
|
||||
/// Backend mischt zwei Formate. Buchungsdaten sind reine Tage, `created_at`
|
||||
/// und Konsorten sind volle Zeitstempel mit Zone. Eine einzige
|
||||
/// `dateDecodingStrategy` müsste beides raten und würde mal das eine, mal das
|
||||
/// andere zerlegen. Zwei saubere Typen sind hier weniger Arbeit als ein
|
||||
/// cleverer Decoder.
|
||||
struct Tagesdatum: Codable, Hashable, Comparable, Sendable, CustomStringConvertible {
|
||||
/// Mitternacht dieses Tages, in der Zeitzone des Geräts.
|
||||
let datum: Date
|
||||
/// Die Form, in der es kam und wieder hinausgeht.
|
||||
let text: String
|
||||
|
||||
private static let leser: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.calendar = Calendar(identifier: .gregorian)
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = .current
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
init?(_ text: String) {
|
||||
guard let geparst = Tagesdatum.leser.date(from: text) else { return nil }
|
||||
self.datum = geparst
|
||||
self.text = text
|
||||
}
|
||||
|
||||
init(_ datum: Date) {
|
||||
self.datum = datum
|
||||
self.text = Tagesdatum.leser.string(from: datum)
|
||||
}
|
||||
|
||||
static var heute: Tagesdatum { Tagesdatum(Date()) }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let roh = try decoder.singleValueContainer().decode(String.self)
|
||||
guard let fertig = Tagesdatum(roh) else {
|
||||
throw DecodingError.dataCorrupted(.init(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Kein Tagesdatum: \(roh)"))
|
||||
}
|
||||
self = fertig
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var behaelter = encoder.singleValueContainer()
|
||||
try behaelter.encode(text)
|
||||
}
|
||||
|
||||
static func < (links: Tagesdatum, rechts: Tagesdatum) -> Bool {
|
||||
links.datum < rechts.datum
|
||||
}
|
||||
|
||||
var description: String { text }
|
||||
|
||||
/// Für die Anzeige, in der Sprache des Geräts.
|
||||
func formatiert(_ stil: Date.FormatStyle.DateStyle = .abbreviated) -> String {
|
||||
datum.formatted(.dateTime.day().month(.defaultDigits).year())
|
||||
.replacingOccurrences(of: "\u{202F}", with: " ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein voller Zeitstempel, wie ihn `created_at`, `last_used_at` und
|
||||
/// `bank_balance_at` tragen.
|
||||
///
|
||||
/// Das Backend schickt ISO 8601, mal mit Sekundenbruchteilen, mal ohne, mal
|
||||
/// mit `Z`, mal mit Offset. Deshalb zwei Leser hintereinander statt einem.
|
||||
struct Zeitpunkt: Codable, Hashable, Comparable, Sendable {
|
||||
let datum: Date
|
||||
|
||||
private static let mitBruchteilen: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let ohneBruchteile: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Letzter Ausweg: Python schreibt ohne Zone, wenn keine gesetzt ist.
|
||||
private static let ohneZone: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.calendar = Calendar(identifier: .gregorian)
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
return f
|
||||
}()
|
||||
|
||||
init(_ datum: Date) { self.datum = datum }
|
||||
|
||||
init?(_ text: String) {
|
||||
let ohneMikro = text.replacingOccurrences(
|
||||
of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression)
|
||||
if let d = Zeitpunkt.mitBruchteilen.date(from: ohneMikro) { self.datum = d; return }
|
||||
if let d = Zeitpunkt.ohneBruchteile.date(from: text) { self.datum = d; return }
|
||||
let gekuerzt = String(text.prefix(19))
|
||||
if let d = Zeitpunkt.ohneZone.date(from: gekuerzt) { self.datum = d; return }
|
||||
return nil
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let roh = try decoder.singleValueContainer().decode(String.self)
|
||||
guard let fertig = Zeitpunkt(roh) else {
|
||||
throw DecodingError.dataCorrupted(.init(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Kein Zeitpunkt: \(roh)"))
|
||||
}
|
||||
self = fertig
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var behaelter = encoder.singleValueContainer()
|
||||
try behaelter.encode(Zeitpunkt.mitBruchteilen.string(from: datum))
|
||||
}
|
||||
|
||||
static func < (links: Zeitpunkt, rechts: Zeitpunkt) -> Bool {
|
||||
links.datum < rechts.datum
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user