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,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))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user