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>
119 lines
4.6 KiB
Swift
119 lines
4.6 KiB
Swift
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))"
|
||
}
|
||
}
|