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:
Scarriffle
2026-10-20 16:20:03 +02:00
co-authored by Claude Opus 5
parent 8a7545bf5b
commit 6b86a6bf91
25 changed files with 2908 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
import SwiftUI
extension Color {
/// Die Farben aus der Datenbank sind Hex-Zeichenketten wie `#3f51b5`.
/// Alles, was nicht passt, wird zum neutralen Grau – eine kaputte Farbe
/// darf keine Ansicht leer lassen.
init(hex: String) {
var text = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("#") { text.removeFirst() }
// Das Backend erlaubt acht Stellen (#rrggbbaa); die Durchsicht nehmen
// wir mit, der Rest wird ignoriert.
guard text.count == 6 || text.count == 8,
let zahl = UInt64(text, radix: 16) else {
self = Color(red: 0.42, green: 0.45, blue: 0.50)
return
}
let hatAlpha = text.count == 8
let r = Double((zahl >> (hatAlpha ? 24 : 16)) & 0xFF) / 255
let g = Double((zahl >> (hatAlpha ? 16 : 8)) & 0xFF) / 255
let b = Double((zahl >> (hatAlpha ? 8 : 0)) & 0xFF) / 255
let a = hatAlpha ? Double(zahl & 0xFF) / 255 : 1
self = Color(.sRGB, red: r, green: g, blue: b, opacity: a)
}
}
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
///
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
/// SVG-Verzeichnis (`web/src/components/Icon.jsx`, knapp 600 Zeilen). Nativ
/// wäre das Unsinn: iOS bringt die Symbole mit, sie passen zur Systemschrift
/// und können fett, farbig und in jeder Grösse. Nur die Namen muss jemand
/// zuordnen – das ist diese Tabelle.
///
/// Die Liste deckt `ACCOUNT_ICONS` und `CATEGORY_ICONS` aus
/// `web/src/components/IconPicker.jsx` ab. Unbekanntes wird zum Platzhalter,
/// nie zu einer leeren Stelle.
enum Symbole {
static let platzhalter = "circle.dashed"
private static let tabelle: [String: String] = [
// Konten
"wallet": "wallet.bifold", "landmark": "building.columns",
"piggy-bank": "banknote", "vault": "lock.square.stack",
"credit-card": "creditcard", "trending-up": "chart.line.uptrend.xyaxis",
"banknote": "banknote", "coins": "centsign.circle",
// Wohnen
"home": "house", "bed": "bed.double", "zap": "bolt",
"droplet": "drop", "flame": "flame", "wifi": "wifi",
"monitor": "display", "cloud": "cloud",
// Alltag
"shopping-cart": "cart", "shopping-bag": "bag", "store": "storefront",
"utensils": "fork.knife", "coffee": "cup.and.saucer",
"wine": "wineglass", "shirt": "tshirt", "scissors": "scissors",
"sparkle": "sparkles",
// Unterwegs
"car": "car", "fuel": "fuelpump", "bus": "bus", "bike": "bicycle",
"plane": "airplane", "ticket": "ticket",
// Gesundheit
"heart-pulse": "heart.text.square", "pill": "pills",
"dumbbell": "figure.strengthtraining.traditional",
// Freizeit
"film": "film", "music": "music.note", "book-open": "book",
"newspaper": "newspaper", "graduation-cap": "graduationcap",
"paw-print": "pawprint",
// Familie
"sprout": "leaf", "cake": "birthday.cake", "gift": "gift",
"baby": "figure.and.child.holdinghands", "heart": "heart",
// Vorsorge
"umbrella": "umbrella", "life-buoy": "lifepreserver",
"shield": "shield", "scale": "scalemass",
// Sonstiges
"briefcase": "briefcase", "wrench": "wrench.adjustable",
"hammer": "hammer", "phone": "phone", "refresh-ccw": "arrow.triangle.2.circlepath",
"percent": "percent", "receipt": "receipt",
"list": "list.bullet", "more-horizontal": "ellipsis",
// In der Oberfläche benutzt, nicht in der Auswahl
"tags": "tag", "inbox": "tray", "settings": "gearshape",
"arrow-left-right": "arrow.left.arrow.right", "pie-chart": "chart.pie",
"dashboard": "square.grid.2x2", "download": "square.and.arrow.down",
"calendar": "calendar", "search": "magnifyingglass",
"edit": "pencil", "trash": "trash", "plus": "plus", "check": "checkmark",
"close": "xmark", "chevron-down": "chevron.down",
]
static func sf(_ name: String?) -> String {
guard let name, let treffer = tabelle[name] else { return platzhalter }
return treffer
}
}
/// Ein farbiges Symbolplättchen – das native Gegenstück zum `IconChip` im Web.
struct Symbolplakette: View {
let symbol: String?
let farbe: String
var groesse: CGFloat = 34
var body: some View {
let farbwert = Color(hex: farbe)
RoundedRectangle(cornerRadius: groesse * 0.28, style: .continuous)
.fill(farbwert.opacity(0.16))
.frame(width: groesse, height: groesse)
.overlay {
Image(systemName: Symbole.sf(symbol))
.font(.system(size: groesse * 0.46, weight: .medium))
.foregroundStyle(farbwert)
}
.accessibilityHidden(true)
}
}
+118
View File
@@ -0,0 +1,118 @@
import Foundation
/// Geld, so wie das Backend es führt: ganze Cent plus Währungscode.
///
/// Nirgends Fliesskomma. Die API kennt ausschliesslich Cent-Ganzzahlen
/// (einzige Ausnahme ist der Kurzbefehl-Endpunkt), und das bleibt hier so –
/// ein `Double` hätte bei Summen über viele Buchungen Rundungsfehler.
struct Geldbetrag: Hashable, Sendable {
let cents: Int
let waehrung: String
init(cents: Int, waehrung: String) {
self.cents = cents
self.waehrung = waehrung.uppercased()
}
var dezimal: Decimal { Decimal(cents) / Decimal(100) }
var istNull: Bool { cents == 0 }
var istNegativ: Bool { cents < 0 }
/// Für die Anzeige, in der Sprache des Geräts.
///
/// `vorzeichen: .immer` schreibt auch bei positiven Beträgen ein Plus –
/// das braucht die Buchungsliste, wo Einnahme und Ausgabe nebeneinander
/// stehen und das Vorzeichen die einzige Unterscheidung ist.
func formatiert(vorzeichen: Vorzeichenstil = .nurMinus) -> String {
let wert = vorzeichen == .ohne ? abs(dezimal) : dezimal
var text = wert.formatted(.currency(code: waehrung).locale(.autoupdatingCurrent))
if vorzeichen == .immer, cents > 0 {
text = "+" + text
}
return text
}
enum Vorzeichenstil: Sendable {
/// Minus bei negativen Beträgen, sonst nichts. Der Normalfall.
case nurMinus
/// Auch bei positiven ein Plus.
case immer
/// Immer ohne – wenn die Richtung schon woanders steht.
case ohne
}
/// Ohne Nachkommastellen, für enge Stellen wie Diagramm-Achsen.
var gerundet: String {
(dezimal as NSDecimalNumber).doubleValue.formatted(
.currency(code: waehrung).precision(.fractionLength(0)))
}
static func + (links: Geldbetrag, rechts: Geldbetrag) -> Geldbetrag {
precondition(links.waehrung == rechts.waehrung,
"Betraege verschiedener Waehrungen lassen sich nicht addieren")
return Geldbetrag(cents: links.cents + rechts.cents, waehrung: links.waehrung)
}
static prefix func - (betrag: Geldbetrag) -> Geldbetrag {
Geldbetrag(cents: -betrag.cents, waehrung: betrag.waehrung)
}
}
extension Geldbetrag {
/// Was der Nutzer tippt, in Cent.
///
/// Nimmt Komma und Punkt als Dezimaltrenner – auf einer deutschen Tastatur
/// tippt man „12,50", auf der Zehnertastatur „12.50", und beides meint
/// dasselbe. Tausendertrenner und Währungszeichen fliegen raus.
static func ausEingabe(_ eingabe: String, waehrung: String) -> Geldbetrag? {
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let negativ = text.hasPrefix("-")
text = text.filter { $0.isNumber || $0 == "," || $0 == "." }
// Ohne eine einzige Ziffer ist das keine Zahl, sondern ein Vertipper.
// Sonst käme aus "abc" ein Betrag von 0,00 heraus.
guard text.contains(where: \.isNumber) else { return nil }
// Steht beides drin, ist das hintere der Dezimaltrenner und das
// vordere der Tausendertrenner ("1.234,50" wie "1,234.50").
let letztesKomma = text.lastIndex(of: ",")
let letzterPunkt = text.lastIndex(of: ".")
let trenner: Character?
switch (letztesKomma, letzterPunkt) {
case (nil, nil): trenner = nil
case (_?, nil): trenner = ","
case (nil, _?): trenner = "."
case (let komma?, let punkt?): trenner = komma > punkt ? "," : "."
}
var ganze = text
var bruch = ""
if let trenner, let stelle = text.lastIndex(of: trenner) {
ganze = String(text[text.startIndex..<stelle])
bruch = String(text[text.index(after: stelle)...])
}
ganze = ganze.filter(\.isNumber)
bruch = bruch.filter(\.isNumber)
// Mehr als zwei Nachkommastellen war keine Dezimalangabe, sondern ein
// Tausendertrenner ("1.234" sind 1234, nicht 1.23).
if bruch.count > 2 {
ganze += bruch
bruch = ""
}
bruch = String((bruch + "00").prefix(2))
guard let vorne = Int(ganze.isEmpty ? "0" : ganze), let hinten = Int(bruch) else {
return nil
}
let summe = vorne * 100 + hinten
return Geldbetrag(cents: negativ ? -summe : summe, waehrung: waehrung)
}
/// Zum Vorbelegen eines Eingabefelds: „12.50", ohne Währung und Trenner.
var alsEingabe: String {
let betrag = abs(cents)
return "\(betrag / 100),\(String(format: "%02d", betrag % 100))"
}
}
+94
View File
@@ -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)
}
}
}