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>
128 lines
4.5 KiB
Swift
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
|
|
}
|
|
}
|