Files
certain/ios/Konten/KontoFormular.swift
T
GuidoandClaude Opus 5 82db09e6de feat: Dritte Stufe der iOS-App - alles laesst sich jetzt bearbeiten
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>
2026-10-21 21:04:46 +02:00

215 lines
8.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}
}