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>
229 lines
7.9 KiB
Swift
229 lines
7.9 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|