171 lines
5.3 KiB
Swift
171 lines
5.3 KiB
Swift
import LocalAuthentication
|
|
import SwiftUI
|
|
|
|
struct LockScreenView: View {
|
|
var onUnlocked: () -> Void
|
|
|
|
@State private var vm = LockViewModel()
|
|
@State private var hintText = ""
|
|
@State private var lockoutSeconds = 0
|
|
@State private var lockoutTimer: Timer?
|
|
|
|
private let biometricsEnabled = UserDefaults.standard.bool(forKey: "biometrics_enabled")
|
|
|
|
var body: some View {
|
|
VStack(spacing: 28) {
|
|
Spacer()
|
|
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 48))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text("Enter PIN")
|
|
.font(.title2).bold()
|
|
}
|
|
|
|
Text(vm.dotsDisplay)
|
|
.font(.system(size: 30, design: .monospaced))
|
|
.kerning(6)
|
|
.frame(height: 44)
|
|
.animation(.spring(response: 0.2), value: vm.pinDigits.count)
|
|
|
|
Group {
|
|
if lockoutSeconds > 0 {
|
|
Text("Try again in \(lockoutSeconds)s")
|
|
.foregroundStyle(.red)
|
|
} else if !hintText.isEmpty {
|
|
Text(hintText)
|
|
.foregroundStyle(.red)
|
|
} else {
|
|
Text(" ") // placeholder to keep layout stable
|
|
}
|
|
}
|
|
.font(.callout)
|
|
|
|
NumpadView { key in handleKey(key) }
|
|
.disabled(lockoutSeconds > 0 || vm.isVerifying)
|
|
.opacity(lockoutSeconds > 0 ? 0.4 : 1)
|
|
|
|
if biometricsEnabled {
|
|
Button { triggerBiometric() } label: {
|
|
Label(biometricLabel(), systemImage: biometricIcon())
|
|
.font(.callout)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.task {
|
|
if biometricsEnabled { triggerBiometric() }
|
|
// Resume any active lockout on re-appearance
|
|
if vm.isLockedOut { startLockoutTimer() }
|
|
}
|
|
.onDisappear {
|
|
lockoutTimer?.invalidate()
|
|
lockoutTimer = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Key handling
|
|
|
|
private func handleKey(_ key: String) {
|
|
guard !vm.isLockedOut else { startLockoutTimer(); return }
|
|
hintText = ""
|
|
if vm.handleKey(key) { verifyPin() }
|
|
}
|
|
|
|
// MARK: - Verification
|
|
|
|
private func verifyPin() {
|
|
guard !vm.isVerifying else { return }
|
|
vm.isVerifying = true
|
|
let entered = vm.currentPin()
|
|
vm.clearPin()
|
|
|
|
Task {
|
|
let ok = await verifyInBackground(entered)
|
|
await MainActor.run {
|
|
vm.isVerifying = false
|
|
if ok {
|
|
vm.resetFailures()
|
|
onUnlocked()
|
|
} else {
|
|
showFailure()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func verifyInBackground(_ input: String) async -> Bool {
|
|
await Task.detached(priority: .userInitiated) {
|
|
guard
|
|
let saltB64 = CredentialStore.shared.load(forKey: CredentialStore.Keys.securityHashSalt),
|
|
let salt = Data(base64Encoded: saltB64),
|
|
let stored = CredentialStore.shared.load(forKey: CredentialStore.Keys.securityHash)
|
|
else { return false }
|
|
return PinHash.verify(input, against: stored, salt: salt)
|
|
}.value
|
|
}
|
|
|
|
private func showFailure() {
|
|
withAnimation { hintText = vm.failureHint() }
|
|
if vm.isLockedOut {
|
|
startLockoutTimer()
|
|
} else {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation { hintText = "" }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lockout timer
|
|
|
|
private func startLockoutTimer() {
|
|
lockoutSeconds = Int(ceil(vm.lockoutRemaining))
|
|
guard lockoutSeconds > 0 else { return }
|
|
hintText = ""
|
|
lockoutTimer?.invalidate()
|
|
lockoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
|
lockoutSeconds = Int(ceil(vm.lockoutRemaining))
|
|
if lockoutSeconds <= 0 {
|
|
lockoutTimer?.invalidate()
|
|
lockoutTimer = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Biometric
|
|
|
|
private func triggerBiometric() {
|
|
let ctx = LAContext()
|
|
var err: NSError?
|
|
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else { return }
|
|
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Unlock Thijooree") { success, _ in
|
|
guard success else { return }
|
|
Task { @MainActor in
|
|
vm.resetFailures()
|
|
onUnlocked()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func biometricLabel() -> String {
|
|
let ctx = LAContext()
|
|
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
|
return ctx.biometryType == .faceID ? "Face ID" : "Touch ID"
|
|
}
|
|
|
|
private func biometricIcon() -> String {
|
|
let ctx = LAContext()
|
|
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
|
return ctx.biometryType == .faceID ? "faceid" : "touchid"
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
LockScreenView(onUnlocked: {})
|
|
}
|