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>
84 lines
3.2 KiB
Swift
84 lines
3.2 KiB
Swift
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
|
||
}
|
||
}
|