feat: Fuenfte Stufe der iOS-App - Kategorien, Schlagwoerter, Bank, zweiter Faktor
Die Nebenraeume haengen unter Einstellungen, nicht als eigene Reiter: Man braucht sie selten, und eine Leiste mit zehn Reitern findet niemand mehr. - Kategorien: anlegen, aendern, archivieren, loeschen - samt Gruppen. Eine Kategorie oeffnet ihre Erkennungswoerter, die sich dort hinzufuegen und entfernen lassen. Darunter steht, was sonst niemand merkt: Sie wirken nur auf kuenftige Importe. - Woerter, die oft korrigiert werden, stehen als Pruefmeldung in den Einstellungen - behalten oder entfernen. - Schlagwoerter: anlegen, umbenennen, umfaerben, loeschen. Neue bekommen reihum eine Farbe, damit nicht alle grau sind. - Einstellungen: Erscheinungsbild, Hauptwaehrung und Budget-Starttag lassen sich jetzt aendern. Ohne Sichern-Knopf - es ist je Zeile genau ein Wert, und das Backend nimmt ihn einzeln entgegen. Dazu der Hinweis, dass sie dem Konto gehoeren und nicht dem Geraet. - Bank: abrufen und Salden auffrischen. Verbinden bleibt am Rechner, die Bank leitet fest dorthin zurueck. Der letzte Fehler der Verbindung steht dabei, sonst ruft man vergeblich ab. - Zweiter Faktor: einrichten in drei Schritten. Auf dem Telefon fuehrt der Weg ueber die otpauth-Adresse direkt in die Authenticator-App statt ueber einen QR-Code, den man sich selbst zeigen muesste. Die Rettungscodes erscheinen einmal; das Blatt laesst sich nicht wegwischen und erst schliessen, wenn man bestaetigt hat, sie gesichert zu haben. Abschalten des zweiten Faktors bleibt bewusst draussen: Es verlangt das Kontopasswort, und die App speichert keines. Ein Vollzugang auf diesem Geraet soll den Schutz des Kontos nicht aufheben koennen. 59 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,146 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Bankanbindungen – abrufen, Salden auffrischen.
|
||||
///
|
||||
/// **Herstellen geht hier nicht.** Die Bank leitet fest auf
|
||||
/// `{base_url}/bank/rueckkehr` zurück, registriert bei Enable Banking; dieser
|
||||
/// Weg führt zwangsläufig über den Browser am Rechner. Abrufen dagegen ist
|
||||
/// genau das, was man unterwegs will.
|
||||
struct BankAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<[Bankverbindung]>?
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var meldung: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
|
||||
inhalt(liste)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "nebenraeume.bank"))
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<[Bankverbindung]>(schluessel: "bank/connections") {
|
||||
try await client.bankverbindungen()
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
.alert(String(localized: "bank.abrufFertig"),
|
||||
isPresented: .constant(meldung != nil)) {
|
||||
Button(String(localized: "allgemein.ok")) { meldung = nil }
|
||||
} message: {
|
||||
Text(meldung ?? "")
|
||||
}
|
||||
.alert(String(localized: "fehler.titel"),
|
||||
isPresented: .constant(vorgang.fehler != nil)) {
|
||||
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
|
||||
} message: {
|
||||
Text(vorgang.fehler ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inhalt(_ liste: [Bankverbindung]) -> some View {
|
||||
if liste.isEmpty {
|
||||
LeerAnsicht(titel: String(localized: "bank.leerTitel"),
|
||||
erklaerung: String(localized: "bank.leerErklaerung"),
|
||||
symbol: "building.columns")
|
||||
} else {
|
||||
List {
|
||||
ForEach(liste) { verbindung in
|
||||
Section {
|
||||
VerbindungsZeile(verbindung: verbindung)
|
||||
|
||||
Button {
|
||||
Task { await rufeAb(verbindung) }
|
||||
} label: {
|
||||
Label(String(localized: "bank.abrufen"),
|
||||
systemImage: "arrow.down.circle")
|
||||
}
|
||||
Button {
|
||||
Task { await frischeSalden(verbindung) }
|
||||
} label: {
|
||||
Label(String(localized: "bank.saldenAuffrischen"),
|
||||
systemImage: "arrow.clockwise")
|
||||
}
|
||||
} header: {
|
||||
Text(verbindung.aspspName)
|
||||
} footer: {
|
||||
if let fehler = verbindung.lastError {
|
||||
// Der letzte Fehler steht am Server. Ihn zu
|
||||
// verschweigen hiesse, den Nutzer vergeblich
|
||||
// abrufen zu lassen.
|
||||
Label(fehler, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
.disabled(vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
|
||||
private func rufeAb(_ verbindung: Bankverbindung) async {
|
||||
guard let client = zustand.client else { return }
|
||||
var ergebnis: Abrufergebnis?
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
ergebnis = try await client.rufeBankAb(verbindung.id)
|
||||
}
|
||||
if geglueckt {
|
||||
meldung = ergebnis?.meldung
|
||||
await lader?.aktualisiere()
|
||||
}
|
||||
}
|
||||
|
||||
private func frischeSalden(_ verbindung: Bankverbindung) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.frischeSaldenAuf(verbindung.id) }) {
|
||||
meldung = String(localized: "bank.saldenAufgefrischt")
|
||||
await lader?.aktualisiere()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VerbindungsZeile: View {
|
||||
let verbindung: Bankverbindung
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
LabeledContent(String(localized: "bank.land"), value: verbindung.aspspCountry)
|
||||
|
||||
if let zuletzt = verbindung.lastSyncAt {
|
||||
LabeledContent(String(localized: "bank.zuletzt")) {
|
||||
Text(zuletzt.datum.formatted(.relative(presentation: .named)))
|
||||
}
|
||||
} else {
|
||||
LabeledContent(String(localized: "bank.zuletzt"),
|
||||
value: String(localized: "bank.nie"))
|
||||
}
|
||||
|
||||
if let gueltig = verbindung.validUntil {
|
||||
LabeledContent(String(localized: "bank.gueltigBis")) {
|
||||
Text(gueltig.datum.formatted(.dateTime.day().month().year()))
|
||||
// Eine auslaufende Anbindung bricht sonst ohne
|
||||
// Vorwarnung ab, und niemand weiss warum.
|
||||
.foregroundStyle(verbindung.expiresSoon ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if verbindung.pollMinutes > 0 {
|
||||
LabeledContent(String(localized: "bank.takt")) {
|
||||
Text(String(format: String(localized: "bank.alleNMinuten"),
|
||||
verbindung.pollMinutes))
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Kategorie anlegen oder ändern.
|
||||
struct KategorieFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
/// Neu in dieser Gruppe. Die Art erbt sie von der Gruppe – eine
|
||||
/// Einnahme-Kategorie in einer Ausgabe-Gruppe gibt es nicht.
|
||||
case neu(Int, Kategorieart)
|
||||
case aendern(Kategorie)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu(let gruppe, _): return -gruppe
|
||||
case .aendern(let kategorie): return kategorie.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 symbol = "more-horizontal"
|
||||
@State private var farbe = "#6b7480"
|
||||
@State private var gruppeId = 0
|
||||
@State private var istSparen = false
|
||||
@State private var ohneAuswertung = false
|
||||
@State private var istArchiviert = false
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "konto.name"), text: $name)
|
||||
Picker(String(localized: "kategorien.gruppe"), selection: $gruppeId) {
|
||||
ForEach(passendeGruppen) { gruppe in
|
||||
Text(gruppe.name).tag(gruppe.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
}
|
||||
|
||||
if art == .expense {
|
||||
Section {
|
||||
Toggle(String(localized: "kategorien.istSparen"), isOn: $istSparen)
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.istSparenErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "kategorien.ohneAuswertungSchalter"),
|
||||
isOn: $ohneAuswertung)
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.ohneAuswertungErklaerung"))
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
|
||||
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.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(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "kategorien.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "kategorien.loeschenErklaerung"))
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "kategorien.neu")
|
||||
case .aendern: return String(localized: "kategorien.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Art diese Kategorie hat – sie steckt in der Gruppe, nicht in
|
||||
/// der Kategorie selbst.
|
||||
private var art: Kategorieart {
|
||||
switch zweck {
|
||||
case .neu(_, let kind): return kind
|
||||
case .aendern(let kategorie): return kategorie.type
|
||||
}
|
||||
}
|
||||
|
||||
/// Umhängen nur innerhalb derselben Art: Eine Ausgabe-Kategorie in einer
|
||||
/// Einnahme-Gruppe wäre ein Widerspruch.
|
||||
private var passendeGruppen: [Kategoriegruppe] {
|
||||
zustand.nachschlag.gruppen.filter { $0.kind == art }
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
switch zweck {
|
||||
case .neu(let gruppe, _):
|
||||
gruppeId = gruppe
|
||||
case .aendern(let kategorie):
|
||||
name = kategorie.name
|
||||
symbol = kategorie.icon
|
||||
farbe = kategorie.color
|
||||
gruppeId = kategorie.groupId
|
||||
istSparen = kategorie.spendingIsGood
|
||||
ohneAuswertung = kategorie.excludedFromStats
|
||||
istArchiviert = kategorie.isArchived
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Sparen gibt es nur bei Ausgaben. Beim Wechsel der Art bliebe der
|
||||
// Haken sonst gesetzt und wirkte still weiter.
|
||||
let sparen = art == .expense && istSparen
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeKategorieAn(KategorieAnlegen(
|
||||
groupId: gruppeId, name: sauber, icon: symbol, color: farbe,
|
||||
excludedFromStats: ohneAuswertung, spendingIsGood: sparen))
|
||||
case .aendern(let kategorie):
|
||||
_ = try await client.aendereKategorie(kategorie.id, KategorieAendern(
|
||||
name: sauber, groupId: gruppeId, icon: symbol, color: farbe,
|
||||
isArchived: istArchiviert, excludedFromStats: ohneAuswertung,
|
||||
spendingIsGood: sparen))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let kategorie) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheKategorie(kategorie.id) }) {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Kategoriegruppe anlegen oder ändern.
|
||||
struct GruppenFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Kategoriegruppe)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let gruppe): return gruppe.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: Kategorieart = .expense
|
||||
@State private var symbol = "list"
|
||||
@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: "konto.name"), text: $name)
|
||||
if case .neu = zweck {
|
||||
Picker(String(localized: "kategorien.art"), selection: $art) {
|
||||
Text(String(localized: "kategorien.ausgaben")).tag(Kategorieart.expense)
|
||||
Text(String(localized: "kategorien.einnahmen")).tag(Kategorieart.income)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
} else {
|
||||
// Die Art einer Gruppe nachträglich zu wechseln würde
|
||||
// alle ihre Kategorien die Seite wechseln lassen.
|
||||
LabeledContent(String(localized: "kategorien.art"),
|
||||
value: art == .income
|
||||
? String(localized: "kategorien.einnahmen")
|
||||
: String(localized: "kategorien.ausgaben"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.gruppeLoeschenErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "kategorien.gruppe"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(String(localized: "kategorien.gruppeLoeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
if case .aendern(let gruppe) = zweck {
|
||||
name = gruppe.name
|
||||
art = gruppe.kind
|
||||
symbol = gruppe.icon
|
||||
farbe = gruppe.color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeGruppeAn(GruppeAnlegen(
|
||||
name: sauber, kind: art, icon: symbol, color: farbe))
|
||||
case .aendern(let gruppe):
|
||||
_ = try await client.aendereGruppe(gruppe.id, GruppeAendern(
|
||||
name: sauber, icon: symbol, color: farbe))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let gruppe) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheGruppe(gruppe.id) }) {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Kategorien, nach Gruppen. Antippen öffnet die Kategorie samt ihren
|
||||
/// Erkennungswörtern.
|
||||
struct KategorienAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var bearbeitet: KategorieFormular.Zweck?
|
||||
@State private var gruppeBearbeiten: GruppenFormular.Zweck?
|
||||
@State private var zeigeArchivierte = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if zustand.nachschlag.gruppen.isEmpty {
|
||||
Section {
|
||||
Text(String(localized: "kategorien.leer"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(zustand.nachschlag.gruppen) { gruppe in
|
||||
Section {
|
||||
ForEach(sichtbar(gruppe)) { kategorie in
|
||||
NavigationLink {
|
||||
KategorieAnsicht(kategorie: kategorie)
|
||||
} label: {
|
||||
KategorieZeile(kategorie: kategorie)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
bearbeitet = .aendern(kategorie)
|
||||
} label: {
|
||||
Label(String(localized: "allgemein.bearbeiten"),
|
||||
systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
bearbeitet = .neu(gruppe.id, gruppe.kind)
|
||||
} label: {
|
||||
Label(String(localized: "kategorien.neu"), systemImage: "plus")
|
||||
.font(.callout)
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: Symbole.sf(gruppe.icon))
|
||||
.foregroundStyle(Color(hex: gruppe.color))
|
||||
Text(gruppe.name)
|
||||
Text(gruppe.kind == .income
|
||||
? String(localized: "kategorien.einnahmen")
|
||||
: String(localized: "kategorien.ausgaben"))
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
Button {
|
||||
gruppeBearbeiten = .aendern(gruppe)
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
}
|
||||
.textCase(nil)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(String(localized: "nebenraeume.kategorien"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Toggle(String(localized: "kategorien.archivierteZeigen"),
|
||||
isOn: $zeigeArchivierte)
|
||||
Button {
|
||||
gruppeBearbeiten = .neu
|
||||
} label: {
|
||||
Label(String(localized: "kategorien.neueGruppe"),
|
||||
systemImage: "folder.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await zustand.ladeNachschlag() }
|
||||
.sheet(item: $bearbeitet) { zweck in
|
||||
KategorieFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.sheet(item: $gruppeBearbeiten) { zweck in
|
||||
GruppenFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
}
|
||||
|
||||
private func sichtbar(_ gruppe: Kategoriegruppe) -> [Kategorie] {
|
||||
gruppe.categories.filter { zeigeArchivierte || !$0.isArchived }
|
||||
}
|
||||
}
|
||||
|
||||
private struct KategorieZeile: View {
|
||||
let kategorie: Kategorie
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
|
||||
Text(kategorie.name)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 6)
|
||||
ForEach(merkmale, id: \.self) { merkmal in
|
||||
Text(merkmal)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
if kategorie.keywordCount > 0 {
|
||||
Label("\(kategorie.keywordCount)", systemImage: "text.magnifyingglass")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur was vom Normalfall abweicht. Eine Plakette an jeder Zeile sagt
|
||||
/// nichts mehr.
|
||||
private var merkmale: [String] {
|
||||
var liste: [String] = []
|
||||
if kategorie.isArchived { liste.append(String(localized: "konten.archiviert")) }
|
||||
if kategorie.spendingIsGood { liste.append(String(localized: "kategorien.sparen")) }
|
||||
if kategorie.excludedFromStats {
|
||||
liste.append(String(localized: "kategorien.ohneAuswertung"))
|
||||
}
|
||||
return liste
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Eine Kategorie mit ihren Erkennungswörtern
|
||||
|
||||
/// Was die Erkennung an dieser Kategorie festmacht.
|
||||
///
|
||||
/// Erkennungswörter wirken **nur auf künftige Importe** – an bestehenden
|
||||
/// Buchungen ändert sich nichts. Das steht auch so unter der Liste, sonst
|
||||
/// wartet jemand vergeblich darauf, dass sich etwas rührt.
|
||||
struct KategorieAnsicht: View {
|
||||
let kategorie: Kategorie
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<[Erkennungswort]>?
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var neuesWort = ""
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { woerter in
|
||||
liste(woerter)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(kategorie.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<[Erkennungswort]>(schluessel: "keywords/\(kategorie.id)") {
|
||||
try await client.erkennungswoerter(kategorie: kategorie.id)
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liste(_ woerter: [Erkennungswort]) -> some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(woerter) { wort in
|
||||
HStack {
|
||||
Text(wort.word)
|
||||
Spacer()
|
||||
if let cents = wort.amountCents {
|
||||
Text(Geldbetrag(cents: cents,
|
||||
waehrung: zustand.einstellungen.basiswaehrung)
|
||||
.formatiert())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
Task { await loesche(wort) }
|
||||
} label: {
|
||||
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField(String(localized: "kategorien.wortHinzufuegen"), text: $neuesWort)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onSubmit { Task { await fuegeHinzu() } }
|
||||
Button {
|
||||
Task { await fuegeHinzu() }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.disabled(neuesWort.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "kategorien.erkennungswoerter"))
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.erkennungErklaerung"))
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
}
|
||||
|
||||
private func fuegeHinzu() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let wort = neuesWort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !wort.isEmpty else { return }
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.legeErkennungswortAn(
|
||||
kategorie: kategorie.id, ErkennungswortAnlegen(word: wort))
|
||||
}
|
||||
if geglueckt {
|
||||
neuesWort = ""
|
||||
await lader?.aktualisiere()
|
||||
// Die Kategorie führt die Anzahl mit; ohne das bleibt die
|
||||
// Plakette in der Übersicht auf dem alten Stand stehen.
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche(_ wort: Erkennungswort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
|
||||
await lader?.aktualisiere()
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Schlagwörter: frei vergebene Etiketten, quer zur Kategorie.
|
||||
///
|
||||
/// Die Kategorie beantwortet „wofür war das Geld". Alles, was quer dazu
|
||||
/// liegt – „Urlaub Island", „steuerlich absetzbar" –, passt in keine, weil
|
||||
/// eine Buchung davon mehrere auf einmal sein kann.
|
||||
struct TagsAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var bearbeitet: Schlagwort?
|
||||
@State private var neuerName = ""
|
||||
|
||||
/// Dieselbe Auswahl wie im Symbol- und Farbfeld, damit die Etiketten zu
|
||||
/// den Kategorien passen.
|
||||
private static let farben = ["#3f51b5", "#2e7d55", "#b3541e", "#8e24aa",
|
||||
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(zustand.nachschlag.schlagwoerter) { etikett in
|
||||
Button {
|
||||
bearbeitet = etikett
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(Color(hex: etikett.color))
|
||||
.frame(width: 14, height: 14)
|
||||
Text(etikett.name)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
Task { await loesche(etikett) }
|
||||
} label: {
|
||||
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField(String(localized: "tags.neu"), text: $neuerName)
|
||||
.onSubmit { Task { await legeAn() } }
|
||||
Button {
|
||||
Task { await legeAn() }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.disabled(neuerName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "tags.erklaerung"))
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(String(localized: "nebenraeume.tags"))
|
||||
.refreshable { await zustand.ladeNachschlag() }
|
||||
.sheet(item: $bearbeitet) { etikett in
|
||||
TagFormular(etikett: etikett) { Task { await zustand.ladeNachschlag() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
}
|
||||
|
||||
private func legeAn() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return }
|
||||
// Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach.
|
||||
let farbe = Self.farben[zustand.nachschlag.schlagwoerter.count % Self.farben.count]
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe))
|
||||
}
|
||||
if geglueckt {
|
||||
neuerName = ""
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche(_ etikett: Schlagwort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheTag(etikett.id) }) {
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Schlagwort umbenennen oder umfärben.
|
||||
struct TagFormular: View {
|
||||
let etikett: Schlagwort
|
||||
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 farbe = "#6b7480"
|
||||
@State private var vorbelegt = false
|
||||
|
||||
private static let farben = ["#3f51b5", "#2e7d55", "#b3541e", "#8e24aa",
|
||||
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "konto.name"), text: $name)
|
||||
}
|
||||
Section {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Self.farben, id: \.self) { wert in
|
||||
Button { farbe = wert } label: {
|
||||
Circle()
|
||||
.fill(Color(hex: wert))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay {
|
||||
if wert == farbe {
|
||||
Circle().strokeBorder(.primary, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "nebenraeume.tags"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
name = etikett.name
|
||||
farbe = etikett.color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.aendereTag(etikett.id,
|
||||
TagAendern(name: sauber, color: farbe))
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Den zweiten Faktor einrichten.
|
||||
///
|
||||
/// Bis Stufe 5 verwies die App dafür in den Browser. Drei Schritte:
|
||||
/// Geheimnis anlegen, in die Authenticator-App übernehmen, mit einem Code
|
||||
/// scharfschalten.
|
||||
///
|
||||
/// **Abschalten geht hier nicht**, und das ist Absicht: Es verlangt das
|
||||
/// Kontopasswort, und die App speichert keines. Wer es abschalten will,
|
||||
/// macht das am Rechner – ein Vollzugang auf diesem Gerät soll den Schutz
|
||||
/// des Kontos nicht aufheben können.
|
||||
struct ZweiterFaktorAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<ZweiterFaktorStand>?
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var einrichtung: ZweiterFaktorEinrichtung?
|
||||
@State private var code = ""
|
||||
@State private var rettungscodes: [String]?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
|
||||
inhalt(stand)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "zweifaktor.titel"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<ZweiterFaktorStand>(schluessel: "auth/2fa/status") {
|
||||
try await client.zweiterFaktorStand()
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
.sheet(item: Binding(get: { rettungscodes.map(Codeliste.init) },
|
||||
set: { if $0 == nil { rettungscodes = nil } })) { liste in
|
||||
RettungscodeBlatt(codes: liste.codes) {
|
||||
rettungscodes = nil
|
||||
Task { await lader?.aktualisiere() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur da, um die Codes an `.sheet(item:)` zu geben.
|
||||
private struct Codeliste: Identifiable {
|
||||
let codes: [String]
|
||||
var id: String { codes.joined() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inhalt(_ stand: ZweiterFaktorStand) -> some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent(String(localized: "zweifaktor.stand")) {
|
||||
Text(stand.enabled ? String(localized: "zweifaktor.aktiv")
|
||||
: String(localized: "zweifaktor.inaktiv"))
|
||||
.foregroundStyle(stand.enabled ? .green : .secondary)
|
||||
}
|
||||
if stand.enabled {
|
||||
LabeledContent(String(localized: "zweifaktor.rettungscodes"),
|
||||
value: "\(stand.recoveryCodesLeft)")
|
||||
}
|
||||
} footer: {
|
||||
Text(stand.enabled ? String(localized: "zweifaktor.aktivErklaerung")
|
||||
: String(localized: "zweifaktor.inaktivErklaerung"))
|
||||
}
|
||||
|
||||
if !stand.enabled {
|
||||
if let einrichtung {
|
||||
Section {
|
||||
// Auf dem Telefon selbst ist ein QR-Code sinnlos – die
|
||||
// Authenticator-App liegt daneben. Der Link führt
|
||||
// direkt hinein, das Geheimnis steht zum Abtippen da.
|
||||
Link(destination: URL(string: einrichtung.otpauthUri)
|
||||
?? URL(string: "https://example.invalid")!) {
|
||||
Label(String(localized: "zweifaktor.inAppOeffnen"),
|
||||
systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
LabeledContent(String(localized: "zweifaktor.geheimnis")) {
|
||||
Text(einrichtung.secret)
|
||||
.font(.footnote.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "zweifaktor.schrittEins"))
|
||||
} footer: {
|
||||
Text(String(localized: "zweifaktor.geheimnisErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(localized: "zweifaktor.code"), text: $code)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.monospaced()
|
||||
Button(String(localized: "zweifaktor.aktivieren")) {
|
||||
Task { await aktiviere() }
|
||||
}
|
||||
.disabled(code.count < 6 || vorgang.laeuft)
|
||||
} header: {
|
||||
Text(String(localized: "zweifaktor.schrittZwei"))
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Button(String(localized: "zweifaktor.einrichten")) {
|
||||
Task { await starte() }
|
||||
}
|
||||
.disabled(vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func starte() async {
|
||||
guard let client = zustand.client else { return }
|
||||
var neu: ZweiterFaktorEinrichtung?
|
||||
if await vorgang.fuehreAus({ neu = try await client.richteZweitenFaktorEin() }) {
|
||||
einrichtung = neu
|
||||
}
|
||||
}
|
||||
|
||||
private func aktiviere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
var codes: Rettungscodes?
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
codes = try await client.aktiviereZweitenFaktor(code: code)
|
||||
}
|
||||
if geglueckt {
|
||||
code = ""
|
||||
einrichtung = nil
|
||||
rettungscodes = codes?.codes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Rettungscodes – einmal zu sehen, danach nie wieder.
|
||||
///
|
||||
/// Deshalb lässt sich dieses Blatt nicht wegwischen: Wer es versehentlich
|
||||
/// schliesst, bekommt neue nur mit dem Kontopasswort am Rechner.
|
||||
private struct RettungscodeBlatt: View {
|
||||
let codes: [String]
|
||||
let fertig: () -> Void
|
||||
|
||||
@State private var abgehakt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(codes, id: \.self) { code in
|
||||
Text(code)
|
||||
.font(.body.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "zweifaktor.rettungscodes"))
|
||||
} footer: {
|
||||
Text(String(localized: "zweifaktor.rettungscodesErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
ShareLink(item: codes.joined(separator: "\n")) {
|
||||
Label(String(localized: "zweifaktor.codesSichern"),
|
||||
systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Toggle(String(localized: "zweifaktor.codesNotiert"), isOn: $abgehakt)
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "zweifaktor.aktiv"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.interactiveDismissDisabled()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.fertig"), action: fertig)
|
||||
.disabled(!abgehakt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user