import LocalAuthentication import SwiftUI /// Der Sperrbildschirm. Face ID zuerst, die PIN als Rückfallweg – und als /// einziger Weg, wenn das Gerät keine Biometrie hat oder sie abgelehnt wurde. /// /// Die Sperre verdeckt die Oberfläche; die Gerätemarke bleibt im /// Schlüsselbund wie zuvor (siehe `Ablage.hatPIN`). Nach fünf falschen PINs /// meldet die App ab, sonst liesse sich eine sechsstellige PIN in Ruhe /// durchprobieren. struct SperrAnsicht: View { @Environment(AppZustand.self) private var zustand @State private var pin = "" @State private var meldung: String? @State private var biometrieLaeuft = false @FocusState private var imFeld: Bool /// Face ID, Touch ID oder gar nichts – entscheidet Beschriftung und Symbol. private var biometrie: LABiometryType { let pruefer = LAContext() guard pruefer.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return .none } return pruefer.biometryType } var body: some View { VStack(spacing: 28) { Spacer() Image(systemName: "lock.fill") .font(.system(size: 44, weight: .medium)) .foregroundStyle(Color.marke) Text(String(localized: "sperre.titel")) .font(.title2.weight(.semibold)) VStack(spacing: 12) { SecureField(String(localized: "sperre.pin"), text: $pin) .textContentType(.password) .keyboardType(.numberPad) .multilineTextAlignment(.center) .monospaced() .font(.title3) .focused($imFeld) .padding(.vertical, 12) .background(.quaternary, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .onChange(of: pin) { _, neu in pin = String(neu.filter(\.isNumber).prefix(Ziffern.anzahl)) if pin.count == Ziffern.anzahl { pruefe() } } if let meldung { Text(meldung) .font(.footnote) .foregroundStyle(.red) .multilineTextAlignment(.center) } } .frame(maxWidth: 240) if biometrie != .none { Button { Task { await versucheBiometrie() } } label: { Label(biometrieName, systemImage: biometrieSymbol) } .disabled(biometrieLaeuft) } Spacer() Button(String(localized: "sperre.abmelden"), role: .destructive) { Task { await zustand.meldeAb() } } .font(.footnote) .padding(.bottom, 24) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemBackground)) .task { await versucheBiometrie() } } private var biometrieName: String { biometrie == .touchID ? String(localized: "sperre.touchid") : String(localized: "sperre.faceid") } private var biometrieSymbol: String { biometrie == .touchID ? "touchid" : "faceid" } /// Fragt Face ID. Jede Ablehnung – abgebrochen, nicht erkannt, nicht /// eingerichtet – führt schlicht zum PIN-Feld; das ist der Rückfallweg. private func versucheBiometrie() async { guard biometrie != .none, !biometrieLaeuft else { imFeld = true return } biometrieLaeuft = true defer { biometrieLaeuft = false } let pruefer = LAContext() // Der Systemdialog bietet sonst „Passcode eingeben" an – das wäre der // Gerätecode, nicht unsere PIN, und damit verwirrend. pruefer.localizedFallbackTitle = "" let geglueckt = (try? await pruefer.evaluatePolicy( .deviceOwnerAuthenticationWithBiometrics, localizedReason: String(localized: "sperre.grund"))) ?? false if geglueckt { zustand.entsperrt() } else { imFeld = true } } private func pruefe() { guard Ablage.stimmtPIN(pin) else { pin = "" Task { let weiter = await zustand.falschePIN() if weiter { meldung = String(format: String(localized: "sperre.falsch"), zustand.verbleibendeVersuche) } } return } meldung = nil zustand.entsperrt() } } enum Ziffern { /// Sechs Stellen, wie der Gerätecode und wie der zweite Faktor. static let anzahl = 6 } /// PIN festlegen oder ändern. Beim ersten Mal führt kein Weg daran vorbei – /// darum gibt es dann kein „Abbrechen". struct PINEinrichtenAnsicht: View { @Environment(AppZustand.self) private var zustand @Environment(\.dismiss) private var schliesse /// Beim ersten Einrichten Pflicht, beim Ändern aus den Einstellungen nicht. var pflicht = true @State private var neue = "" @State private var wiederholung = "" @State private var meldung: String? var body: some View { NavigationStack { Form { Section { SecureField(String(localized: "pin.neu"), text: $neue) .keyboardType(.numberPad) .monospaced() .onChange(of: neue) { _, wert in neue = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl)) } SecureField(String(localized: "pin.wiederholen"), text: $wiederholung) .keyboardType(.numberPad) .monospaced() .onChange(of: wiederholung) { _, wert in wiederholung = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl)) } } footer: { // Keine Überschrift: Die steht schon in der Leiste oben. Text(String(localized: "pin.erklaerung")) } if let meldung { Section { Label(meldung, systemImage: "exclamationmark.triangle") .foregroundStyle(.red) } } Section { Button(String(localized: "pin.sichern"), action: lege) .disabled(neue.count < Ziffern.anzahl || wiederholung.count < Ziffern.anzahl) } } .navigationTitle(String(localized: "pin.einrichtenTitel")) .navigationBarTitleDisplayMode(.inline) .toolbar { if !pflicht { ToolbarItem(placement: .cancellationAction) { Button(String(localized: "allgemein.abbrechen")) { schliesse() } } } } } } private func lege() { guard neue.count == Ziffern.anzahl else { meldung = String(localized: "pin.zuKurz"); return } guard neue == wiederholung else { meldung = String(localized: "pin.ungleich") wiederholung = "" return } zustand.setzePIN(neue) if !pflicht { schliesse() } } }