1// AppLockView.swift
2// SophaxChat
3//
4// Full-screen lock overlay shown when app lock is enabled.
5// Auto-attempts biometric / passcode authentication on appear.
6// If a real lock PIN is set, also offers a PIN entry path (which supports duress PIN).
7
8import SwiftUI
9
10struct AppLockView: View {
11 @EnvironmentObject var appState: AppState
12 @State private var now = Date()
13 @State private var showPINEntry = false
14 @State private var enteredPIN = ""
15
16 /// Refresh the countdown every second when locked out.
17 private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
18
19 private var isLockedOut: Bool {
20 if let until = appState.unlockLockedUntil { return now < until }
21 return false
22 }
23
24 private var hasPIN: Bool { appState.hasRealLockPIN }
25
26 var body: some View {
27 ZStack {
28 Color(.systemBackground).ignoresSafeArea()
29
30 VStack(spacing: 28) {
31 Image(systemName: "lock.shield.fill")
32 .font(.system(size: 64))
33 .foregroundStyle(Color.accentColor)
34
35 VStack(spacing: 6) {
36 Text("SophaxChat")
37 .font(.title.bold())
38 Text("App is locked")
39 .font(.subheadline)
40 .foregroundStyle(.secondary)
41 }
42
43 Button(action: { appState.tryUnlock() }) {
44 if appState.isUnlocking {
45 ProgressView().frame(width: 24, height: 24)
46 .padding(.horizontal, 36)
47 .padding(.vertical, 14)
48 } else {
49 Label("Unlock with Biometrics", systemImage: "faceid")
50 .font(.headline)
51 .padding(.horizontal, 36)
52 .padding(.vertical, 14)
53 }
54 }
55 .buttonStyle(.borderedProminent)
56 .controlSize(.large)
57 .disabled(appState.isUnlocking || isLockedOut)
58
59 if hasPIN {
60 if showPINEntry {
61 pinEntryField
62 } else {
63 Button("Enter PIN instead") {
64 showPINEntry = true
65 appState.unlockError = nil
66 }
67 .font(.subheadline)
68 .foregroundStyle(.secondary)
69 }
70 }
71
72 if let err = appState.unlockError {
73 Text(err)
74 .font(.caption)
75 .foregroundStyle(.red)
76 .multilineTextAlignment(.center)
77 .padding(.horizontal, 32)
78 }
79 }
80 .padding(.horizontal, 32)
81 }
82 .onAppear { appState.tryUnlock() }
83 .onReceive(timer) { now = $0 }
84 }
85
86 @ViewBuilder
87 private var pinEntryField: some View {
88 VStack(spacing: 12) {
89 SecureField("PIN", text: $enteredPIN)
90 .keyboardType(.numberPad)
91 .textContentType(.password)
92 .multilineTextAlignment(.center)
93 .font(.title3.monospacedDigit())
94 .padding(.horizontal, 20)
95 .padding(.vertical, 10)
96 .background(Color(.secondarySystemBackground))
97 .clipShape(RoundedRectangle(cornerRadius: 10))
98
99 Button {
100 let pin = enteredPIN
101 enteredPIN = ""
102 appState.tryUnlockWithPIN(pin)
103 if !appState.isDuressActive {
104 // Keep PIN field visible if unlock failed; hide on success
105 showPINEntry = appState.isAppLocked
106 }
107 } label: {
108 Text("Unlock")
109 .font(.headline)
110 .frame(maxWidth: .infinity)
111 .padding(.vertical, 12)
112 }
113 .buttonStyle(.borderedProminent)
114 .disabled(enteredPIN.isEmpty || isLockedOut)
115 }
116 }
117}
118
119#Preview {
120 AppLockView().environmentObject(AppState())
121}