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 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(_ 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) } } }