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:
@@ -1,11 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Stufe 1 zeigt hier nur, woran man ist, und lässt abmelden. Die echten
|
||||
/// Einstellungen (Hauptwährung, Budget-Starttag, Sprache, Gerätemarken)
|
||||
/// kommen in Stufe 5.
|
||||
/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank,
|
||||
/// zweiter Faktor.
|
||||
///
|
||||
/// Sie hängen hier und nicht als eigene Reiter: Man braucht sie selten, und
|
||||
/// eine Leiste mit zehn Reitern findet niemand mehr.
|
||||
struct EinstellungenAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var zeigeAbmeldefrage = false
|
||||
@State private var markierte: [MarkiertesWort] = []
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -18,17 +22,78 @@ struct EinstellungenAnsicht: View {
|
||||
LabeledContent(String(localized: "einstellungen.server"),
|
||||
value: adresse.host() ?? adresse.absoluteString)
|
||||
}
|
||||
NavigationLink {
|
||||
ZweiterFaktorAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "einstellungen.zugang"))
|
||||
} footer: {
|
||||
Text(String(localized: "einstellungen.markeErklaerung"))
|
||||
}
|
||||
|
||||
Section(String(localized: "einstellungen.ausDemServer")) {
|
||||
LabeledContent(String(localized: "einstellungen.hauptwaehrung"),
|
||||
value: zustand.einstellungen.basiswaehrung)
|
||||
LabeledContent(String(localized: "einstellungen.budgetStarttag"),
|
||||
value: "\(zustand.einstellungen.budgetStarttag)")
|
||||
Section(String(localized: "nebenraeume.titel")) {
|
||||
NavigationLink {
|
||||
KategorienAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.kategorien"), systemImage: "square.grid.2x2")
|
||||
}
|
||||
NavigationLink {
|
||||
TagsAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.tags"), systemImage: "tag")
|
||||
}
|
||||
NavigationLink {
|
||||
BankAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.bank"), systemImage: "building.columns")
|
||||
}
|
||||
}
|
||||
|
||||
if !markierte.isEmpty {
|
||||
Section {
|
||||
ForEach(markierte) { wort in
|
||||
MarkiertesWortZeile(wort: wort,
|
||||
behalten: { Task { await behalte(wort) } },
|
||||
entfernen: { Task { await entferne(wort) } })
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "kategorien.markiertTitel"))
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.markiertErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(String(localized: "einstellungen.erscheinungsbild"),
|
||||
selection: bindung(\.erscheinungsbild, "theme")) {
|
||||
Text(String(localized: "einstellungen.system")).tag("system")
|
||||
Text(String(localized: "einstellungen.hell")).tag("light")
|
||||
Text(String(localized: "einstellungen.dunkel")).tag("dark")
|
||||
}
|
||||
Picker(String(localized: "einstellungen.hauptwaehrung"),
|
||||
selection: bindung(\.basiswaehrung, "base_currency")) {
|
||||
ForEach(zustand.einstellungen.aktiveWaehrungen, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "einstellungen.budgetStarttag"),
|
||||
selection: tagBindung) {
|
||||
ForEach(1...28, id: \.self) { tag in
|
||||
Text("\(tag).").tag(tag)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "einstellungen.ausDemServer"))
|
||||
} footer: {
|
||||
// Die Hauptwährung rechnet alle Auswertungen um. Das gilt
|
||||
// für jedes Gerät, nicht nur für dieses.
|
||||
Text(String(localized: "einstellungen.serverweitErklaerung"))
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -40,7 +105,8 @@ struct EinstellungenAnsicht: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.einstellungen"))
|
||||
.refreshable { await zustand.aktualisiereEinstellungen() }
|
||||
.refreshable { await lade() }
|
||||
.task { await lade() }
|
||||
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
||||
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||
@@ -49,4 +115,87 @@ struct EinstellungenAnsicht: View {
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Einstellungen schreiben
|
||||
|
||||
/// Eine Bindung, die beim Setzen gleich zum Server geht.
|
||||
///
|
||||
/// Ein „Sichern"-Knopf wäre hier falsch: Es ist je Zeile genau ein Wert,
|
||||
/// und das Backend nimmt ihn einzeln entgegen (`PUT /settings/{key}`).
|
||||
private func bindung(_ pfad: KeyPath<Einstellungen, String>,
|
||||
_ schluessel: String) -> Binding<String> {
|
||||
Binding(get: { zustand.einstellungen[keyPath: pfad] },
|
||||
set: { neu in Task { await setze(schluessel, neu) } })
|
||||
}
|
||||
|
||||
private var tagBindung: Binding<Int> {
|
||||
Binding(get: { zustand.einstellungen.budgetStarttag },
|
||||
set: { neu in Task { await setze("budget_start_day", String(neu)) } })
|
||||
}
|
||||
|
||||
private func setze(_ schluessel: String, _ wert: String) async {
|
||||
guard let client = zustand.client else { return }
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.setzeEinstellung(schluessel, wert)
|
||||
}
|
||||
if geglueckt { await zustand.aktualisiereEinstellungen() }
|
||||
}
|
||||
|
||||
// MARK: - Markierte Erkennungswörter
|
||||
|
||||
private func lade() async {
|
||||
await zustand.aktualisiereEinstellungen()
|
||||
guard let client = zustand.client else { return }
|
||||
markierte = (try? await client.markierteErkennungswoerter()) ?? []
|
||||
}
|
||||
|
||||
private func behalte(_ wort: MarkiertesWort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.bestaetigeErkennungswort(wort.id) }) {
|
||||
await lade()
|
||||
}
|
||||
}
|
||||
|
||||
private func entferne(_ wort: MarkiertesWort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
|
||||
await lade()
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Erkennungswort, das oft wieder korrigiert wird.
|
||||
private struct MarkiertesWortZeile: View {
|
||||
let wort: MarkiertesWort
|
||||
let behalten: () -> Void
|
||||
let entfernen: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(wort.word)
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Text(wort.categoryName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(String(format: String(localized: "kategorien.markiertZaehler"),
|
||||
wort.appliedCount, wort.overriddenCount))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(String(localized: "kategorien.behalten"), action: behalten)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
Button(String(localized: "kategorien.entfernen"), role: .destructive,
|
||||
action: entfernen)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user