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>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
/// Ein laufendes Speichern: sperrt den Knopf und hält den Fehler fest.
|
||||
///
|
||||
/// Vier Formulare brauchen genau dasselbe. Zwei davon hätten es noch je für
|
||||
/// sich machen können, bei vieren liefen die Kopien auseinander.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Speichervorgang {
|
||||
private(set) var laeuft = false
|
||||
var fehler: String?
|
||||
|
||||
/// Führt die Arbeit aus und sagt, ob sie geklappt hat. Ein Fehler bleibt
|
||||
/// in `fehler` stehen, damit das Formular offen bleiben und ihn zeigen
|
||||
/// kann – zugeklappt wäre die Eingabe weg und die Meldung nutzlos.
|
||||
func fuehreAus(_ arbeit: () async throws -> Void) async -> Bool {
|
||||
guard !laeuft else { return false }
|
||||
laeuft = true
|
||||
fehler = nil
|
||||
defer { laeuft = false }
|
||||
do {
|
||||
try await arbeit()
|
||||
return true
|
||||
} catch {
|
||||
fehler = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Fehlerzeile unter einem Formular.
|
||||
struct FehlerZeile: View {
|
||||
let text: String?
|
||||
|
||||
var body: some View {
|
||||
if let text {
|
||||
Label {
|
||||
Text(text)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Betrag zum Eintippen.
|
||||
///
|
||||
/// Gebunden an **Cent**, nicht an Text: Die Umrechnung an genau einer Stelle
|
||||
/// zu haben ist der Grund, warum `Geldbetrag` existiert. Die Zehnertastatur
|
||||
/// liefert einen Punkt, die deutsche ein Komma – `ausEingabe` nimmt beides.
|
||||
struct Betragsfeld: View {
|
||||
let titel: String
|
||||
let waehrung: String
|
||||
@Binding var cents: Int
|
||||
|
||||
@State private var text = ""
|
||||
@FocusState private var hatFokus: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(titel)
|
||||
Spacer()
|
||||
TextField(Geldbetrag(cents: 0, waehrung: waehrung).alsEingabe, text: $text)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.monospacedDigit()
|
||||
.focused($hatFokus)
|
||||
.frame(maxWidth: 140)
|
||||
Text(waehrung)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.onAppear {
|
||||
// Eine glatte Null als "0,00" vorzublenden sähe aus wie eine
|
||||
// Eingabe. Leer lassen, der Platzhalter sagt genug.
|
||||
if cents != 0 { text = Geldbetrag(cents: cents, waehrung: waehrung).alsEingabe }
|
||||
}
|
||||
.onChange(of: text) { _, neu in
|
||||
cents = Geldbetrag.ausEingabe(neu, waehrung: waehrung)?.cents ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kategorie wählen, nach Gruppen sortiert – wie im Web das Dropdown mit
|
||||
/// Gruppenüberschriften.
|
||||
struct KategorieFeld: View {
|
||||
let titel: String
|
||||
let nachschlag: Nachschlag
|
||||
/// Nur Kategorien dieser Art. `nil` zeigt alle.
|
||||
var art: Kategorieart?
|
||||
@Binding var auswahl: Int?
|
||||
|
||||
var body: some View {
|
||||
Picker(titel, selection: $auswahl) {
|
||||
Text(String(localized: "buchung.ohneKategorie")).tag(Int?.none)
|
||||
ForEach(gruppen) { gruppe in
|
||||
Section(gruppe.name) {
|
||||
ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in
|
||||
Text(kategorie.name).tag(Int?.some(kategorie.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gruppen: [Kategoriegruppe] {
|
||||
nachschlag.gruppen
|
||||
.filter { art == nil || $0.kind == art }
|
||||
.filter { $0.categories.contains { !$0.isArchived } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Konto wählen.
|
||||
struct KontoFeld: View {
|
||||
let titel: String
|
||||
let nachschlag: Nachschlag
|
||||
/// Ein Konto, das nicht zur Auswahl stehen soll – das Gegenkonto eines
|
||||
/// Übertrags darf nicht dasselbe sein wie das Quellkonto.
|
||||
var ausser: Int?
|
||||
@Binding var auswahl: Int?
|
||||
|
||||
var body: some View {
|
||||
Picker(titel, selection: $auswahl) {
|
||||
Text(String(localized: "buchung.konto.keines")).tag(Int?.none)
|
||||
ForEach(auswaehlbar) { konto in
|
||||
Text(konto.name).tag(Int?.some(konto.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var auswaehlbar: [Konto] {
|
||||
nachschlag.konten.filter { !$0.isArchived && $0.id != ausser }
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Schlagwörter einer Buchung an- und abwählen.
|
||||
struct SchlagwortFeld: View {
|
||||
let nachschlag: Nachschlag
|
||||
@Binding var gewaehlt: Set<Int>
|
||||
|
||||
var body: some View {
|
||||
if nachschlag.schlagwoerter.isEmpty {
|
||||
Text(String(localized: "buchung.keineSchlagwoerter"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(nachschlag.schlagwoerter) { etikett in
|
||||
Button {
|
||||
if gewaehlt.contains(etikett.id) { gewaehlt.remove(etikett.id) }
|
||||
else { gewaehlt.insert(etikett.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: etikett.color))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(etikett.name)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if gewaehlt.contains(etikett.id) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol und Farbe wählen – für Konten und Verträge.
|
||||
struct SymbolUndFarbeFeld: View {
|
||||
@Binding var symbol: String
|
||||
@Binding var farbe: String
|
||||
|
||||
/// Eine kleine Auswahl statt aller 600 Lucide-Namen: Wer mehr braucht,
|
||||
/// stellt es am Rechner ein. Die Namen sind die des Backends, nicht die
|
||||
/// der SF Symbols – `Symbole.sf` übersetzt beim Anzeigen.
|
||||
private static let symbole = [
|
||||
"wallet", "landmark", "piggy-bank", "credit-card", "banknote", "coins",
|
||||
"vault", "trending-up", "home", "shopping-cart", "car", "heart",
|
||||
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
||||
]
|
||||
|
||||
private static let farben = [
|
||||
"#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", "#c62828",
|
||||
"#0277bd", "#6b7480", "#5d4037",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
||||
Image(systemName: Symbole.sf(name))
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: farbe))
|
||||
}
|
||||
|
||||
auswahl(Self.farben, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
||||
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func auswahl<Inhalt: View>(_ werte: [String], aktuell: String,
|
||||
waehle: @escaping (String) -> Void,
|
||||
@ViewBuilder inhalt: @escaping (String) -> Inhalt) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(werte, id: \.self) { wert in
|
||||
Button { waehle(wert) } label: {
|
||||
inhalt(wert)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(wert == aktuell ? Color.accentColor.opacity(0.18)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(wert == aktuell ? Color.accentColor : .clear,
|
||||
lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user