Files
ScarriffleandClaude Opus 5 6b86a6bf91 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>
2026-10-20 16:20:03 +02:00

128 lines
4.5 KiB
Swift

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
}
}