diff --git a/ios/Abos/AboFormular.swift b/ios/Abos/AboFormular.swift new file mode 100644 index 0000000..7116127 --- /dev/null +++ b/ios/Abos/AboFormular.swift @@ -0,0 +1,201 @@ +import SwiftUI + +/// Einen Vertrag anlegen oder ändern. +struct AboFormular: View { + enum Zweck: Equatable, Identifiable { + case neu + case aendern(Abo) + + var id: Int { + switch self { + case .neu: return 0 + case .aendern(let abo): return abo.id + } + } + } + + let zweck: Zweck + var fertig: () -> Void + + @Environment(AppZustand.self) private var zustand + @Environment(\.dismiss) private var schliesse + @State private var vorgang = Speichervorgang() + + @State private var name = "" + @State private var betragCents = 0 + @State private var waehrung = "EUR" + @State private var rhythmus: Zahlungsrhythmus = .monatlich + @State private var kategorieId: Int? + @State private var kontoId: Int? + @State private var hatFaelligkeit = false + @State private var faelligAm = Date() + @State private var erkennungstext = "" + @State private var notiz = "" + @State private var istAktiv = true + @State private var symbol = "refresh-ccw" + @State private var farbe = "#6b7480" + @State private var fragtNachLoeschen = false + @State private var vorbelegt = false + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "abo.name"), text: $name) + Betragsfeld(titel: String(localized: "abo.betrag"), + waehrung: waehrung, cents: $betragCents) + Picker(String(localized: "abo.rhythmus"), selection: $rhythmus) { + ForEach(Zahlungsrhythmus.allCases) { einer in + Text(einer.name).tag(einer) + } + } + } + + Section { + KategorieFeld(titel: String(localized: "buchung.kategorie"), + nachschlag: zustand.nachschlag, art: .expense, + auswahl: $kategorieId) + KontoFeld(titel: String(localized: "abo.konto"), + nachschlag: zustand.nachschlag, auswahl: $kontoId) + } footer: { + Text(String(localized: "abo.kategorieErklaerung")) + } + + Section { + Toggle(String(localized: "abo.faelligkeitKennen"), isOn: $hatFaelligkeit) + if hatFaelligkeit { + DatePicker(String(localized: "abo.naechsteZahlung"), + selection: $faelligAm, displayedComponents: .date) + } + } + + Section { + TextField(String(localized: "abo.erkennungstext"), text: $erkennungstext) + } footer: { + Text(String(localized: "abo.erkennungErklaerung")) + } + + Section { + SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe) + TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical) + .lineLimit(1...3) + } + + if case .aendern = zweck { + Section { + Toggle(String(localized: "abo.laeuftNoch"), isOn: $istAktiv) + Button(String(localized: "abo.loeschen"), role: .destructive) { + fragtNachLoeschen = true + } + } footer: { + Text(String(localized: "abo.beendenStattLoeschen")) + } + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .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: "abo.loeschenFrage"), + isPresented: $fragtNachLoeschen, titleVisibility: .visible) { + Button(String(localized: "abo.loeschen"), role: .destructive) { + Task { await loesche() } + } + Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} + } message: { + Text(String(localized: "abo.loeschenErklaerung")) + } + .onAppear(perform: belegeVor) + } + } + + private var titel: String { + switch zweck { + case .neu: return String(localized: "abo.neu") + case .aendern: return String(localized: "abo.aendern") + } + } + + private var istVollstaendig: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && betragCents > 0 + } + + private func belegeVor() { + guard !vorbelegt else { return } + vorbelegt = true + + guard case .aendern(let abo) = zweck else { + waehrung = zustand.einstellungen.basiswaehrung + return + } + name = abo.name + betragCents = abo.amountCents + waehrung = abo.currency + rhythmus = Zahlungsrhythmus(rawValue: abo.intervalMonths) ?? .monatlich + kategorieId = abo.categoryId + kontoId = abo.accountId + hatFaelligkeit = abo.nextDue != nil + faelligAm = abo.nextDue?.datum ?? Date() + erkennungstext = abo.payeePattern ?? "" + notiz = abo.notes ?? "" + istAktiv = abo.isActive + symbol = abo.icon + farbe = abo.color + } + + private func sichere() async { + guard let client = zustand.client else { return } + let faellig = hatFaelligkeit ? Tagesdatum(faelligAm) : nil + let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines) + + let geglueckt = await vorgang.fuehreAus { + switch zweck { + case .neu: + _ = try await client.legeAboAn(AboAnlegen( + name: sauberName, icon: symbol, color: farbe, + amountCents: betragCents, currency: waehrung, + intervalMonths: rhythmus.rawValue, categoryId: kategorieId, + accountId: kontoId, nextDue: faellig, + payeePattern: leerAlsNil(erkennungstext), notes: leerAlsNil(notiz))) + case .aendern(let abo): + _ = try await client.aendereAbo(abo.id, AboAendern( + name: sauberName, icon: symbol, color: farbe, + amountCents: betragCents, intervalMonths: rhythmus.rawValue, + categoryId: kategorieId, accountId: kontoId, nextDue: faellig, + payeePattern: leerAlsNil(erkennungstext), isActive: istAktiv, + notes: leerAlsNil(notiz))) + } + } + if geglueckt { + fertig() + schliesse() + } + } + + private func loesche() async { + guard let client = zustand.client, case .aendern(let abo) = zweck else { return } + if await vorgang.fuehreAus({ try await client.loescheAbo(abo.id) }) { + fertig() + schliesse() + } + } + + private func leerAlsNil(_ text: String) -> String? { + let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines) + return sauber.isEmpty ? nil : sauber + } +} diff --git a/ios/Abos/AboListe.swift b/ios/Abos/AboListe.swift index d505ec5..cc1cd12 100644 --- a/ios/Abos/AboListe.swift +++ b/ios/Abos/AboListe.swift @@ -5,6 +5,7 @@ struct AboListe: View { @Environment(AppZustand.self) private var zustand @State private var lader: Lader? @State private var zeigeBeendete = false + @State private var formular: AboFormular.Zweck? var body: some View { Group { @@ -25,6 +26,14 @@ struct AboListe: View { Image(systemName: "line.3.horizontal.decrease.circle") } } + ToolbarItem(placement: .topBarTrailing) { + Button { formular = .neu } label: { Image(systemName: "plus") } + .accessibilityLabel(String(localized: "abo.neu")) + } + } + .sheet(item: $formular) { zweck in + AboFormular(zweck: zweck) { Task { await lader?.aktualisiere() } } + .environment(zustand) } .task { guard lader == nil, let client = zustand.client else { return } @@ -51,7 +60,12 @@ struct AboListe: View { } Section { ForEach(sichtbar) { abo in - AboZeile(abo: abo, nachschlag: zustand.nachschlag) + Button { + formular = .aendern(abo) + } label: { + AboZeile(abo: abo, nachschlag: zustand.nachschlag) + } + .buttonStyle(.plain) } } } diff --git a/ios/Bausteine/Formular.swift b/ios/Bausteine/Formular.swift new file mode 100644 index 0000000..edb5b59 --- /dev/null +++ b/ios/Bausteine/Formular.swift @@ -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 + + 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(_ 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) + } + } +} diff --git a/ios/Buchungen/BuchungFormular.swift b/ios/Buchungen/BuchungFormular.swift new file mode 100644 index 0000000..f799f88 --- /dev/null +++ b/ios/Buchungen/BuchungFormular.swift @@ -0,0 +1,218 @@ +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 = [] + @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 + } +} diff --git a/ios/Buchungen/BuchungsListe.swift b/ios/Buchungen/BuchungsListe.swift index f40ecd9..d8d3981 100644 --- a/ios/Buchungen/BuchungsListe.swift +++ b/ios/Buchungen/BuchungsListe.swift @@ -25,6 +25,7 @@ struct BuchungsListe: View { /// Was zuletzt wirklich abgerufen wurde – damit das Tippen nicht je /// Buchstabe einen Abruf auslöst. @State private var abgerufen = "" + @State private var formular: BuchungFormular.Zweck? var body: some View { Group { @@ -47,6 +48,18 @@ struct BuchungsListe: View { } .toolbar { ToolbarItem(placement: .topBarTrailing) { filterMenue } + ToolbarItem(placement: .topBarTrailing) { + Button { + formular = .neu + } label: { + Image(systemName: "plus") + } + .accessibilityLabel(String(localized: "buchung.neu")) + } + } + .sheet(item: $formular) { zweck in + BuchungFormular(zweck: zweck) { Task { await lade() } } + .environment(zustand) } .task { guard lader == nil else { return } @@ -112,7 +125,12 @@ struct BuchungsListe: View { ForEach(nachTagen(zeilen), id: \.tag) { gruppe in Section(gruppe.tag.formatiert()) { ForEach(gruppe.zeilen) { zeile in - BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag) + Button { + formular = .aendern(zeile.buchung) + } label: { + BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag) + } + .buttonStyle(.plain) } } } diff --git a/ios/Budget/BudgetAnsicht.swift b/ios/Budget/BudgetAnsicht.swift index 27f24cc..be8c63a 100644 --- a/ios/Budget/BudgetAnsicht.swift +++ b/ios/Budget/BudgetAnsicht.swift @@ -10,6 +10,17 @@ struct BudgetAnsicht: View { /// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label. @State private var label: String? @State private var lader: Lader? + @State private var bearbeitet: ZeileZumBearbeiten? + @State private var vorgang = Speichervorgang() + @State private var fragtNachZuruecksetzen = false + + /// Welche Zeile wohin gesetzt wird – beides zusammen, damit das Blatt + /// weiss, ob es den Monat oder die Vorlage ändert. + struct ZeileZumBearbeiten: Identifiable, Equatable { + let kategorie: Budgetkategorie + let ziel: BudgetZeileBearbeiten.Ziel + var id: Int { kategorie.categoryId } + } var body: some View { Group { @@ -54,7 +65,22 @@ struct BudgetAnsicht: View { ForEach(monat.groups) { gruppe in Section { ForEach(gruppe.categories) { kategorie in - KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency) + Button { + bearbeitet = ZeileZumBearbeiten(kategorie: kategorie, + ziel: .monat(monat.label)) + } label: { + KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency) + } + .buttonStyle(.plain) + .swipeActions(edge: .trailing) { + Button { + bearbeitet = ZeileZumBearbeiten(kategorie: kategorie, + ziel: .vorlage) + } label: { + Label(String(localized: "budget.vorlage"), systemImage: "doc.text") + } + .tint(.indigo) + } } } header: { GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency) @@ -90,6 +116,63 @@ struct BudgetAnsicht: View { } .accessibilityLabel(String(localized: "budget.naechsterMonat")) } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + Task { await uebernimm { try await $0.holeVorlageInMonat(monat.label) } } + } label: { + Label(String(localized: "budget.vorlageHolen"), + systemImage: "arrow.down.doc") + } + Button { + Task { await uebernimm { try await $0.macheMonatZurVorlage(monat.label) } } + } label: { + Label(String(localized: "budget.alsVorlageSetzen"), + systemImage: "arrow.up.doc") + } + if monat.customized { + Button(role: .destructive) { + fragtNachZuruecksetzen = true + } label: { + Label(String(localized: "budget.zuruecksetzen"), + systemImage: "arrow.uturn.backward") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(vorgang.laeuft) + } + } + .sheet(item: $bearbeitet) { eintrag in + BudgetZeileBearbeiten(ziel: eintrag.ziel, kategorie: eintrag.kategorie, + waehrung: monat.baseCurrency) { + Task { await lader?.aktualisiere() } + } + .environment(zustand) + } + .confirmationDialog(String(localized: "budget.zuruecksetzenFrage"), + isPresented: $fragtNachZuruecksetzen, titleVisibility: .visible) { + Button(String(localized: "budget.zuruecksetzen"), role: .destructive) { + Task { await uebernimm { try await $0.setzeBudgetMonatZurueck(monat.label) } } + } + Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} + } message: { + Text(String(localized: "budget.zuruecksetzenErklaerung")) + } + .alert(String(localized: "fehler.titel"), + isPresented: .constant(vorgang.fehler != nil)) { + Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil } + } message: { + Text(vorgang.fehler ?? "") + } + } + + /// Die drei Knöpfe im Menü tun dasselbe: etwas schreiben, dann neu laden. + private func uebernimm(_ arbeit: @escaping (CertainClient) async throws -> Void) async { + guard let client = zustand.client else { return } + if await vorgang.fuehreAus({ try await arbeit(client) }) { + await lader?.aktualisiere() } } diff --git a/ios/Budget/BudgetZeileBearbeiten.swift b/ios/Budget/BudgetZeileBearbeiten.swift new file mode 100644 index 0000000..dd468c8 --- /dev/null +++ b/ios/Budget/BudgetZeileBearbeiten.swift @@ -0,0 +1,130 @@ +import SwiftUI + +/// Eine Budget-Zeile setzen – für den Monat oder für die Vorlage. +/// +/// Dieselbe Maske für beides, weil das Backend dieselben drei Angaben +/// entgegennimmt (`planned_cents`, `modus`, `zuschlag_cents`) und sie über +/// `uebernimm` durch dieselbe Regel schickt. Was sich unterscheidet, ist nur +/// der Endpunkt und der Satz darunter. +struct BudgetZeileBearbeiten: View { + enum Ziel: Equatable { + /// Wirkt nur auf diesen Monat; er gilt danach als angepasst. + case monat(String) + /// Wirkt auf alle Monate, die nicht eigens angepasst sind. + case vorlage + } + + let ziel: Ziel + let kategorie: Budgetkategorie + let waehrung: String + var fertig: () -> Void + + @Environment(AppZustand.self) private var zustand + @Environment(\.dismiss) private var schliesse + @State private var vorgang = Speichervorgang() + + @State private var istBerechnet = false + @State private var geplantCents = 0 + @State private var zuschlagCents = 0 + @State private var vorbelegt = false + + var body: some View { + NavigationStack { + Form { + Section { + HStack(spacing: 10) { + Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color) + Text(kategorie.name) + .font(.headline) + } + } + + Section { + Toggle(String(localized: "budget.ausVertraegenRechnen"), + isOn: $istBerechnet) + } footer: { + Text(istBerechnet ? String(localized: "budget.berechnetErklaerung") + : String(localized: "budget.festErklaerung")) + } + + Section { + if istBerechnet { + // Bei „berechnet" ist die hingeschriebene Zahl nicht + // der Soll-Betrag, sondern nur der Zuschlag obendrauf. + LabeledContent(String(localized: "budget.ausVertraegen"), + value: Geldbetrag(cents: kategorie.ausVertraegenCents, + waehrung: waehrung).formatiert()) + Betragsfeld(titel: String(localized: "budget.zuschlag"), + waehrung: waehrung, cents: $zuschlagCents) + LabeledContent(String(localized: "budget.ergibt"), + value: Geldbetrag(cents: kategorie.ausVertraegenCents + zuschlagCents, + waehrung: waehrung).formatiert()) + .fontWeight(.medium) + } else { + Betragsfeld(titel: String(localized: "budget.geplant"), + waehrung: waehrung, cents: $geplantCents) + } + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .navigationTitle(zielTitel) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "allgemein.abbrechen")) { schliesse() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "allgemein.sichern")) { + Task { await sichere() } + } + .disabled(vorgang.laeuft) + } + } + .interactiveDismissDisabled(vorgang.laeuft) + .onAppear { + guard !vorbelegt else { return } + vorbelegt = true + istBerechnet = kategorie.istBerechnet + geplantCents = kategorie.plannedCents + zuschlagCents = kategorie.zuschlagCents + } + } + } + + private var zielTitel: String { + switch ziel { + case .monat: return String(localized: "budget.monatSetzen") + case .vorlage: return String(localized: "budget.vorlageSetzen") + } + } + + private func sichere() async { + guard let client = zustand.client else { return } + let modus = istBerechnet ? "berechnet" : "fest" + // Bei „berechnet" bleibt planned_cents unberührt: Das Backend liest + // es dann nicht, und es zu überschreiben verlöre den Wert, auf den + // ein Zurückschalten auf „fest" zurückfallen soll. + let geplant = istBerechnet ? nil : geplantCents + let zuschlag = istBerechnet ? zuschlagCents : nil + + let geglueckt = await vorgang.fuehreAus { + switch ziel { + case .monat(let label): + try await client.setzeBudgetMonat(label, kategorie: kategorie.categoryId, + geplantCents: geplant, modus: modus, + zuschlagCents: zuschlag) + case .vorlage: + try await client.setzeBudgetVorlage(kategorie: kategorie.categoryId, + geplantCents: geplant, modus: modus, + zuschlagCents: zuschlag) + } + } + if geglueckt { + fertig() + schliesse() + } + } +} diff --git a/ios/Certain.xcodeproj/project.pbxproj b/ios/Certain.xcodeproj/project.pbxproj index 20ae525..86b7dae 100644 --- a/ios/Certain.xcodeproj/project.pbxproj +++ b/ios/Certain.xcodeproj/project.pbxproj @@ -12,12 +12,15 @@ 18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; }; 1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; }; 1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; }; + 2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; }; 25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; }; 2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; }; 3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; }; 3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; }; 402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; }; + 428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */; }; 431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; }; + 49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; }; 4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; }; 4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; }; 66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; }; @@ -34,11 +37,14 @@ B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; }; BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; }; C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; }; + C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; }; D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; }; D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; }; + DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; }; E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; }; E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; }; EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; }; + F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; }; F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; }; /* End PBXBuildFile section */ @@ -55,6 +61,7 @@ /* Begin PBXFileReference section */ 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = ""; }; 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = ""; }; + 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = ""; }; 13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = ""; }; 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = ""; }; 1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -65,11 +72,14 @@ 4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = ""; }; 416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; }; 451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = ""; }; + 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = ""; }; 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = ""; }; 5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = ""; }; + 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = ""; }; 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = ""; }; 776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = ""; }; + 778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = ""; }; 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = ""; }; 78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = ""; }; 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = ""; }; @@ -77,6 +87,7 @@ 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = ""; }; A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = ""; }; A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = ""; }; + A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungFormular.swift; sourceTree = ""; }; A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = ""; }; B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = ""; }; BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = ""; }; @@ -88,6 +99,7 @@ DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = ""; }; E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = ""; }; E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = ""; }; + EFABF1A481445B1CD960C993 /* AboFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboFormular.swift; sourceTree = ""; }; F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -104,6 +116,7 @@ isa = PBXGroup; children = ( 78AD0B83BDFDF777917C9382 /* KontenListe.swift */, + 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */, ); path = Konten; sourceTree = ""; @@ -129,6 +142,7 @@ isa = PBXGroup; children = ( 451BA41D9B53FB6B511AF734 /* Darstellung.swift */, + 778040646D05B1DEEE3BF727 /* Formular.swift */, 287D314176EBA5E888605F5F /* Geldbetrag.swift */, 776F26865B849D2318B6819B /* Zustandsansichten.swift */, ); @@ -150,6 +164,7 @@ 327787E4869ED7D0C7E9EE63 /* Buchung.swift */, E51DE5BDB909910D586F819F /* Budget.swift */, BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */, + 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */, F8BE152C9F91584900796ADA /* Grundmodelle.swift */, B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */, 4119B85EFF3FB38E84FAA913 /* Konto.swift */, @@ -220,6 +235,7 @@ isa = PBXGroup; children = ( 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */, + 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */, ); path = Budget; sourceTree = ""; @@ -227,6 +243,7 @@ CB7098BB5F4AEFE48BA9BB36 /* Abos */ = { isa = PBXGroup; children = ( + EFABF1A481445B1CD960C993 /* AboFormular.swift */, 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */, ); path = Abos; @@ -235,6 +252,7 @@ D99101183ED9DA14C8D22722 /* Buchungen */ = { isa = PBXGroup; children = ( + A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */, A150CAB4967A479B506A997F /* BuchungsListe.swift */, ); path = Buchungen; @@ -364,25 +382,31 @@ C518581A7F62632420821479 /* APIFehler.swift in Sources */, F414F982412C79242CADB473 /* Ablage.swift in Sources */, A499FAEF73B54C5533817BAD /* Abo.swift in Sources */, + F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */, 04F945FE4BAC298FC242354B /* AboListe.swift in Sources */, E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */, 402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */, 4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */, + 2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */, 917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */, 841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */, 2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */, + 428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */, 18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */, EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */, 918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */, 1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */, 89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */, + DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */, 66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */, BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */, + C810555B892E1CAD08053FC3 /* Formular.swift in Sources */, 431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */, 8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */, D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */, 3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */, 0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */, + 49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */, D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */, B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */, B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */, diff --git a/ios/Konten/KontenListe.swift b/ios/Konten/KontenListe.swift index a973407..4f83e60 100644 --- a/ios/Konten/KontenListe.swift +++ b/ios/Konten/KontenListe.swift @@ -6,6 +6,7 @@ struct KontenListe: View { @Environment(AppZustand.self) private var zustand @State private var lader: Lader<[Konto]>? @State private var zeigeArchivierte = false + @State private var formular: KontoFormular.Zweck? var body: some View { Group { @@ -27,6 +28,14 @@ struct KontenListe: View { Image(systemName: "line.3.horizontal.decrease.circle") } } + ToolbarItem(placement: .topBarTrailing) { + Button { formular = .neu } label: { Image(systemName: "plus") } + .accessibilityLabel(String(localized: "konto.neu")) + } + } + .sheet(item: $formular) { zweck in + KontoFormular(zweck: zweck) { Task { await lader?.aktualisiere() } } + .environment(zustand) } .task { guard lader == nil, let client = zustand.client else { return } @@ -50,7 +59,12 @@ struct KontenListe: View { List { Section { ForEach(sichtbar) { konto in - KontoZeile(konto: konto) + Button { + formular = .aendern(konto) + } label: { + KontoZeile(konto: konto) + } + .buttonStyle(.plain) } } footer: { Gesamtsumme(konten: sichtbar, diff --git a/ios/Konten/KontoFormular.swift b/ios/Konten/KontoFormular.swift new file mode 100644 index 0000000..f6ff637 --- /dev/null +++ b/ios/Konten/KontoFormular.swift @@ -0,0 +1,214 @@ +import SwiftUI + +/// Ein Konto anlegen oder ändern. +struct KontoFormular: View { + enum Zweck: Equatable, Identifiable { + case neu + case aendern(Konto) + + var id: Int { + switch self { + case .neu: return 0 + case .aendern(let konto): return konto.id + } + } + } + + let zweck: Zweck + var fertig: () -> Void + + @Environment(AppZustand.self) private var zustand + @Environment(\.dismiss) private var schliesse + @State private var vorgang = Speichervorgang() + + @State private var name = "" + @State private var art: Kontoart = .girokonto + @State private var waehrung = "EUR" + @State private var anfangssaldoCents = 0 + @State private var hatStichtag = false + @State private var stichtag = Date() + @State private var hatKreditrahmen = false + @State private var kreditrahmenCents = 0 + @State private var istGebunden = false + @State private var istArchiviert = false + @State private var symbol = "wallet" + @State private var farbe = "#3f51b5" + @State private var fragtNachLoeschen = false + @State private var vorbelegt = false + + /// Was das Backend annimmt (`_check_currency`). Mehr braucht ein + /// Haushaltsbuch hier nicht; die Hauptwährung steht in den Einstellungen. + private static let waehrungen = ["EUR", "CHF", "USD", "GBP"] + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "konto.name"), text: $name) + Picker(String(localized: "konto.art"), selection: $art) { + ForEach(Kontoart.allCases, id: \.self) { eine in + Text(eine.name).tag(eine) + } + } + if case .neu = zweck { + Picker(String(localized: "konto.waehrung"), selection: $waehrung) { + ForEach(Self.waehrungen, id: \.self) { code in + Text(code).tag(code) + } + } + } else { + // Nachträglich zu wechseln liesse jeden gespeicherten + // Betrag etwas anderes bedeuten. Deshalb nur zu sehen. + LabeledContent(String(localized: "konto.waehrung"), value: waehrung) + } + } + + Section { + Betragsfeld(titel: String(localized: "konto.anfangssaldo"), + waehrung: waehrung, cents: $anfangssaldoCents) + Toggle(String(localized: "konto.stichtagKennen"), isOn: $hatStichtag) + if hatStichtag { + DatePicker(String(localized: "konto.stichtag"), selection: $stichtag, + displayedComponents: .date) + } + } footer: { + Text(String(localized: "konto.anfangssaldoErklaerung")) + } + + if art == .kreditkarte { + Section { + Toggle(String(localized: "konto.kreditrahmenKennen"), + isOn: $hatKreditrahmen) + if hatKreditrahmen { + Betragsfeld(titel: String(localized: "konto.kreditrahmen"), + waehrung: waehrung, cents: $kreditrahmenCents) + } + } footer: { + Text(String(localized: "konto.kreditrahmenErklaerung")) + } + } + + Section { + Toggle(String(localized: "konto.gebunden"), isOn: $istGebunden) + } footer: { + Text(String(localized: "konto.gebundenErklaerung")) + } + + Section { + SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe) + } + + if case .aendern = zweck { + Section { + Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert) + Button(String(localized: "konto.loeschen"), role: .destructive) { + fragtNachLoeschen = true + } + } footer: { + Text(String(localized: "konto.archivierenStattLoeschen")) + } + } + + if let fehler = vorgang.fehler { + Section { FehlerZeile(text: fehler) } + } + } + .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: "konto.loeschenFrage"), + isPresented: $fragtNachLoeschen, titleVisibility: .visible) { + Button(String(localized: "konto.loeschen"), role: .destructive) { + Task { await loesche() } + } + Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} + } message: { + Text(String(localized: "konto.loeschenErklaerung")) + } + .onAppear(perform: belegeVor) + } + } + + private var titel: String { + switch zweck { + case .neu: return String(localized: "konto.neu") + case .aendern: return String(localized: "konto.aendern") + } + } + + private var istVollstaendig: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func belegeVor() { + guard !vorbelegt else { return } + vorbelegt = true + + guard case .aendern(let konto) = zweck else { + waehrung = zustand.einstellungen.basiswaehrung + return + } + name = konto.name + art = konto.type + waehrung = konto.currency + anfangssaldoCents = konto.openingBalanceCents + hatStichtag = konto.openingDate != nil + stichtag = konto.openingDate?.datum ?? Date() + hatKreditrahmen = konto.creditLimitCents != nil + kreditrahmenCents = konto.creditLimitCents ?? 0 + istGebunden = konto.isLocked + istArchiviert = konto.isArchived + symbol = konto.icon + farbe = konto.color + } + + private func sichere() async { + guard let client = zustand.client else { return } + let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let datum = hatStichtag ? Tagesdatum(stichtag) : nil + // Nur bei Kreditkarten überhaupt ein Rahmen – bei allem anderen wäre + // die Zahl bedeutungslos und würde beim Umstellen der Art hängenbleiben. + let rahmen = (art == .kreditkarte && hatKreditrahmen) ? kreditrahmenCents : nil + + let geglueckt = await vorgang.fuehreAus { + switch zweck { + case .neu: + _ = try await client.legeKontoAn(KontoAnlegen( + name: sauberName, type: art, currency: waehrung, icon: symbol, + color: farbe, openingBalanceCents: anfangssaldoCents, + openingDate: datum, creditLimitCents: rahmen, isLocked: istGebunden)) + case .aendern(let konto): + _ = try await client.aendereKonto(konto.id, KontoAendern( + name: sauberName, type: art, icon: symbol, color: farbe, + openingBalanceCents: anfangssaldoCents, openingDate: datum, + creditLimitCents: rahmen, isLocked: istGebunden, + isArchived: istArchiviert)) + } + } + if geglueckt { + await zustand.ladeNachschlag() + fertig() + schliesse() + } + } + + private func loesche() async { + guard let client = zustand.client, case .aendern(let konto) = zweck else { return } + if await vorgang.fuehreAus({ try await client.loescheKonto(konto.id) }) { + await zustand.ladeNachschlag() + fertig() + schliesse() + } + } +} diff --git a/ios/Modelle/Eingaben.swift b/ios/Modelle/Eingaben.swift new file mode 100644 index 0000000..69aa6b2 --- /dev/null +++ b/ios/Modelle/Eingaben.swift @@ -0,0 +1,186 @@ +import Foundation + +// Was die App zum Server schickt. Getrennt von den Lesemodellen, weil das +// Backend es ebenso trennt: `TransactionCreate` kennt Felder, die +// `TransactionOut` nicht hat, und umgekehrt (`subscription_id` etwa steht nur +// in der Ausgabe – beim Anlegen gibt man es nicht mit). +// +// Alle Änderungstypen sind **teilweise**: Nur was gesetzt ist, geht mit. Ein +// `nil` darf nicht als `null` hinausgehen, sonst löschte jedes Speichern die +// Felder, die das Formular gar nicht anfasst. +// +// Das macht Swift von selbst – die erzeugte `encode(to:)` ruft für Optionals +// `encodeIfPresent`. Hier stand das eine Weile von Hand ausgeschrieben, bis +// ein Test zeigte, dass es dasselbe tut. Der Test bleibt: Die Eigenschaft ist +// zu wichtig, um sie ungeprüft einer Spracheigenheit zu überlassen. + +/// `TransactionCreate` +struct BuchungAnlegen: Encodable, Sendable { + var type: Buchungsart + var date: Tagesdatum + var accountId: Int + var toAccountId: Int? + /// Immer positiv – die Richtung steckt in `type`, nicht im Vorzeichen. + var amountCents: Int + var toAmountCents: Int? + var categoryId: Int? + var payee: String? + var description: String? + var notes: String? + var tagIds: [Int] = [] + + private enum CodingKeys: String, CodingKey { + case type, date, payee, description, notes + case accountId = "account_id" + case toAccountId = "to_account_id" + case amountCents = "amount_cents" + case toAmountCents = "to_amount_cents" + case categoryId = "category_id" + case tagIds = "tag_ids" + } +} + +/// `TransactionUpdate` – nur die gesetzten Felder gehen mit. +struct BuchungAendern: Encodable, Sendable { + var type: Buchungsart? + var date: Tagesdatum? + var accountId: Int? + var toAccountId: Int? + var amountCents: Int? + var categoryId: Int? + var payee: String? + var description: String? + var notes: String? + /// Mitgeschickt heisst **ersetzen**; eine leere Liste nimmt alle Tags ab. + /// Weggelassen lässt sie unberührt. + var tagIds: [Int]? + + private enum CodingKeys: String, CodingKey { + case type, date, payee, description, notes + case accountId = "account_id" + case toAccountId = "to_account_id" + case amountCents = "amount_cents" + case categoryId = "category_id" + case tagIds = "tag_ids" + } +} + +/// `AccountCreate` +struct KontoAnlegen: Encodable, Sendable { + var name: String + var type: Kontoart = .girokonto + var currency: String = "EUR" + var icon: String = "wallet" + var color: String = "#3f51b5" + var openingBalanceCents: Int = 0 + var openingDate: Tagesdatum? + var creditLimitCents: Int? + var isLocked: Bool = false + + private enum CodingKeys: String, CodingKey { + case name, type, currency, icon, color + case openingBalanceCents = "opening_balance_cents" + case openingDate = "opening_date" + case creditLimitCents = "credit_limit_cents" + case isLocked = "is_locked" + } +} + +/// `AccountUpdate` – nur die gesetzten Felder gehen mit. +/// +/// Die Währung fehlt mit Absicht: Sie nachträglich zu wechseln liesse jeden +/// gespeicherten Betrag etwas anderes bedeuten. Wer das will, legt ein neues +/// Konto an. +struct KontoAendern: Encodable, Sendable { + var name: String? + var type: Kontoart? + var icon: String? + var color: String? + var openingBalanceCents: Int? + var openingDate: Tagesdatum? + var creditLimitCents: Int? + var isLocked: Bool? + var isArchived: Bool? + + private enum CodingKeys: String, CodingKey { + case name, type, icon, color + case openingBalanceCents = "opening_balance_cents" + case openingDate = "opening_date" + case creditLimitCents = "credit_limit_cents" + case isLocked = "is_locked" + case isArchived = "is_archived" + } +} + +/// `SubscriptionIn` +struct AboAnlegen: Encodable, Sendable { + var name: String + var icon: String = "refresh-ccw" + var color: String = "#6b7480" + var amountCents: Int + var currency: String = "EUR" + /// Nur 1, 2, 3, 6 oder 12 – das Backend weist alles andere ab. + var intervalMonths: Int = 1 + var categoryId: Int? + var accountId: Int? + var nextDue: Tagesdatum? + var payeePattern: String? + var notes: String? + + private enum CodingKeys: String, CodingKey { + case name, icon, color, currency, notes + case amountCents = "amount_cents" + case intervalMonths = "interval_months" + case categoryId = "category_id" + case accountId = "account_id" + case nextDue = "next_due" + case payeePattern = "payee_pattern" + } +} + +/// `SubscriptionUpdate` – nur die gesetzten Felder gehen mit. +struct AboAendern: Encodable, Sendable { + var name: String? + var icon: String? + var color: String? + var amountCents: Int? + var intervalMonths: Int? + var categoryId: Int? + var accountId: Int? + var nextDue: Tagesdatum? + var payeePattern: String? + var isActive: Bool? + var notes: String? + + private enum CodingKeys: String, CodingKey { + case name, icon, color, notes + case amountCents = "amount_cents" + case intervalMonths = "interval_months" + case categoryId = "category_id" + case accountId = "account_id" + case nextDue = "next_due" + case payeePattern = "payee_pattern" + case isActive = "is_active" + } +} + +/// Die möglichen Zahlungsrhythmen. Mehr nimmt das Backend nicht an. +enum Zahlungsrhythmus: Int, CaseIterable, Identifiable, Sendable { + case monatlich = 1 + case zweimonatlich = 2 + case vierteljaehrlich = 3 + case halbjaehrlich = 6 + case jaehrlich = 12 + + var id: Int { rawValue } + + var name: String { + switch self { + case .monatlich: return String(localized: "abo.monatlich") + case .zweimonatlich: return String(format: String(localized: "abo.alleNMonate"), 2) + case .vierteljaehrlich: return String(localized: "abo.vierteljaehrlich") + case .halbjaehrlich: return String(localized: "abo.halbjaehrlich") + case .jaehrlich: return String(localized: "abo.jaehrlich") + } + } +} diff --git a/ios/Netzwerk/Endpunkte.swift b/ios/Netzwerk/Endpunkte.swift index 25652c1..5b0f2d0 100644 --- a/ios/Netzwerk/Endpunkte.swift +++ b/ios/Netzwerk/Endpunkte.swift @@ -190,4 +190,102 @@ extension CertainClient { try await hole("dashboard/recent", abfrage: [URLQueryItem(name: "limit", value: String(grenze))]) } + + // MARK: - Schreiben: Buchungen + + func legeBuchungAn(_ eingabe: BuchungAnlegen) async throws -> Buchung { + try await schicke(.post, "transactions", koerper: eingabe) + } + + func aendereBuchung(_ id: Int, _ aenderung: BuchungAendern) async throws -> Buchung { + try await schicke(.patch, "transactions/\(id)", koerper: aenderung) + } + + func loescheBuchung(_ id: Int) async throws { + try await schickeStumm(.delete, "transactions/\(id)") + } + + // MARK: - Schreiben: Budget + + /// Setzt eine Zeile der Monatsansicht. Der Monat wird dadurch als + /// „angepasst" geführt und folgt der Vorlage nicht mehr. + /// + /// Query statt Körper – so will es das Backend (`routers/budgets.py`). + @discardableResult + func setzeBudgetMonat(_ label: String, kategorie: Int, geplantCents: Int?, + modus: String? = nil, + zuschlagCents: Int? = nil) async throws -> Budgetmonat { + try await schicke(.put, "budget/\(label)/category/\(kategorie)", + abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents)) + } + + /// Setzt eine Zeile der Standard-Vorlage. Wirkt auf alle Monate, die + /// nicht eigens angepasst sind. + @discardableResult + func setzeBudgetVorlage(kategorie: Int, geplantCents: Int?, modus: String? = nil, + zuschlagCents: Int? = nil) async throws -> Vorlagenzeile { + try await schicke(.put, "budget/template/category/\(kategorie)", + abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents)) + } + + private func budgetAbfrage(_ geplantCents: Int?, _ modus: String?, + _ zuschlagCents: Int?) -> [URLQueryItem] { + var abfrage: [URLQueryItem] = [] + if let geplantCents { + abfrage.append(URLQueryItem(name: "planned_cents", value: String(geplantCents))) + } + if let modus { + abfrage.append(URLQueryItem(name: "modus", value: modus)) + } + if let zuschlagCents { + abfrage.append(URLQueryItem(name: "zuschlag_cents", value: String(zuschlagCents))) + } + return abfrage + } + + /// Nimmt die Anpassungen dieses Monats zurück – er folgt wieder der Vorlage. + @discardableResult + func setzeBudgetMonatZurueck(_ label: String) async throws -> Budgetmonat { + try await schicke(.delete, "budget/\(label)") + } + + /// Überschreibt den Monat mit der Vorlage. + @discardableResult + func holeVorlageInMonat(_ label: String) async throws -> Budgetmonat { + try await schicke(.post, "budget/\(label)/from-template") + } + + /// Macht diesen Monat zur neuen Vorlage. + @discardableResult + func macheMonatZurVorlage(_ label: String) async throws -> Budgetvorlage { + try await schicke(.post, "budget/template/from-month/\(label)") + } + + // MARK: - Schreiben: Verträge + + func legeAboAn(_ eingabe: AboAnlegen) async throws -> Abo { + try await schicke(.post, "subscriptions", koerper: eingabe) + } + + func aendereAbo(_ id: Int, _ aenderung: AboAendern) async throws -> Abo { + try await schicke(.patch, "subscriptions/\(id)", koerper: aenderung) + } + + func loescheAbo(_ id: Int) async throws { + try await schickeStumm(.delete, "subscriptions/\(id)") + } + + // MARK: - Schreiben: Konten + + func legeKontoAn(_ eingabe: KontoAnlegen) async throws -> Konto { + try await schicke(.post, "accounts", koerper: eingabe) + } + + func aendereKonto(_ id: Int, _ aenderung: KontoAendern) async throws -> Konto { + try await schicke(.patch, "accounts/\(id)", koerper: aenderung) + } + + func loescheKonto(_ id: Int) async throws { + try await schickeStumm(.delete, "accounts/\(id)") + } } diff --git a/ios/Tests/GrundlagenTests.swift b/ios/Tests/GrundlagenTests.swift index 725cf80..9a911f7 100644 --- a/ios/Tests/GrundlagenTests.swift +++ b/ios/Tests/GrundlagenTests.swift @@ -578,3 +578,67 @@ struct ServerantwortenTests { #expect(!Einstellungen(roh).basiswaehrung.isEmpty) } } + +// MARK: - Was zum Server hinausgeht + +@Suite("Eingaben") +struct EingabenTests { + + /// Die Felder, die wirklich im JSON stehen. + private func felder(_ wert: A) throws -> [String: Any] { + let roh = try JSONEncoder().encode(wert) + return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any]) + } + + @Test("Eine Teiländerung schickt nur, was gesetzt ist") + func teilaenderungBleibtTeilweise() throws { + // Der Fall, der stillschweigend Daten frisst: Ginge ein nicht + // angefasstes Feld als null hinaus, loeschte jedes Speichern den + // Empfänger, die Notiz und die Kategorie gleich mit. + let nurNotiz = try felder(BuchungAendern(notes: "Kassenbon fehlt")) + + #expect(nurNotiz.keys.sorted() == ["notes"]) + #expect(nurNotiz["notes"] as? String == "Kassenbon fehlt") + } + + @Test("Eine leere Tag-Liste geht mit, denn sie bedeutet etwas") + func leereTagListeGehtMit() throws { + // Mitgeschickt heisst ERSETZEN. Eine leere Liste nimmt alle Tags ab; + // sie wegzulassen liesse sie stehen. Die zwei Faelle duerfen nicht + // zusammenfallen. + let ohneTags = try felder(BuchungAendern(tagIds: [])) + #expect(ohneTags["tag_ids"] as? [Int] == []) + + let unberuehrt = try felder(BuchungAendern(payee: "Laden")) + #expect(unberuehrt["tag_ids"] == nil) + } + + @Test("Die Feldnamen sind die des Backends") + func feldnamenPassen() throws { + let anlegen = try felder(BuchungAnlegen( + type: .expense, date: #require(Tagesdatum("2026-10-15")), accountId: 3, + amountCents: 4250, categoryId: 7, payee: "Supermarkt")) + + #expect(anlegen["account_id"] as? Int == 3) + #expect(anlegen["amount_cents"] as? Int == 4250) + #expect(anlegen["category_id"] as? Int == 7) + #expect(anlegen["date"] as? String == "2026-10-15") + #expect(anlegen["type"] as? String == "expense") + } + + @Test("Konto und Vertrag ändern sich ebenso nur in dem, was gesetzt ist") + func weitereTeilaenderungen() throws { + #expect(try felder(KontoAendern(isArchived: true)).keys.sorted() == ["is_archived"]) + #expect(try felder(AboAendern(isActive: false)).keys.sorted() == ["is_active"]) + + let kreditrahmen = try felder(KontoAendern(creditLimitCents: 200000)) + #expect(kreditrahmen["credit_limit_cents"] as? Int == 200000) + } + + @Test("Die Rhythmen sind genau die, die das Backend annimmt") + func rhythmenPassenZumBackend() { + // INTERVALL_MONATE in backend/app/schemas.py. Weicht die App ab, + // antwortet der Server mit 422 auf etwas, das die Auswahl anbot. + #expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12]) + } +} diff --git a/ios/Texte/de.lproj/Localizable.strings b/ios/Texte/de.lproj/Localizable.strings index 1541995..9cce3a7 100644 --- a/ios/Texte/de.lproj/Localizable.strings +++ b/ios/Texte/de.lproj/Localizable.strings @@ -9,6 +9,8 @@ "allgemein.abbrechen" = "Abbrechen"; "allgemein.abmelden" = "Abmelden"; "allgemein.unbekannt" = "Unbekannt"; +"allgemein.sichern" = "Sichern"; +"allgemein.ok" = "OK"; /* --- Reiter --- */ "reiter.dashboard" = "Übersicht"; @@ -163,3 +165,77 @@ "abo.proMonat" = "%@ im Monat"; "abo.naechste" = "nächste am %@"; "abo.inTagen" = "fällig in %d Tagen"; + +/* --- Buchung bearbeiten --- */ +"buchung.neu" = "Neue Buchung"; +"buchung.aendern" = "Buchung ändern"; +"buchung.datum" = "Datum"; +"buchung.betrag" = "Betrag"; +"buchung.konto" = "Konto"; +"buchung.konto.keines" = "Kein Konto"; +"buchung.zielkonto" = "Auf Konto"; +"buchung.kategorie" = "Kategorie"; +"buchung.empfaenger" = "Empfänger"; +"buchung.verwendungszweck" = "Verwendungszweck"; +"buchung.notiz" = "Notiz"; +"buchung.schlagwoerter" = "Schlagwörter"; +"buchung.keineSchlagwoerter" = "Noch keine Schlagwörter angelegt. Das geht am Rechner."; +"buchung.uebertragErklaerung" = "Ein Übertrag verschiebt Geld zwischen deinen Konten. Er zählt in keiner Auswertung mit und braucht deshalb keine Kategorie."; +"buchung.loeschen" = "Buchung löschen"; +"buchung.loeschenFrage" = "Diese Buchung löschen?"; + +/* --- Budget bearbeiten --- */ +"budget.geplant" = "Geplant"; +"budget.zuschlag" = "Zuschlag"; +"budget.ausVertraegen" = "Aus Verträgen"; +"budget.ergibt" = "Ergibt"; +"budget.monatSetzen" = "Nur dieser Monat"; +"budget.vorlageSetzen" = "Standard-Budget"; +"budget.vorlage" = "Vorlage"; +"budget.ausVertraegenRechnen" = "Aus Verträgen rechnen"; +"budget.berechnetErklaerung" = "Der Soll-Betrag ergibt sich aus den Verträgen dieser Kategorie plus Zuschlag. Ändert sich ein Vertrag, ändert sich das Budget mit."; +"budget.festErklaerung" = "Ein fester Betrag, den du selbst setzt."; +"budget.vorlageHolen" = "Vorlage in diesen Monat holen"; +"budget.alsVorlageSetzen" = "Diesen Monat als Standard festlegen"; +"budget.zuruecksetzen" = "Monat zurücksetzen"; +"budget.zuruecksetzenFrage" = "Diesen Monat zurücksetzen?"; +"budget.zuruecksetzenErklaerung" = "Alle Anpassungen dieses Monats fallen weg. Danach gilt wieder das Standard-Budget."; + +/* --- Vertrag bearbeiten --- */ +"abo.neu" = "Neuer Vertrag"; +"abo.aendern" = "Vertrag ändern"; +"abo.name" = "Name"; +"abo.betrag" = "Betrag je Zahlung"; +"abo.rhythmus" = "Rhythmus"; +"abo.konto" = "Wird abgebucht von"; +"abo.kategorieErklaerung" = "Die Kategorie entscheidet, in welcher Budget-Zeile dieser Vertrag mitzählt."; +"abo.faelligkeitKennen" = "Nächste Zahlung bekannt"; +"abo.naechsteZahlung" = "Nächste Zahlung"; +"abo.erkennungstext" = "Erkennungstext"; +"abo.erkennungErklaerung" = "Woran die Erkennung diesen Vertrag in einer Buchung wiedererkennt - meist ein Teil des Empfängernamens."; +"abo.laeuftNoch" = "Läuft noch"; +"abo.beendenStattLoeschen" = "Beenden statt löschen hält fest, was er gekostet hat. Löschen entfernt ihn ganz."; +"abo.loeschen" = "Vertrag löschen"; +"abo.loeschenFrage" = "Diesen Vertrag löschen?"; +"abo.loeschenErklaerung" = "Die zugeordneten Buchungen bleiben, sie verlieren nur das Etikett."; + +/* --- Konto bearbeiten --- */ +"konto.neu" = "Neues Konto"; +"konto.aendern" = "Konto ändern"; +"konto.name" = "Name"; +"konto.art" = "Art"; +"konto.waehrung" = "Währung"; +"konto.anfangssaldo" = "Anfangssaldo"; +"konto.stichtagKennen" = "Stichtag angeben"; +"konto.stichtag" = "Stichtag"; +"konto.anfangssaldoErklaerung" = "Der Stand zu Beginn. Ohne Stichtag gilt er von Anfang an; mit Stichtag zählen nur Buchungen ab diesem Tag darauf."; +"konto.kreditrahmenKennen" = "Kreditrahmen angeben"; +"konto.kreditrahmen" = "Kreditrahmen"; +"konto.kreditrahmenErklaerung" = "Was die Karte ausgeben darf - kein Guthaben."; +"konto.gebunden" = "Gebundenes Geld"; +"konto.gebundenErklaerung" = "Geld, an das du nicht herankommst, etwa Säule 3a. Es zählt nicht als verfügbar."; +"konto.archiviert" = "Archiviert"; +"konto.archivierenStattLoeschen" = "Archivieren blendet das Konto aus und behält seine Buchungen. Löschen geht nur, solange keine daran hängen."; +"konto.loeschen" = "Konto löschen"; +"konto.loeschenFrage" = "Dieses Konto löschen?"; +"konto.loeschenErklaerung" = "Das geht nur, wenn keine Buchung mehr daran hängt. Sonst archivieren."; diff --git a/ios/Texte/en.lproj/Localizable.strings b/ios/Texte/en.lproj/Localizable.strings index 5f6bf09..9cbb85e 100644 --- a/ios/Texte/en.lproj/Localizable.strings +++ b/ios/Texte/en.lproj/Localizable.strings @@ -9,6 +9,8 @@ "allgemein.abbrechen" = "Cancel"; "allgemein.abmelden" = "Sign out"; "allgemein.unbekannt" = "Unknown"; +"allgemein.sichern" = "Save"; +"allgemein.ok" = "OK"; /* --- Tabs --- */ "reiter.dashboard" = "Overview"; @@ -163,3 +165,77 @@ "abo.proMonat" = "%@ per month"; "abo.naechste" = "next on %@"; "abo.inTagen" = "due in %d days"; + +/* --- Edit transaction --- */ +"buchung.neu" = "New transaction"; +"buchung.aendern" = "Edit transaction"; +"buchung.datum" = "Date"; +"buchung.betrag" = "Amount"; +"buchung.konto" = "Account"; +"buchung.konto.keines" = "No account"; +"buchung.zielkonto" = "To account"; +"buchung.kategorie" = "Category"; +"buchung.empfaenger" = "Payee"; +"buchung.verwendungszweck" = "Description"; +"buchung.notiz" = "Note"; +"buchung.schlagwoerter" = "Tags"; +"buchung.keineSchlagwoerter" = "No tags yet. You can add them on the desktop."; +"buchung.uebertragErklaerung" = "A transfer moves money between your own accounts. It counts in no report and therefore needs no category."; +"buchung.loeschen" = "Delete transaction"; +"buchung.loeschenFrage" = "Delete this transaction?"; + +/* --- Edit budget --- */ +"budget.geplant" = "Planned"; +"budget.zuschlag" = "Extra"; +"budget.ausVertraegen" = "From subscriptions"; +"budget.ergibt" = "Totals"; +"budget.monatSetzen" = "This month only"; +"budget.vorlageSetzen" = "Default budget"; +"budget.vorlage" = "Template"; +"budget.ausVertraegenRechnen" = "Calculate from subscriptions"; +"budget.berechnetErklaerung" = "The planned amount comes from this category's subscriptions plus the extra. Change a subscription and the budget follows."; +"budget.festErklaerung" = "A fixed amount you set yourself."; +"budget.vorlageHolen" = "Load template into this month"; +"budget.alsVorlageSetzen" = "Make this month the default"; +"budget.zuruecksetzen" = "Reset month"; +"budget.zuruecksetzenFrage" = "Reset this month?"; +"budget.zuruecksetzenErklaerung" = "All adjustments to this month are dropped. The default budget applies again."; + +/* --- Edit subscription --- */ +"abo.neu" = "New subscription"; +"abo.aendern" = "Edit subscription"; +"abo.name" = "Name"; +"abo.betrag" = "Amount per payment"; +"abo.rhythmus" = "Billed"; +"abo.konto" = "Charged to"; +"abo.kategorieErklaerung" = "The category decides which budget line this subscription counts towards."; +"abo.faelligkeitKennen" = "Next payment known"; +"abo.naechsteZahlung" = "Next payment"; +"abo.erkennungstext" = "Match text"; +"abo.erkennungErklaerung" = "How the matcher recognises this subscription in a transaction - usually part of the payee name."; +"abo.laeuftNoch" = "Still running"; +"abo.beendenStattLoeschen" = "Cancelling keeps the record of what it cost. Deleting removes it entirely."; +"abo.loeschen" = "Delete subscription"; +"abo.loeschenFrage" = "Delete this subscription?"; +"abo.loeschenErklaerung" = "The linked transactions stay; they only lose the label."; + +/* --- Edit account --- */ +"konto.neu" = "New account"; +"konto.aendern" = "Edit account"; +"konto.name" = "Name"; +"konto.art" = "Type"; +"konto.waehrung" = "Currency"; +"konto.anfangssaldo" = "Opening balance"; +"konto.stichtagKennen" = "Set an opening date"; +"konto.stichtag" = "Opening date"; +"konto.anfangssaldoErklaerung" = "The balance to start from. Without a date it applies from the beginning; with one, only transactions from that day count on top."; +"konto.kreditrahmenKennen" = "Set a credit limit"; +"konto.kreditrahmen" = "Credit limit"; +"konto.kreditrahmenErklaerung" = "What the card may spend - not a balance."; +"konto.gebunden" = "Locked money"; +"konto.gebundenErklaerung" = "Money you cannot reach, such as a pension account. It does not count as available."; +"konto.archiviert" = "Archived"; +"konto.archivierenStattLoeschen" = "Archiving hides the account and keeps its transactions. Deleting only works while none are attached."; +"konto.loeschen" = "Delete account"; +"konto.loeschenFrage" = "Delete this account?"; +"konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise.";