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
+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))"
}
}