Files
certain/ios/Bausteine/Formular.swift
T
GuidoandClaude Opus 5 2d1d571b17 feat: Gruen ist die Vorgabe fuer Kategorien und Schlagwoerter
Die Farbauswahl der App lag dreimal gleich im Code; sie steht jetzt einmal
in Farbwahl, mit dem Markengruen an erster Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:51:49 +02:00

229 lines
7.9 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
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",
]
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(Farbwahl.palette, 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)
}
}
}