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>
219 lines
8.7 KiB
Swift
219 lines
8.7 KiB
Swift
import SwiftUI
|
||
|
||
/// Buchung anlegen oder ändern.
|
||
///
|
||
/// Eine Ansicht für beides: Die Felder sind dieselben, und zwei Formulare
|
||
/// nebeneinander wären genau die Stelle, an der sie auseinanderlaufen. Was
|
||
/// sich unterscheidet, steht in `BuchungFormular.Zweck`.
|
||
struct BuchungFormular: View {
|
||
enum Zweck: Equatable, Identifiable {
|
||
case neu
|
||
case aendern(Buchung)
|
||
|
||
/// Damit `.sheet(item:)` damit umgehen kann. Die ID der Buchung
|
||
/// genügt: Zwei Formulare für dieselbe Buchung gibt es nicht.
|
||
var id: Int {
|
||
switch self {
|
||
case .neu: return 0
|
||
case .aendern(let buchung): return buchung.id
|
||
}
|
||
}
|
||
}
|
||
|
||
let zweck: Zweck
|
||
/// Wird nach erfolgreichem Speichern oder Löschen gerufen – die Liste
|
||
/// dahinter muss sich neu laden.
|
||
var fertig: () -> Void
|
||
|
||
@Environment(AppZustand.self) private var zustand
|
||
@Environment(\.dismiss) private var schliesse
|
||
@State private var vorgang = Speichervorgang()
|
||
|
||
@State private var art: Buchungsart = .expense
|
||
@State private var datum = Date()
|
||
@State private var kontoId: Int?
|
||
@State private var zielkontoId: Int?
|
||
@State private var betragCents = 0
|
||
@State private var kategorieId: Int?
|
||
@State private var empfaenger = ""
|
||
@State private var zweckText = ""
|
||
@State private var notiz = ""
|
||
@State private var etiketten: Set<Int> = []
|
||
@State private var fragtNachLoeschen = false
|
||
@State private var vorbelegt = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section {
|
||
Picker(String(localized: "buchungen.art"), selection: $art) {
|
||
ForEach(Buchungsart.allCases) { eine in
|
||
Text(eine.name).tag(eine)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
DatePicker(String(localized: "buchung.datum"), selection: $datum,
|
||
displayedComponents: .date)
|
||
|
||
Betragsfeld(titel: String(localized: "buchung.betrag"),
|
||
waehrung: waehrung, cents: $betragCents)
|
||
}
|
||
|
||
Section {
|
||
KontoFeld(titel: String(localized: "buchung.konto"),
|
||
nachschlag: zustand.nachschlag, auswahl: $kontoId)
|
||
if art == .transfer {
|
||
KontoFeld(titel: String(localized: "buchung.zielkonto"),
|
||
nachschlag: zustand.nachschlag, ausser: kontoId,
|
||
auswahl: $zielkontoId)
|
||
} else {
|
||
KategorieFeld(titel: String(localized: "buchung.kategorie"),
|
||
nachschlag: zustand.nachschlag,
|
||
art: art == .income ? .income : .expense,
|
||
auswahl: $kategorieId)
|
||
}
|
||
} footer: {
|
||
if art == .transfer {
|
||
Text(String(localized: "buchung.uebertragErklaerung"))
|
||
}
|
||
}
|
||
|
||
Section {
|
||
TextField(String(localized: "buchung.empfaenger"), text: $empfaenger)
|
||
TextField(String(localized: "buchung.verwendungszweck"), text: $zweckText)
|
||
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
|
||
.lineLimit(1...4)
|
||
}
|
||
|
||
Section(String(localized: "buchung.schlagwoerter")) {
|
||
SchlagwortFeld(nachschlag: zustand.nachschlag, gewaehlt: $etiketten)
|
||
}
|
||
|
||
if let fehler = vorgang.fehler {
|
||
Section { FehlerZeile(text: fehler) }
|
||
}
|
||
|
||
if case .aendern = zweck {
|
||
Section {
|
||
Button(String(localized: "buchung.loeschen"), role: .destructive) {
|
||
fragtNachLoeschen = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.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: "buchung.loeschenFrage"),
|
||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||
Button(String(localized: "buchung.loeschen"), role: .destructive) {
|
||
Task { await loesche() }
|
||
}
|
||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||
}
|
||
.onAppear(perform: belegeVor)
|
||
}
|
||
}
|
||
|
||
private var titel: String {
|
||
switch zweck {
|
||
case .neu: return String(localized: "buchung.neu")
|
||
case .aendern: return String(localized: "buchung.aendern")
|
||
}
|
||
}
|
||
|
||
/// Die Währung des gewählten Kontos – Beträge stehen immer in ihr.
|
||
private var waehrung: String {
|
||
zustand.nachschlag.konto(kontoId)?.currency ?? zustand.einstellungen.basiswaehrung
|
||
}
|
||
|
||
/// Das Backend verlangt einen Betrag grösser null und bei einem Übertrag
|
||
/// ein Zielkonto. Hier zu prüfen erspart dem Nutzer eine Fehlermeldung
|
||
/// vom Server für etwas, das die App schon weiss.
|
||
private var istVollstaendig: Bool {
|
||
guard betragCents > 0, kontoId != nil else { return false }
|
||
if art == .transfer { return zielkontoId != nil && zielkontoId != kontoId }
|
||
return true
|
||
}
|
||
|
||
private func belegeVor() {
|
||
guard !vorbelegt else { return }
|
||
vorbelegt = true
|
||
|
||
guard case .aendern(let buchung) = zweck else {
|
||
kontoId = zustand.nachschlag.konten.first { !$0.isArchived }?.id
|
||
return
|
||
}
|
||
art = buchung.type
|
||
datum = buchung.date.datum
|
||
kontoId = buchung.accountId
|
||
zielkontoId = buchung.toAccountId
|
||
betragCents = abs(buchung.amountCents)
|
||
kategorieId = buchung.categoryId
|
||
empfaenger = buchung.payee ?? ""
|
||
zweckText = buchung.description ?? ""
|
||
notiz = buchung.notes ?? ""
|
||
etiketten = Set(buchung.tagIds)
|
||
}
|
||
|
||
private func sichere() async {
|
||
guard let client = zustand.client, let kontoId else { return }
|
||
let tag = Tagesdatum(datum)
|
||
// Bei einem Übertrag gibt es keine Kategorie – sie mitzuschicken
|
||
// hiesse, Geld einzuordnen, das nur die Tasche wechselt.
|
||
let kategorie = art == .transfer ? nil : kategorieId
|
||
let ziel = art == .transfer ? zielkontoId : nil
|
||
|
||
let geglueckt = await vorgang.fuehreAus {
|
||
switch zweck {
|
||
case .neu:
|
||
_ = try await client.legeBuchungAn(BuchungAnlegen(
|
||
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
|
||
amountCents: betragCents, categoryId: kategorie,
|
||
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
|
||
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
|
||
case .aendern(let buchung):
|
||
_ = try await client.aendereBuchung(buchung.id, BuchungAendern(
|
||
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
|
||
amountCents: betragCents, categoryId: kategorie,
|
||
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
|
||
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
|
||
}
|
||
}
|
||
if geglueckt {
|
||
fertig()
|
||
schliesse()
|
||
}
|
||
}
|
||
|
||
private func loesche() async {
|
||
guard let client = zustand.client, case .aendern(let buchung) = zweck else { return }
|
||
let geglueckt = await vorgang.fuehreAus {
|
||
try await client.loescheBuchung(buchung.id)
|
||
}
|
||
if geglueckt {
|
||
fertig()
|
||
schliesse()
|
||
}
|
||
}
|
||
|
||
/// Ein leeres Feld ist keine Angabe, sondern das Fehlen einer. Leerzeichen
|
||
/// als Empfänger zu speichern wäre eine Angabe, die niemand gemacht hat.
|
||
private func leerAlsNil(_ text: String) -> String? {
|
||
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return sauber.isEmpty ? nil : sauber
|
||
}
|
||
}
|