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,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)
|
||||
}
|
||||
}
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
@@ -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