mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Initial implementation for AppLockSetupFlowCoordinator. (#1949)
Rename AppLockSettingsScreen to AppLockSetupSettingsScreen. Implement forced logout and persist PIN entry attempts.
This commit is contained in:
@@ -25,6 +25,8 @@ struct AppLockScreenCoordinatorParameters {
|
||||
enum AppLockScreenCoordinatorAction {
|
||||
/// The user has successfully unlocked the app.
|
||||
case appUnlocked
|
||||
/// The user failed to unlock the app (or forgot their PIN).
|
||||
case forceLogout
|
||||
}
|
||||
|
||||
final class AppLockScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -51,6 +53,8 @@ final class AppLockScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .appUnlocked:
|
||||
self.actionsSubject.send(.appUnlocked)
|
||||
case .forceLogout:
|
||||
self.actionsSubject.send(.forceLogout)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -19,6 +19,8 @@ import Foundation
|
||||
enum AppLockScreenViewModelAction {
|
||||
/// The user has successfully unlocked the app.
|
||||
case appUnlocked
|
||||
/// The user failed to unlock the app (or forgot their PIN).
|
||||
case forceLogout
|
||||
}
|
||||
|
||||
struct AppLockScreenViewState: BindableState {
|
||||
@@ -36,7 +38,7 @@ struct AppLockScreenViewState: BindableState {
|
||||
/// The string shown in the screen's subtitle.
|
||||
var subtitle: String {
|
||||
if !isSubtitleWarning {
|
||||
return L10n.screenAppLockSubtitle
|
||||
return L10n.screenAppLockSubtitle(maximumAttempts)
|
||||
} else {
|
||||
return L10n.screenAppLockSubtitleWrongPin(maximumAttempts - numberOfPINAttempts)
|
||||
}
|
||||
@@ -50,8 +52,8 @@ struct AppLockScreenViewStateBindings {
|
||||
}
|
||||
|
||||
enum AppLockScreenAlertType {
|
||||
/// The user has failed too many times, they're being signed out.
|
||||
case forceSignOut
|
||||
/// The user has failed too many times, they're being logged out.
|
||||
case forcedLogout
|
||||
/// The user has forgotten their PIN, confirm they're happy to sign out.
|
||||
case confirmResetPIN
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
||||
self.appLockService = appLockService
|
||||
|
||||
super.init(initialViewState: AppLockScreenViewState(bindings: .init()))
|
||||
|
||||
appLockService.numberOfPINAttempts
|
||||
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
showForceLogoutAlertIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -58,24 +64,21 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
||||
state.bindings.alertInfo = .init(id: .confirmResetPIN,
|
||||
title: L10n.screenAppLockSignoutAlertTitle,
|
||||
message: L10n.screenAppLockSignoutAlertMessage,
|
||||
primaryButton: .init(title: L10n.actionOk, action: forceSignOut),
|
||||
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) },
|
||||
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
|
||||
}
|
||||
|
||||
private func handleInvalidPIN() {
|
||||
MXLog.warning("Invalid PIN code entered.")
|
||||
state.numberOfPINAttempts += 1
|
||||
|
||||
if state.numberOfPINAttempts == 3 {
|
||||
state.bindings.alertInfo = .init(id: .forceSignOut,
|
||||
title: L10n.screenAppLockSignoutAlertTitle,
|
||||
message: L10n.screenAppLockSignoutAlertMessage,
|
||||
primaryButton: .init(title: L10n.actionOk, action: nil))
|
||||
forceSignOut()
|
||||
}
|
||||
showForceLogoutAlertIfNeeded()
|
||||
}
|
||||
|
||||
private func forceSignOut() {
|
||||
// To be implemented.
|
||||
private func showForceLogoutAlertIfNeeded() {
|
||||
if state.numberOfPINAttempts >= 3 {
|
||||
state.bindings.alertInfo = .init(id: .forcedLogout,
|
||||
title: L10n.screenAppLockSignoutAlertTitle,
|
||||
message: L10n.screenAppLockSignoutAlertMessage,
|
||||
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -31,8 +31,10 @@ struct AppLockSetupBiometricsScreen: View {
|
||||
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
.navigationBarBackButtonHidden()
|
||||
.interactiveDismissDisabled()
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
buttons
|
||||
.padding(.top, 16)
|
||||
|
||||
+5
-2
@@ -21,6 +21,8 @@ struct AppLockSetupPINScreenCoordinatorParameters {
|
||||
/// Whether the screen should start in create or unlock mode.
|
||||
/// Specifying confirm here will raise a fatal error.
|
||||
let initialMode: AppLockSetupPINScreenMode
|
||||
/// Whether the screen is mandatory or can be cancelled by the user.
|
||||
let isMandatory: Bool
|
||||
let appLockService: AppLockServiceProtocol
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
self.parameters = parameters
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: parameters.initialMode,
|
||||
isMandatory: parameters.isMandatory,
|
||||
appLockService: parameters.appLockService)
|
||||
}
|
||||
|
||||
@@ -56,9 +59,9 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .complete:
|
||||
break
|
||||
actionsSubject.send(.complete)
|
||||
case .cancel:
|
||||
break
|
||||
actionsSubject.send(.cancel)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
+2
@@ -35,6 +35,8 @@ enum AppLockSetupPINScreenMode {
|
||||
struct AppLockSetupPINScreenViewState: BindableState {
|
||||
/// The current mode that the screen is in.
|
||||
var mode: AppLockSetupPINScreenMode
|
||||
/// Whether the screen is mandatory or can be cancelled.
|
||||
let isMandatory: Bool
|
||||
|
||||
var title: String {
|
||||
switch mode {
|
||||
|
||||
+2
-2
@@ -30,9 +30,9 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(initialMode: AppLockSetupPINScreenMode, appLockService: AppLockServiceProtocol) {
|
||||
init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) {
|
||||
self.appLockService = appLockService
|
||||
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, bindings: .init(pinCode: "")))
|
||||
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: "")))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
+29
-3
@@ -21,6 +21,8 @@ import SwiftUI
|
||||
struct AppLockSetupPINScreen: View {
|
||||
@ObservedObject var context: AppLockSetupPINScreenViewModel.Context
|
||||
|
||||
@FocusState private var textFieldFocus
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 48) {
|
||||
@@ -28,6 +30,7 @@ struct AppLockSetupPINScreen: View {
|
||||
|
||||
PINTextField(pinCode: $context.pinCode,
|
||||
isSecure: context.viewState.mode == .unlock)
|
||||
.focused($textFieldFocus)
|
||||
.onChange(of: context.pinCode) { newValue in
|
||||
guard newValue.count == 4 else { return }
|
||||
context.send(viewAction: .submitPINCode)
|
||||
@@ -38,6 +41,12 @@ struct AppLockSetupPINScreen: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.toolbar { toolbar }
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
.navigationBarBackButtonHidden()
|
||||
.interactiveDismissDisabled(context.viewState.isMandatory)
|
||||
.alert(item: $context.alertInfo)
|
||||
.onAppear { textFieldFocus = true }
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
@@ -59,6 +68,17 @@ struct AppLockSetupPINScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
var toolbar: some ToolbarContent {
|
||||
if !context.viewState.isMandatory {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
@@ -66,9 +86,15 @@ struct AppLockSetupPINScreen: View {
|
||||
struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let service = AppLockService(keychainController: KeychainControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create, appLockService: service)
|
||||
static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm, appLockService: service)
|
||||
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, appLockService: service)
|
||||
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create,
|
||||
isMandatory: false,
|
||||
appLockService: service)
|
||||
static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm,
|
||||
isMandatory: false,
|
||||
appLockService: service)
|
||||
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
|
||||
isMandatory: false,
|
||||
appLockService: service)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
|
||||
+17
-14
@@ -17,28 +17,31 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct AppLockSettingsScreenCoordinatorParameters {
|
||||
struct AppLockSetupSettingsScreenCoordinatorParameters {
|
||||
/// Whether App Lock is mandatory and can be disabled by the user.
|
||||
let isMandatory: Bool
|
||||
let appLockService: AppLockServiceProtocol
|
||||
}
|
||||
|
||||
enum AppLockSettingsScreenCoordinatorAction {
|
||||
case done
|
||||
enum AppLockSetupSettingsScreenCoordinatorAction {
|
||||
case changePINCode
|
||||
case appLockDisabled
|
||||
}
|
||||
|
||||
final class AppLockSettingsScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: AppLockSettingsScreenCoordinatorParameters
|
||||
private var viewModel: AppLockSettingsScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<AppLockSettingsScreenCoordinatorAction, Never> = .init()
|
||||
final class AppLockSetupSettingsScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: AppLockSetupSettingsScreenCoordinatorParameters
|
||||
private var viewModel: AppLockSetupSettingsScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<AppLockSetupSettingsScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<AppLockSettingsScreenCoordinatorAction, Never> {
|
||||
var actions: AnyPublisher<AppLockSetupSettingsScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: AppLockSettingsScreenCoordinatorParameters) {
|
||||
init(parameters: AppLockSetupSettingsScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = AppLockSettingsScreenViewModel(appLockService: parameters.appLockService)
|
||||
viewModel = AppLockSetupSettingsScreenViewModel(appLockService: parameters.appLockService)
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -48,15 +51,15 @@ final class AppLockSettingsScreenCoordinator: CoordinatorProtocol {
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .changePINCode:
|
||||
break
|
||||
case .done:
|
||||
self.actionsSubject.send(.done)
|
||||
actionsSubject.send(.changePINCode)
|
||||
case .appLockDisabled:
|
||||
actionsSubject.send(.appLockDisabled)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(AppLockSettingsScreen(context: viewModel.context))
|
||||
AnyView(AppLockSetupSettingsScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -17,36 +17,38 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
enum AppLockSettingsScreenViewModelAction {
|
||||
enum AppLockSetupSettingsScreenViewModelAction {
|
||||
/// The user would like to enter new PIN code.
|
||||
case changePINCode
|
||||
case done
|
||||
/// The user has disabled the App Lock feature.
|
||||
case appLockDisabled
|
||||
}
|
||||
|
||||
struct AppLockSettingsScreenViewState: BindableState {
|
||||
struct AppLockSetupSettingsScreenViewState: BindableState {
|
||||
/// Whether App Lock is mandatory and can be disabled by the user.
|
||||
let isMandatory: Bool
|
||||
let biometryType: LABiometryType
|
||||
var bindings: AppLockSettingsScreenViewStateBindings
|
||||
var bindings: AppLockSetupSettingsScreenViewStateBindings
|
||||
|
||||
var supportsBiometry: Bool { biometryType != .none }
|
||||
var enableBiometryTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
|
||||
}
|
||||
|
||||
struct AppLockSettingsScreenViewStateBindings {
|
||||
struct AppLockSetupSettingsScreenViewStateBindings {
|
||||
var enableBiometrics: Bool
|
||||
var alertInfo: AlertInfo<AppLockSettingsScreenAlertType>?
|
||||
var alertInfo: AlertInfo<AppLockSetupSettingsScreenAlertType>?
|
||||
}
|
||||
|
||||
enum AppLockSettingsScreenAlertType {
|
||||
enum AppLockSetupSettingsScreenAlertType {
|
||||
/// The alert shown to confirm the user would like to remove their PIN.
|
||||
case confirmRemovePINCode
|
||||
}
|
||||
|
||||
enum AppLockSettingsScreenViewAction {
|
||||
enum AppLockSetupSettingsScreenViewAction {
|
||||
/// The user would like to enter a new PIN code.
|
||||
case changePINCode
|
||||
/// The user would like to disable the App Lock feature.
|
||||
case disable
|
||||
/// The user has toggled the biometrics setting.
|
||||
case enableBiometricsChanged
|
||||
case done
|
||||
}
|
||||
+9
-10
@@ -17,25 +17,26 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias AppLockSettingsScreenViewModelType = StateStoreViewModel<AppLockSettingsScreenViewState, AppLockSettingsScreenViewAction>
|
||||
typealias AppLockSetupSettingsScreenViewModelType = StateStoreViewModel<AppLockSetupSettingsScreenViewState, AppLockSetupSettingsScreenViewAction>
|
||||
|
||||
class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLockSettingsScreenViewModelProtocol {
|
||||
class AppLockSetupSettingsScreenViewModel: AppLockSetupSettingsScreenViewModelType, AppLockSetupSettingsScreenViewModelProtocol {
|
||||
private let appLockService: AppLockServiceProtocol
|
||||
private var actionsSubject: PassthroughSubject<AppLockSettingsScreenViewModelAction, Never> = .init()
|
||||
private var actionsSubject: PassthroughSubject<AppLockSetupSettingsScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<AppLockSettingsScreenViewModelAction, Never> {
|
||||
var actions: AnyPublisher<AppLockSetupSettingsScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(appLockService: AppLockServiceProtocol) {
|
||||
self.appLockService = appLockService
|
||||
super.init(initialViewState: AppLockSettingsScreenViewState(biometryType: appLockService.biometryType,
|
||||
bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled)))
|
||||
super.init(initialViewState: AppLockSetupSettingsScreenViewState(isMandatory: appLockService.isMandatory,
|
||||
biometryType: appLockService.biometryType,
|
||||
bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled)))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AppLockSettingsScreenViewAction) {
|
||||
override func process(viewAction: AppLockSetupSettingsScreenViewAction) {
|
||||
MXLog.info("View model: received view action: \(viewAction)")
|
||||
|
||||
switch viewAction {
|
||||
@@ -45,8 +46,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc
|
||||
showRemovePINAlert()
|
||||
case .enableBiometricsChanged:
|
||||
appLockService.biometricUnlockEnabled = state.bindings.enableBiometrics
|
||||
case .done:
|
||||
actionsSubject.send(.done)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +63,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc
|
||||
/// Removes the user's PIN code, disabling the App Lock feature.
|
||||
private func completeRemovePIN() {
|
||||
appLockService.disable()
|
||||
actionsSubject.send(.done)
|
||||
actionsSubject.send(.appLockDisabled)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -17,7 +17,7 @@
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol AppLockSettingsScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<AppLockSettingsScreenViewModelAction, Never> { get }
|
||||
var context: AppLockSettingsScreenViewModelType.Context { get }
|
||||
protocol AppLockSetupSettingsScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<AppLockSetupSettingsScreenViewModelAction, Never> { get }
|
||||
var context: AppLockSetupSettingsScreenViewModelType.Context { get }
|
||||
}
|
||||
+16
-13
@@ -17,16 +17,19 @@
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct AppLockSettingsScreen: View {
|
||||
@ObservedObject var context: AppLockSettingsScreenViewModel.Context
|
||||
struct AppLockSetupSettingsScreen: View {
|
||||
@ObservedObject var context: AppLockSetupSettingsScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
ListRow(label: .plain(title: L10n.screenAppLockSettingsChangePin),
|
||||
kind: .button { context.send(viewAction: .changePINCode) })
|
||||
ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive),
|
||||
kind: .button { context.send(viewAction: .disable) })
|
||||
|
||||
if !context.viewState.isMandatory {
|
||||
ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive),
|
||||
kind: .button { context.send(viewAction: .disable) })
|
||||
}
|
||||
}
|
||||
|
||||
if context.viewState.supportsBiometry {
|
||||
@@ -48,25 +51,25 @@ struct AppLockSettingsScreen: View {
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AppLockSettingsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let faceIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID))
|
||||
static let touchIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .touchID))
|
||||
static let biometricsUnavailableViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none))
|
||||
struct AppLockSetupSettingsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let faceIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID))
|
||||
static let touchIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(isMandatory: true, biometryType: .touchID))
|
||||
static let biometricsUnavailableViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none))
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
AppLockSettingsScreen(context: faceIDViewModel.context)
|
||||
AppLockSetupSettingsScreen(context: faceIDViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Face ID")
|
||||
|
||||
NavigationStack {
|
||||
AppLockSettingsScreen(context: touchIDViewModel.context)
|
||||
AppLockSetupSettingsScreen(context: touchIDViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Touch ID")
|
||||
.previewDisplayName("Touch ID (Mandatory)")
|
||||
|
||||
NavigationStack {
|
||||
AppLockSettingsScreen(context: biometricsUnavailableViewModel.context)
|
||||
AppLockSetupSettingsScreen(context: biometricsUnavailableViewModel.context)
|
||||
}
|
||||
.previewDisplayName("No Biometrics")
|
||||
.previewDisplayName("PIN only")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user