Files
certain/ios/Einstellungen/EinstellungenAnsicht.swift
GuidoandClaude Opus 5 09ec2dc5fd feat: Bildschirmsperre mit Face ID und PIN
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>
2026-10-24 18:09:05 +02:00

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)
}
}