Buchungen anlegen, aendern und loeschen; Budget-Zeilen setzen, fuer den Monat oder fuer die Vorlage; Vertraege und Konten anlegen und aendern. Damit kann die App alles, wofuer man bisher an den Rechner musste - ausser Import, Arbeitsliste und Verwaltung. Ein Formular je Sache, nicht zwei: Anlegen und Aendern teilen sich die Maske, weil die Felder dieselben sind und zwei Kopien genau die Stelle waeren, an der sie auseinanderlaufen. Was sich unterscheidet, steht in einem Zweck-Typ. Geteilt in Bausteine/Formular.swift: ein Speichervorgang, der den Knopf sperrt und den Fehler festhaelt (das Formular bleibt offen - zugeklappt waere die Eingabe weg und die Meldung nutzlos), ein Betragsfeld, das an Cent gebunden ist statt an Text, und die Auswahlfelder fuer Kategorie, Konto und Schlagwoerter. Drei Dinge, die stillschweigend falsch waeren: Eine Teilaenderung schickt nur, was gesetzt ist. Ginge ein nicht angefasstes Feld als null hinaus, loeschte jedes Speichern den Empfaenger, die Notiz und die Kategorie gleich mit. Eine leere Tag-Liste geht dagegen sehr wohl mit - sie bedeutet "alle abnehmen" und darf nicht mit "nicht angefasst" zusammenfallen. Bei einem Uebertrag wird keine Kategorie mitgeschickt: Das Geld wechselt nur die Tasche, es wird nicht eingeordnet. Bei Betriebsart "berechnet" bleibt planned_cents unberuehrt - das Backend liest es dann nicht, und es zu ueberschreiben verloere den Wert, auf den ein Zurueckschalten auf "fest" zurueckfallen soll. Die Waehrung eines bestehenden Kontos laesst sich nicht aendern: Jeder gespeicherte Betrag wuerde danach etwas anderes bedeuten. 49 Tests gruen. Texte in Deutsch und Englisch, zeilengleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
8.4 KiB
Swift
215 lines
8.4 KiB
Swift
import SwiftUI
|
||
|
||
/// Ein Konto anlegen oder ändern.
|
||
struct KontoFormular: View {
|
||
enum Zweck: Equatable, Identifiable {
|
||
case neu
|
||
case aendern(Konto)
|
||
|
||
var id: Int {
|
||
switch self {
|
||
case .neu: return 0
|
||
case .aendern(let konto): return konto.id
|
||
}
|
||
}
|
||
}
|
||
|
||
let zweck: Zweck
|
||
var fertig: () -> Void
|
||
|
||
@Environment(AppZustand.self) private var zustand
|
||
@Environment(\.dismiss) private var schliesse
|
||
@State private var vorgang = Speichervorgang()
|
||
|
||
@State private var name = ""
|
||
@State private var art: Kontoart = .girokonto
|
||
@State private var waehrung = "EUR"
|
||
@State private var anfangssaldoCents = 0
|
||
@State private var hatStichtag = false
|
||
@State private var stichtag = Date()
|
||
@State private var hatKreditrahmen = false
|
||
@State private var kreditrahmenCents = 0
|
||
@State private var istGebunden = false
|
||
@State private var istArchiviert = false
|
||
@State private var symbol = "wallet"
|
||
@State private var farbe = "#3f51b5"
|
||
@State private var fragtNachLoeschen = false
|
||
@State private var vorbelegt = false
|
||
|
||
/// Was das Backend annimmt (`_check_currency`). Mehr braucht ein
|
||
/// Haushaltsbuch hier nicht; die Hauptwährung steht in den Einstellungen.
|
||
private static let waehrungen = ["EUR", "CHF", "USD", "GBP"]
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section {
|
||
TextField(String(localized: "konto.name"), text: $name)
|
||
Picker(String(localized: "konto.art"), selection: $art) {
|
||
ForEach(Kontoart.allCases, id: \.self) { eine in
|
||
Text(eine.name).tag(eine)
|
||
}
|
||
}
|
||
if case .neu = zweck {
|
||
Picker(String(localized: "konto.waehrung"), selection: $waehrung) {
|
||
ForEach(Self.waehrungen, id: \.self) { code in
|
||
Text(code).tag(code)
|
||
}
|
||
}
|
||
} else {
|
||
// Nachträglich zu wechseln liesse jeden gespeicherten
|
||
// Betrag etwas anderes bedeuten. Deshalb nur zu sehen.
|
||
LabeledContent(String(localized: "konto.waehrung"), value: waehrung)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Betragsfeld(titel: String(localized: "konto.anfangssaldo"),
|
||
waehrung: waehrung, cents: $anfangssaldoCents)
|
||
Toggle(String(localized: "konto.stichtagKennen"), isOn: $hatStichtag)
|
||
if hatStichtag {
|
||
DatePicker(String(localized: "konto.stichtag"), selection: $stichtag,
|
||
displayedComponents: .date)
|
||
}
|
||
} footer: {
|
||
Text(String(localized: "konto.anfangssaldoErklaerung"))
|
||
}
|
||
|
||
if art == .kreditkarte {
|
||
Section {
|
||
Toggle(String(localized: "konto.kreditrahmenKennen"),
|
||
isOn: $hatKreditrahmen)
|
||
if hatKreditrahmen {
|
||
Betragsfeld(titel: String(localized: "konto.kreditrahmen"),
|
||
waehrung: waehrung, cents: $kreditrahmenCents)
|
||
}
|
||
} footer: {
|
||
Text(String(localized: "konto.kreditrahmenErklaerung"))
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Toggle(String(localized: "konto.gebunden"), isOn: $istGebunden)
|
||
} footer: {
|
||
Text(String(localized: "konto.gebundenErklaerung"))
|
||
}
|
||
|
||
Section {
|
||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||
}
|
||
|
||
if case .aendern = zweck {
|
||
Section {
|
||
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
|
||
Button(String(localized: "konto.loeschen"), role: .destructive) {
|
||
fragtNachLoeschen = true
|
||
}
|
||
} footer: {
|
||
Text(String(localized: "konto.archivierenStattLoeschen"))
|
||
}
|
||
}
|
||
|
||
if let fehler = vorgang.fehler {
|
||
Section { FehlerZeile(text: fehler) }
|
||
}
|
||
}
|
||
.navigationTitle(titel)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button(String(localized: "allgemein.sichern")) {
|
||
Task { await sichere() }
|
||
}
|
||
.disabled(!istVollstaendig || vorgang.laeuft)
|
||
}
|
||
}
|
||
.interactiveDismissDisabled(vorgang.laeuft)
|
||
.confirmationDialog(String(localized: "konto.loeschenFrage"),
|
||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||
Button(String(localized: "konto.loeschen"), role: .destructive) {
|
||
Task { await loesche() }
|
||
}
|
||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||
} message: {
|
||
Text(String(localized: "konto.loeschenErklaerung"))
|
||
}
|
||
.onAppear(perform: belegeVor)
|
||
}
|
||
}
|
||
|
||
private var titel: String {
|
||
switch zweck {
|
||
case .neu: return String(localized: "konto.neu")
|
||
case .aendern: return String(localized: "konto.aendern")
|
||
}
|
||
}
|
||
|
||
private var istVollstaendig: Bool {
|
||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
private func belegeVor() {
|
||
guard !vorbelegt else { return }
|
||
vorbelegt = true
|
||
|
||
guard case .aendern(let konto) = zweck else {
|
||
waehrung = zustand.einstellungen.basiswaehrung
|
||
return
|
||
}
|
||
name = konto.name
|
||
art = konto.type
|
||
waehrung = konto.currency
|
||
anfangssaldoCents = konto.openingBalanceCents
|
||
hatStichtag = konto.openingDate != nil
|
||
stichtag = konto.openingDate?.datum ?? Date()
|
||
hatKreditrahmen = konto.creditLimitCents != nil
|
||
kreditrahmenCents = konto.creditLimitCents ?? 0
|
||
istGebunden = konto.isLocked
|
||
istArchiviert = konto.isArchived
|
||
symbol = konto.icon
|
||
farbe = konto.color
|
||
}
|
||
|
||
private func sichere() async {
|
||
guard let client = zustand.client else { return }
|
||
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let datum = hatStichtag ? Tagesdatum(stichtag) : nil
|
||
// Nur bei Kreditkarten überhaupt ein Rahmen – bei allem anderen wäre
|
||
// die Zahl bedeutungslos und würde beim Umstellen der Art hängenbleiben.
|
||
let rahmen = (art == .kreditkarte && hatKreditrahmen) ? kreditrahmenCents : nil
|
||
|
||
let geglueckt = await vorgang.fuehreAus {
|
||
switch zweck {
|
||
case .neu:
|
||
_ = try await client.legeKontoAn(KontoAnlegen(
|
||
name: sauberName, type: art, currency: waehrung, icon: symbol,
|
||
color: farbe, openingBalanceCents: anfangssaldoCents,
|
||
openingDate: datum, creditLimitCents: rahmen, isLocked: istGebunden))
|
||
case .aendern(let konto):
|
||
_ = try await client.aendereKonto(konto.id, KontoAendern(
|
||
name: sauberName, type: art, icon: symbol, color: farbe,
|
||
openingBalanceCents: anfangssaldoCents, openingDate: datum,
|
||
creditLimitCents: rahmen, isLocked: istGebunden,
|
||
isArchived: istArchiviert))
|
||
}
|
||
}
|
||
if geglueckt {
|
||
await zustand.ladeNachschlag()
|
||
fertig()
|
||
schliesse()
|
||
}
|
||
}
|
||
|
||
private func loesche() async {
|
||
guard let client = zustand.client, case .aendern(let konto) = zweck else { return }
|
||
if await vorgang.fuehreAus({ try await client.loescheKonto(konto.id) }) {
|
||
await zustand.ladeNachschlag()
|
||
fertig()
|
||
schliesse()
|
||
}
|
||
}
|
||
}
|