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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user