Beim ersten Start fuehrt kein Weg an einer sechsstelligen PIN vorbei. Danach sperrt die App beim Kaltstart und beim Wegschalten; kommt man binnen einer Minute zurueck, geht sie ohne Nachfrage wieder auf. Face ID zuerst, die PIN als Rueckfallweg und als einziger Weg ohne Biometrie. Nach fuenf falschen PINs meldet die App ab. Gesperrt wird schon bei inactive, nicht erst im Hintergrund - sonst stuenden die Kontostaende im App-Umschalter. Von der PIN liegt nur ein gesalzener SHA-256 im Schluesselbund. Die Gerätemarke selbst bleibt wie bisher abgelegt: Die Sperre verdeckt die Oberflaeche, sie verschluesselt nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.3 KiB
Swift
211 lines
8.3 KiB
Swift
import SwiftUI
|
|
|
|
/// 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] = []
|
|
@State private var aenderePIN = false
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
if case .bereit(let benutzer) = zustand.lage {
|
|
LabeledContent(String(localized: "einstellungen.angemeldetAls"),
|
|
value: benutzer.anzeigename)
|
|
}
|
|
if let adresse = zustand.serverAdresse {
|
|
LabeledContent(String(localized: "einstellungen.server"),
|
|
value: adresse.host() ?? adresse.absoluteString)
|
|
}
|
|
NavigationLink {
|
|
ZweiterFaktorAnsicht()
|
|
} label: {
|
|
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
|
|
}
|
|
Button {
|
|
aenderePIN = true
|
|
} label: {
|
|
Label(String(localized: "pin.aendern"), systemImage: "lock.rotation")
|
|
}
|
|
} header: {
|
|
Text(String(localized: "einstellungen.zugang"))
|
|
} footer: {
|
|
Text(String(localized: "einstellungen.markeErklaerung"))
|
|
}
|
|
|
|
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 {
|
|
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
|
zeigeAbmeldefrage = true
|
|
}
|
|
} footer: {
|
|
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
|
}
|
|
}
|
|
.sheet(isPresented: $aenderePIN) {
|
|
PINEinrichtenAnsicht(pflicht: false)
|
|
}
|
|
.navigationTitle(String(localized: "reiter.einstellungen"))
|
|
.refreshable { await lade() }
|
|
.task { await lade() }
|
|
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
|
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
|
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
|
Task { await zustand.meldeAb() }
|
|
}
|
|
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)
|
|
}
|
|
}
|