Implement bank transfer app flows
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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: {})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import LocalAuthentication
|
||||
import SwiftUI
|
||||
|
||||
struct SecuritySetupView: View {
|
||||
var onComplete: () -> Void
|
||||
var changeMode: Bool = false
|
||||
|
||||
private enum Step { case enterPin, confirmPin, done }
|
||||
|
||||
@State private var step: Step = .enterPin
|
||||
@State private var pinDigits: [Int] = []
|
||||
@State private var firstPin = ""
|
||||
@State private var hintText = ""
|
||||
@State private var biometricsAvailable = false
|
||||
@State private var biometricsEnabled = UserDefaults.standard.bool(forKey: "biometrics_enabled")
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 28) {
|
||||
Spacer()
|
||||
|
||||
switch step {
|
||||
case .enterPin, .confirmPin:
|
||||
pinSetupContent
|
||||
case .done:
|
||||
doneContent
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: step)
|
||||
.task {
|
||||
let ctx = LAContext()
|
||||
biometricsAvailable = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PIN setup
|
||||
|
||||
@ViewBuilder
|
||||
private var pinSetupContent: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(step == .enterPin ? "Create a PIN" : "Confirm PIN")
|
||||
.font(.title2).bold()
|
||||
|
||||
Text("Use 4–8 digits")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(dotsDisplay)
|
||||
.font(.system(size: 30, design: .monospaced))
|
||||
.kerning(6)
|
||||
.frame(height: 44)
|
||||
.animation(.spring(response: 0.2), value: pinDigits.count)
|
||||
|
||||
if !hintText.isEmpty {
|
||||
Text(hintText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
NumpadView { key in handleKey(key) }
|
||||
}
|
||||
|
||||
// MARK: - Done screen
|
||||
|
||||
@ViewBuilder
|
||||
private var doneContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("PIN Created")
|
||||
.font(.title2).bold()
|
||||
|
||||
Text("Your app is now protected.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if biometricsAvailable {
|
||||
VStack(spacing: 4) {
|
||||
let label = biometricLabel()
|
||||
Toggle(isOn: $biometricsEnabled) {
|
||||
Label("Use \(label) to unlock", systemImage: biometricIcon())
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.tint(.accentColor)
|
||||
.onChange(of: biometricsEnabled) {
|
||||
UserDefaults.standard.set(biometricsEnabled, forKey: "biometrics_enabled")
|
||||
}
|
||||
|
||||
Text("Recommended for quick access")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Continue") { onComplete() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private var dotsDisplay: String {
|
||||
let n = pinDigits.count
|
||||
let total = max(n, 4)
|
||||
return String(repeating: "●", count: n)
|
||||
+ String(repeating: "○", count: total - n)
|
||||
}
|
||||
|
||||
private func handleKey(_ key: String) {
|
||||
withAnimation { hintText = "" }
|
||||
switch key {
|
||||
case "⌫":
|
||||
if !pinDigits.isEmpty { pinDigits.removeLast() }
|
||||
case "✓":
|
||||
if pinDigits.count >= 4 { submitPin() }
|
||||
default:
|
||||
if let d = Int(key), pinDigits.count < 8 {
|
||||
pinDigits.append(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func submitPin() {
|
||||
let entered = pinDigits.map(String.init).joined()
|
||||
switch step {
|
||||
case .enterPin:
|
||||
firstPin = entered
|
||||
pinDigits = []
|
||||
step = .confirmPin
|
||||
|
||||
case .confirmPin:
|
||||
if entered == firstPin {
|
||||
savePin(entered)
|
||||
withAnimation { step = .done }
|
||||
} else {
|
||||
withAnimation { hintText = "PINs don't match — try again" }
|
||||
pinDigits = []
|
||||
}
|
||||
|
||||
case .done:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func savePin(_ pin: String) {
|
||||
let salt = PinHash.generateSalt()
|
||||
let hash = PinHash.hash(pin, salt: salt)
|
||||
try? CredentialStore.shared.save(salt.base64EncodedString(), forKey: CredentialStore.Keys.securityHashSalt)
|
||||
try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.securityHash)
|
||||
UserDefaults.standard.set("pin", forKey: "security_method")
|
||||
UserDefaults.standard.set(pin.count, forKey: "pin_length")
|
||||
UserDefaults.standard.set(true, forKey: "auto_unlock_pin")
|
||||
}
|
||||
|
||||
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 {
|
||||
SecuritySetupView(onComplete: {})
|
||||
}
|
||||
Reference in New Issue
Block a user