Files
certain/ios/Netzwerk/Zwischenspeicher.swift
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

84 lines
3.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}