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:
Guido
2026-10-21 22:03:28 +02:00
co-authored by Claude Opus 5
parent bab5cc5060
commit d290930759
13 changed files with 1828 additions and 9 deletions
+146
View File
@@ -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)
}
}
+307
View File
@@ -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()
}
}
}
+250
View File
@@ -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()
}
}
}
+176
View File
@@ -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()
}
}
}
+190
View File
@@ -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)
}
}
}
}
}