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,94 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der Hinweis über einer Liste, deren Daten aus dem Zwischenspeicher kommen.
|
||||
///
|
||||
/// Wichtiger als er aussieht: Ohne ihn zeigt die App im Flugmodus alte Zahlen,
|
||||
/// die aussehen wie frische. Mit Datum, weil „offline" allein nicht sagt, ob
|
||||
/// der Stand von vor fünf Minuten oder von vor drei Wochen ist.
|
||||
struct StandHinweis: View {
|
||||
let geholtAm: Date
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(String(format: String(localized: "stand.hinweis"),
|
||||
geholtAm.formatted(.relative(presentation: .named))))
|
||||
} icon: {
|
||||
Image(systemName: "wifi.slash")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wenn gar nichts da ist: Fehler zeigen und, wo es Sinn ergibt, einen
|
||||
/// Knopf zum Nochmalversuchen.
|
||||
struct FehlerAnsicht: View {
|
||||
let fehler: APIFehler
|
||||
var nochmal: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "fehler.titel"), systemImage: symbol)
|
||||
} description: {
|
||||
Text(fehler.errorDescription ?? String(localized: "fehler.unbekannt"))
|
||||
} actions: {
|
||||
if let nochmal, fehler.lohntWiederholung {
|
||||
Button(String(localized: "allgemein.nochmal"), action: nochmal)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch fehler {
|
||||
case .keineVerbindung: return "wifi.slash"
|
||||
case .nichtAngemeldet: return "person.crop.circle.badge.exclamationmark"
|
||||
case .nichtGefunden: return "questionmark.folder"
|
||||
default: return "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Liste, die noch nichts enthält – aber nicht wegen eines Fehlers.
|
||||
struct LeerAnsicht: View {
|
||||
let titel: String
|
||||
let erklaerung: String
|
||||
var symbol: String = "tray"
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label(titel, systemImage: symbol)
|
||||
} description: {
|
||||
Text(erklaerung)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt die drei Zustände für eine Liste zusammen, damit das nicht jede
|
||||
/// Ansicht neu baut.
|
||||
struct Inhalt<A: Sendable, Darstellung: View>: View {
|
||||
let zustand: Ladezustand<A>
|
||||
var nochmal: (() -> Void)?
|
||||
@ViewBuilder let darstellung: (A) -> Darstellung
|
||||
|
||||
var body: some View {
|
||||
switch zustand {
|
||||
case .laedt:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .da(let wert):
|
||||
darstellung(wert)
|
||||
case .ausSpeicher(let wert, let wann, _):
|
||||
VStack(spacing: 0) {
|
||||
StandHinweis(geholtAm: wann)
|
||||
darstellung(wert)
|
||||
}
|
||||
case .fehler(let fehler):
|
||||
FehlerAnsicht(fehler: fehler, nochmal: nochmal)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user