Files
aliasvault/apps/mobile-app/ios/VaultUI/Auth/PinSetupViewModel.swift
2025-11-13 18:43:54 +00:00

191 lines
6.1 KiB
Swift

import Foundation
import SwiftUI
import UIKit
private let locBundle = Bundle.vaultUI
/// Configuration for PIN setup flow
public struct PinSetupConfiguration {
/// Current step in the setup process
public enum Step {
case enterNew
case confirm
}
public let step: Step
public let title: String
public let subtitle: String
public let pinLength: Int? // nil = variable length (4-8 digits)
public let firstStepPin: String? // Pin from first step (used in confirm step)
public init(step: Step, title: String, subtitle: String, pinLength: Int?, firstStepPin: String? = nil) {
self.step = step
self.title = title
self.subtitle = subtitle
self.pinLength = pinLength
self.firstStepPin = firstStepPin
}
}
/// ViewModel for PIN setup flow
@MainActor
public class PinSetupViewModel: ObservableObject {
@Published public var pin: String = ""
@Published public var error: String?
@Published public var isProcessing: Bool = false
@Published public var configuration: PinSetupConfiguration
private let setupHandler: (String) async throws -> Void
private let cancelHandler: () -> Void
public init(
setupHandler: @escaping (String) async throws -> Void,
cancelHandler: @escaping () -> Void
) {
self.setupHandler = setupHandler
self.cancelHandler = cancelHandler
// Start with first step
self.configuration = PinSetupConfiguration(
step: .enterNew,
title: String(localized: "pin_setup_title", bundle: locBundle),
subtitle: String(localized: "pin_setup_subtitle", bundle: locBundle),
pinLength: nil // Variable length for first step
)
}
public func addDigit(_ digit: String) {
// Clear error when user starts typing again
error = nil
// Check if we've reached max length
let maxLength = configuration.pinLength ?? 8 // Max 8 digits in setup mode
if pin.count >= maxLength {
return
}
pin += digit
// Auto-submit when PIN reaches expected length (only for confirm step with fixed length)
if configuration.step == .confirm,
let expectedLength = configuration.pinLength,
pin.count == expectedLength {
// Small delay to show the last dot filled before submitting
Task {
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
await submitPin()
}
}
}
public func removeDigit() {
guard !pin.isEmpty else { return }
pin.removeLast()
error = nil
}
public func cancel() {
cancelHandler()
}
public func submitPin() async {
// Validate minimum length for setup mode
if configuration.step == .enterNew && pin.count < 4 {
return // Don't submit until at least 4 digits
}
isProcessing = true
// Give UI time to update
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
switch configuration.step {
case .enterNew:
// Move to confirm step
let pinLength = pin.count
let firstStepPin = pin
isProcessing = false
// Update configuration for confirm step
configuration = PinSetupConfiguration(
step: .confirm,
title: String(localized: "pin_confirm_title", bundle: locBundle),
subtitle: String(localized: "pin_confirm_subtitle", bundle: locBundle),
pinLength: pinLength, // Fix length for confirmation
firstStepPin: firstStepPin
)
// Clear current PIN for confirmation entry
pin = ""
case .confirm:
// Check if PINs match
guard let firstPin = configuration.firstStepPin else {
isProcessing = false
self.error = String(localized: "unknown_error", bundle: locBundle)
triggerErrorFeedback()
return
}
if pin != firstPin {
// PINs don't match - restart from beginning
isProcessing = false
self.error = String(localized: "pin_mismatch", bundle: locBundle)
triggerErrorFeedback()
// Wait to let user see the error message
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
// Restart from beginning
configuration = PinSetupConfiguration(
step: .enterNew,
title: String(localized: "pin_setup_title", bundle: locBundle),
subtitle: String(localized: "pin_setup_subtitle", bundle: locBundle),
pinLength: nil
)
pin = ""
error = nil
return
}
// PINs match - setup the PIN
do {
try await setupHandler(pin)
// Success - the handler will navigate away or complete the flow
// Keep loading state active since we're navigating
} catch {
// Generic error fallback
isProcessing = false
self.error = String(localized: "unknown_error", bundle: locBundle)
triggerErrorFeedback()
shakeAndClear()
}
}
}
/// Check if we can submit based on current state
public var canSubmit: Bool {
switch configuration.step {
case .enterNew:
return pin.count >= 4 && pin.count <= 8
case .confirm:
return pin.count == configuration.pinLength
}
}
private func triggerErrorFeedback() {
// Trigger haptic feedback for error
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.error)
}
private func shakeAndClear() {
// Clear the PIN after a short delay to show error
Task {
try? await Task.sleep(nanoseconds: 500_000_000) // 500ms
pin = ""
}
}
}