From ceee6b185346ea4bd358cb17709056364f853d6b Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Fri, 20 Oct 2023 17:35:57 +0100 Subject: [PATCH] Implement AppLockScreen as per the designs. (#1925) Fix a bug in the unlock flow --- ElementX.xcodeproj/project.pbxproj | 4 + .../en.lproj/Localizable.strings | 16 +++ .../en.lproj/Localizable.stringsdict | 32 ++++++ .../en.lproj/Untranslated.strings | 12 --- .../Sources/Application/Application.swift | 4 - .../Application/Windowing/WindowManager.swift | 3 + .../AppLockFlowCoordinator.swift | 10 +- .../Generated/Strings+Untranslated.swift | 24 ----- ElementX/Sources/Generated/Strings.swift | 40 +++++++ .../Other/AccessibilityIdentifiers.swift | 1 + .../AppLockScreen/AppLockScreenModels.swift | 46 ++++++-- .../AppLockScreenViewModel.swift | 38 ++++++- .../AppLockScreen/View/AppLockScreen.swift | 101 +++++++++++++++--- .../View/AppLockScreenPINKeypad.swift | 99 +++++++++++++++++ .../AppLockSettingsScreenModels.swift | 8 +- .../AppLockSettingsScreenViewModel.swift | 4 +- .../View/AppLockSettingsScreen.swift | 6 +- .../SettingsScreen/View/SettingsScreen.swift | 4 +- .../AppLock/AppLockScreenViewModelTests.swift | 3 +- .../PreviewTests/test_appLockScreen.1.png | 3 + changelog.d/pr-1925.wip | 1 + 21 files changed, 376 insertions(+), 83 deletions(-) create mode 100644 ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreenPINKeypad.swift create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_appLockScreen.1.png create mode 100644 changelog.d/pr-1925.wip diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index b06058b8b8..c9d644ec31 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -351,6 +351,7 @@ 64AB99285DC4437C0DDE9585 /* MenuSheetLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49ABAB186CF00B15C5521D04 /* MenuSheetLabelStyle.swift */; }; 64C373ACCFA26D42BA45CFAD /* HomeScreenInvitesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24227FF9A2797F6EA7F69CDD /* HomeScreenInvitesButton.swift */; }; 64D05250CEDE8B604119F6E6 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981663D961C94270FA035FD0 /* Alert.swift */; }; + 64E541F88F35BD126C4AFCA1 /* AppLockScreenPINKeypad.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38345442415E07A931197C55 /* AppLockScreenPINKeypad.swift */; }; 64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1941C8817E6B6971BA4415F5 /* VideoRoomTimelineView.swift */; }; 64FF5CB4E35971255872E1BB /* AuthenticationServiceProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0CB536D1C3CC15AA740CC6 /* AuthenticationServiceProxyProtocol.swift */; }; 651341E67C3514F9811A1EC1 /* LoginScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */; }; @@ -1174,6 +1175,7 @@ 37CA26F55123E36B50DB0B3A /* AttributedStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringTests.swift; sourceTree = ""; }; 37E727F7E0BCE8A0BBFD33FF /* OnboardingScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreenCoordinator.swift; sourceTree = ""; }; 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyMock.swift; sourceTree = ""; }; + 38345442415E07A931197C55 /* AppLockScreenPINKeypad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenPINKeypad.swift; sourceTree = ""; }; 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreen.swift; sourceTree = ""; }; 3948D16F021DFDB2CD26EAA8 /* MockBackgroundTaskService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBackgroundTaskService.swift; sourceTree = ""; }; 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadableFrameModifier.swift; sourceTree = ""; }; @@ -4400,6 +4402,7 @@ isa = PBXGroup; children = ( 56D6F88FE35A0979D2821E06 /* AppLockScreen.swift */, + 38345442415E07A931197C55 /* AppLockScreenPINKeypad.swift */, ); path = View; sourceTree = ""; @@ -5091,6 +5094,7 @@ 06F8EDF52E33A2D36BCC1161 /* AppLockScreen.swift in Sources */, 9912F9EB2D6589141A2957B4 /* AppLockScreenCoordinator.swift in Sources */, 2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */, + 64E541F88F35BD126C4AFCA1 /* AppLockScreenPINKeypad.swift in Sources */, 97969EF0B9C412CD38E5CA93 /* AppLockScreenViewModel.swift in Sources */, E79D79CDAFE8BEBCC3AECA54 /* AppLockScreenViewModelProtocol.swift in Sources */, 1D623953F970D11F6F38499C /* AppLockService.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 158770485a..ff914fe63b 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -4,6 +4,7 @@ "a11y_notifications_mentions_only" = "Mentions only"; "a11y_notifications_muted" = "Muted"; "a11y_pause" = "Pause"; +"a11y_pin_field" = "PIN field"; "a11y_play" = "Play"; "a11y_poll" = "Poll"; "a11y_poll_end" = "Ended poll"; @@ -95,6 +96,7 @@ "common_editing" = "Editing"; "common_emote" = "* %1$@ %2$@"; "common_encryption_enabled" = "Encryption enabled"; +"common_enter_your_pin" = "Enter your PIN"; "common_error" = "Error"; "common_everyone" = "Everyone"; "common_file" = "File"; @@ -130,6 +132,7 @@ "common_rich_text_editor" = "Rich text editor"; "common_room_name" = "Room name"; "common_room_name_placeholder" = "e.g. your project name"; +"common_screen_lock" = "Screen lock"; "common_search_for_someone" = "Search for someone"; "common_search_results" = "Search results"; "common_security" = "Security"; @@ -151,6 +154,7 @@ "common_unable_to_decrypt" = "Unable to decrypt"; "common_unable_to_invite_message" = "Invites couldn't be sent to one or more users."; "common_unable_to_invite_title" = "Unable to send invite(s)"; +"common_unlock" = "Unlock"; "common_unmute" = "Unmute"; "common_unsupported_event" = "Unsupported event"; "common_username" = "Username"; @@ -260,6 +264,18 @@ "screen_analytics_prompt_third_party_sharing" = "We won't share your data with third parties"; "screen_analytics_prompt_title" = "Help improve %1$@"; "screen_analytics_settings_share_data" = "Share analytics data"; +"screen_app_lock_forgot_pin" = "Forgot PIN?"; +"screen_app_lock_settings_change_pin" = "Change PIN code"; +"screen_app_lock_settings_enable_biometric_unlock" = "Allow biometric unlock"; +"screen_app_lock_settings_enable_face_id_ios" = "Allow Face ID"; +"screen_app_lock_settings_enable_optic_id_ios" = "Allow Optic ID"; +"screen_app_lock_settings_enable_touch_id_ios" = "Allow Touch ID"; +"screen_app_lock_settings_remove_pin" = "Remove PIN"; +"screen_app_lock_settings_remove_pin_alert_message" = "Are you sure you want to remove PIN?"; +"screen_app_lock_settings_remove_pin_alert_title" = "Remove PIN?"; +"screen_app_lock_signout_alert_message" = "You’ll need to re-login and create a new PIN to proceed"; +"screen_app_lock_signout_alert_title" = "You are being signed out"; +"screen_app_lock_subtitle" = "You have 3 attempts to unlock"; "screen_bug_report_attach_screenshot" = "Attach screenshot"; "screen_bug_report_contact_me" = "You may contact me if you have any follow up questions."; "screen_bug_report_contact_me_title" = "Contact me"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index 8d6217706f..96c31dbb34 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y_digits_entered + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %1$d digit entered + other + %1$d digits entered + + common_member_count NSStringLocalizedFormatKey @@ -146,6 +162,22 @@ %1$d room changes + screen_app_lock_subtitle_wrong_pin + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Wrong PIN. You have %1$d more chance + other + Wrong PIN. You have %1$d more chances + + screen_room_member_list_header_title NSStringLocalizedFormatKey diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 4cec3d4b8b..2b31da559c 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -4,18 +4,6 @@ /* Used for testing */ "untranslated" = "Untranslated"; -"screen_app_lock_title" = "%@ is locked"; -"screen_app_lock_settings_change_pin" = "Change PIN code"; -"screen_app_lock_settings_remove_pin" = "Remove PIN"; -"screen_app_lock_settings_enable_touch_id_ios" = "Allow Touch ID"; -"screen_app_lock_settings_enable_face_id_ios" = "Allow Face ID"; -"screen_app_lock_settings_enable_optic_id_ios" = "Allow Optic ID"; -"screen_app_lock_settings_enable_biometric_unlock" = "Allow biometric unlock"; -"screen_app_lock_settings_remove_pin_alert_title" = "Remove PIN?"; -"screen_app_lock_settings_remove_pin_alert_message" = "Are you sure you want to remove PIN?"; -"common_unlock" = "Unlock"; -"common_screen_lock" = "Screen lock"; - // MARK: - Soft logout "soft_logout_signin_title" = "Sign in"; diff --git a/ElementX/Sources/Application/Application.swift b/ElementX/Sources/Application/Application.swift index 41a1432b01..1eec384316 100644 --- a/ElementX/Sources/Application/Application.swift +++ b/ElementX/Sources/Application/Application.swift @@ -51,10 +51,6 @@ struct Application: App { openURLInSystemBrowser($0) } } - .introspect(.window, on: .supportedVersions) { window in - // Workaround for SwiftUI not consistently applying the tint colour to Alerts/Confirmation Dialogs. - window.tintColor = .compound.textActionPrimary - } .task { appCoordinator.start() } diff --git a/ElementX/Sources/Application/Windowing/WindowManager.swift b/ElementX/Sources/Application/Windowing/WindowManager.swift index 43def9f4b3..b7208a848e 100644 --- a/ElementX/Sources/Application/Windowing/WindowManager.swift +++ b/ElementX/Sources/Application/Windowing/WindowManager.swift @@ -38,8 +38,10 @@ class WindowManager { /// Configures the window manager to operate on the supplied scene. func configure(with windowScene: UIWindowScene) { mainWindow = windowScene.keyWindow + mainWindow.tintColor = .compound.textActionPrimary overlayWindow = UIWindow(windowScene: windowScene) + overlayWindow.tintColor = .compound.textActionPrimary overlayWindow.backgroundColor = .clear // We don't support user interaction on our indicators so disable interaction, to pass // touches through to the main window. If this changes, there's another solution here: @@ -47,6 +49,7 @@ class WindowManager { overlayWindow.isUserInteractionEnabled = false alternateWindow = UIWindow(windowScene: windowScene) + alternateWindow.tintColor = .compound.textActionPrimary delegate?.windowManagerDidConfigureWindows(self) } diff --git a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift index 5eff1ac3bc..6ff67b94db 100644 --- a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift @@ -67,8 +67,14 @@ class AppLockFlowCoordinator: CoordinatorProtocol { } private func applicationWillEnterForeground() { - guard appLockService.isEnabled, appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) else { return } - showUnlockScreen() + guard appLockService.isEnabled else { return } + + if appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) { + showUnlockScreen() + } else { + // Reveal the app again if within the grace period. + actionsSubject.send(.unlockApp) + } } /// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher. diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 9112194893..2fb62c2fab 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -10,30 +10,6 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum UntranslatedL10n { - /// Screen lock - public static var commonScreenLock: String { return UntranslatedL10n.tr("Untranslated", "common_screen_lock") } - /// Unlock - public static var commonUnlock: String { return UntranslatedL10n.tr("Untranslated", "common_unlock") } - /// Change PIN code - public static var screenAppLockSettingsChangePin: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_change_pin") } - /// Allow biometric unlock - public static var screenAppLockSettingsEnableBiometricUnlock: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_enable_biometric_unlock") } - /// Allow Face ID - public static var screenAppLockSettingsEnableFaceIdIos: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_enable_face_id_ios") } - /// Allow Optic ID - public static var screenAppLockSettingsEnableOpticIdIos: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_enable_optic_id_ios") } - /// Allow Touch ID - public static var screenAppLockSettingsEnableTouchIdIos: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_enable_touch_id_ios") } - /// Remove PIN - public static var screenAppLockSettingsRemovePin: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_remove_pin") } - /// Are you sure you want to remove PIN? - public static var screenAppLockSettingsRemovePinAlertMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_remove_pin_alert_message") } - /// Remove PIN? - public static var screenAppLockSettingsRemovePinAlertTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_app_lock_settings_remove_pin_alert_title") } - /// %@ is locked - public static func screenAppLockTitle(_ p1: Any) -> String { - return UntranslatedL10n.tr("Untranslated", "screen_app_lock_title", String(describing: p1)) - } /// Clear all data currently stored on this device? /// Sign in again to access your account data and messages. public static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") } diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 9f9631653d..e568eb3600 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -12,6 +12,10 @@ import Foundation public enum L10n { /// Delete public static var a11yDelete: String { return L10n.tr("Localizable", "a11y_delete") } + /// Plural format key: "%#@COUNT@" + public static func a11yDigitsEntered(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y_digits_entered", p1) + } /// Hide password public static var a11yHidePassword: String { return L10n.tr("Localizable", "a11y_hide_password") } /// Mentions only @@ -20,6 +24,8 @@ public enum L10n { public static var a11yNotificationsMuted: String { return L10n.tr("Localizable", "a11y_notifications_muted") } /// Pause public static var a11yPause: String { return L10n.tr("Localizable", "a11y_pause") } + /// PIN field + public static var a11yPinField: String { return L10n.tr("Localizable", "a11y_pin_field") } /// Play public static var a11yPlay: String { return L10n.tr("Localizable", "a11y_play") } /// Poll @@ -206,6 +212,8 @@ public enum L10n { } /// Encryption enabled public static var commonEncryptionEnabled: String { return L10n.tr("Localizable", "common_encryption_enabled") } + /// Enter your PIN + public static var commonEnterYourPin: String { return L10n.tr("Localizable", "common_enter_your_pin") } /// Error public static var commonError: String { return L10n.tr("Localizable", "common_error") } /// Everyone @@ -296,6 +304,8 @@ public enum L10n { public static var commonRoomName: String { return L10n.tr("Localizable", "common_room_name") } /// e.g. your project name public static var commonRoomNamePlaceholder: String { return L10n.tr("Localizable", "common_room_name_placeholder") } + /// Screen lock + public static var commonScreenLock: String { return L10n.tr("Localizable", "common_screen_lock") } /// Search for someone public static var commonSearchForSomeone: String { return L10n.tr("Localizable", "common_search_for_someone") } /// Search results @@ -338,6 +348,8 @@ public enum L10n { public static var commonUnableToInviteMessage: String { return L10n.tr("Localizable", "common_unable_to_invite_message") } /// Unable to send invite(s) public static var commonUnableToInviteTitle: String { return L10n.tr("Localizable", "common_unable_to_invite_title") } + /// Unlock + public static var commonUnlock: String { return L10n.tr("Localizable", "common_unlock") } /// Unmute public static var commonUnmute: String { return L10n.tr("Localizable", "common_unmute") } /// Unsupported event @@ -634,6 +646,34 @@ public enum L10n { public static var screenAnalyticsSettingsReadTermsContentLink: String { return L10n.tr("Localizable", "screen_analytics_settings_read_terms_content_link") } /// Share analytics data public static var screenAnalyticsSettingsShareData: String { return L10n.tr("Localizable", "screen_analytics_settings_share_data") } + /// Forgot PIN? + public static var screenAppLockForgotPin: String { return L10n.tr("Localizable", "screen_app_lock_forgot_pin") } + /// Change PIN code + public static var screenAppLockSettingsChangePin: String { return L10n.tr("Localizable", "screen_app_lock_settings_change_pin") } + /// Allow biometric unlock + public static var screenAppLockSettingsEnableBiometricUnlock: String { return L10n.tr("Localizable", "screen_app_lock_settings_enable_biometric_unlock") } + /// Allow Face ID + public static var screenAppLockSettingsEnableFaceIdIos: String { return L10n.tr("Localizable", "screen_app_lock_settings_enable_face_id_ios") } + /// Allow Optic ID + public static var screenAppLockSettingsEnableOpticIdIos: String { return L10n.tr("Localizable", "screen_app_lock_settings_enable_optic_id_ios") } + /// Allow Touch ID + public static var screenAppLockSettingsEnableTouchIdIos: String { return L10n.tr("Localizable", "screen_app_lock_settings_enable_touch_id_ios") } + /// Remove PIN + public static var screenAppLockSettingsRemovePin: String { return L10n.tr("Localizable", "screen_app_lock_settings_remove_pin") } + /// Are you sure you want to remove PIN? + public static var screenAppLockSettingsRemovePinAlertMessage: String { return L10n.tr("Localizable", "screen_app_lock_settings_remove_pin_alert_message") } + /// Remove PIN? + public static var screenAppLockSettingsRemovePinAlertTitle: String { return L10n.tr("Localizable", "screen_app_lock_settings_remove_pin_alert_title") } + /// You’ll need to re-login and create a new PIN to proceed + public static var screenAppLockSignoutAlertMessage: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_message") } + /// You are being signed out + public static var screenAppLockSignoutAlertTitle: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_title") } + /// You have 3 attempts to unlock + public static var screenAppLockSubtitle: String { return L10n.tr("Localizable", "screen_app_lock_subtitle") } + /// Plural format key: "%#@COUNT@" + public static func screenAppLockSubtitleWrongPin(_ p1: Int) -> String { + return L10n.tr("Localizable", "screen_app_lock_subtitle_wrong_pin", p1) + } /// Attach screenshot public static var screenBugReportAttachScreenshot: String { return L10n.tr("Localizable", "screen_bug_report_attach_screenshot") } /// You may contact me if you have any follow up questions. diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index f16d57e4ae..41a0f64eac 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -173,6 +173,7 @@ struct A11yIdentifiers { let secureBackup = "settings-secure_backup" let notifications = "settings-notifications" let analytics = "settings-analytics" + let screenLock = "settings-screen_lock" let reportBug = "settings-report_bug" let about = "settings_about" let advancedSettings = "settings_advanced-settings" diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift index 16f2a5f08d..63e34ab901 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift @@ -22,19 +22,45 @@ enum AppLockScreenViewModelAction { } struct AppLockScreenViewState: BindableState { + private let maximumAttempts = 3 + + /// The number of times the user attempted to enter their PIN. + var numberOfPINAttempts = 0 + var bindings: AppLockScreenViewStateBindings + + /// The number of digits the user has entered so far. + var numberOfDigitsEntered: Int { bindings.pinCode.count } + /// Whether the subtitle is in a warning state or not. + var isSubtitleWarning: Bool { numberOfPINAttempts > 0 } + /// The string shown in the screen's subtitle. + var subtitle: String { + if !isSubtitleWarning { + return L10n.screenAppLockSubtitle + } else { + return L10n.screenAppLockSubtitleWrongPin(maximumAttempts - numberOfPINAttempts) + } + } } -struct AppLockScreenViewStateBindings { } +struct AppLockScreenViewStateBindings { + /// The PIN code entered by the user. + var pinCode = "" + var alertInfo: AlertInfo? +} -enum AppLockScreenViewAction: CustomStringConvertible { +enum AppLockScreenAlertType { + /// The user has failed too many times, they're being signed out. + case forceSignOut + /// The user has forgotten their PIN, confirm they're happy to sign out. + case confirmResetPIN +} + +enum AppLockScreenViewAction { /// Attempt to unlock the app with the supplied PIN code. - case submitPINCode(String) - - var description: String { - switch self { - case .submitPINCode: - return "submitPINCode" - } - } + case submitPINCode + /// Clears the PIN code after a failure animation. + case clearPINCode + /// The user didn't heed the warnings and can't remember their PIN. + case forgotPIN } diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift index 732a32debf..001c7e1a76 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift @@ -39,13 +39,43 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel MXLog.info("View model: received view action: \(viewAction)") switch viewAction { - case .submitPINCode(let pinCode): - guard appLockService.unlock(with: pinCode) else { - MXLog.warning("Invalid PIN code entered.") - // Indicate failure here. + case .submitPINCode: + guard appLockService.unlock(with: state.bindings.pinCode) else { + handleInvalidPIN() return } actionsSubject.send(.appUnlocked) + case .clearPINCode: + state.bindings.pinCode = "" + case .forgotPIN: + handleForgotPIN() } } + + // MARK: - Private + + private func handleForgotPIN() { + state.bindings.alertInfo = .init(id: .confirmResetPIN, + title: L10n.screenAppLockSignoutAlertTitle, + message: L10n.screenAppLockSignoutAlertMessage, + primaryButton: .init(title: L10n.actionOk, action: forceSignOut), + 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() + } + } + + private func forceSignOut() { + // To be implemented. + } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreen.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreen.swift index d96f723f08..2582ba679d 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreen.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreen.swift @@ -17,36 +17,107 @@ import Compound import SwiftUI -// This implementation is only for development purposes. - struct AppLockScreen: View { @ObservedObject var context: AppLockScreenViewModel.Context + /// The size of each dot within the PIN input field. + @ScaledMetric private var pinDotSize = 14 + /// Used to animate the PIN input field on failure. + @State private var pinInputFieldOffset = 0.0 + + /// A focus state to highlight a failed PIN entry in VoiceOver. + @AccessibilityFocusState private var accessibilitySubtitleFocus: Bool + + var subtitleColor: Color { + context.viewState.isSubtitleWarning ? .compound.textCriticalPrimary : .compound.textPrimary + } + var body: some View { FullscreenDialog { - VStack(spacing: 8) { - HeroImage(image: Image(systemSymbol: .lock)) - .symbolVariant(.fill) - .padding(.bottom, 8) + VStack(spacing: 32) { + header + + pinInputField + .padding(.bottom, 16) + .offset(x: pinInputFieldOffset) + .onChange(of: context.viewState.numberOfPINAttempts) { newValue in + guard newValue > 0 else { return } // Reset without animation in Previews. + accessibilitySubtitleFocus = true + Task { await animatePINFailure() } + } + .accessibilityLabel(L10n.a11yPinField) + .accessibilityValue(L10n.a11yDigitsEntered(context.viewState.numberOfDigitsEntered)) - Text(UntranslatedL10n.screenAppLockTitle(InfoPlistReader.main.bundleDisplayName)) - .font(.compound.headingMDBold) - .multilineTextAlignment(.center) - .foregroundColor(.compound.textPrimary) + AppLockScreenPINKeypad(pinCode: $context.pinCode) + .onChange(of: context.pinCode) { newValue in + guard newValue.count == 4 else { return } + context.send(viewAction: .submitPINCode) + } } } bottomContent: { - Button(UntranslatedL10n.commonUnlock) { - context.send(viewAction: .submitPINCode("0000")) + Button(L10n.screenAppLockForgotPin) { + context.send(viewAction: .forgotPIN) } - .buttonStyle(.compound(.primary)) + .font(.compound.bodyMDSemibold) + } + .alert(item: $context.alertInfo) + } + + var header: some View { + VStack(spacing: 8) { + CompoundIcon(\.lock, size: .medium, relativeTo: .compound.headingMDBold) + .padding(.bottom, 8) + .accessibilityHidden(true) + + Text(L10n.commonEnterYourPin) + .font(.compound.headingMDBold) + .foregroundColor(.compound.textPrimary) + .multilineTextAlignment(.center) + + Text(context.viewState.subtitle) + .font(.compound.bodyMD) + .foregroundColor(subtitleColor) + .multilineTextAlignment(.center) + .accessibilityFocused($accessibilitySubtitleFocus) + } + } + + /// The row of dots showing how many digits have been entered. + var pinInputField: some View { + HStack(spacing: 24) { + Circle() + .fill(context.viewState.numberOfDigitsEntered > 0 ? .compound.iconPrimary : .compound.bgSubtlePrimary) + .frame(width: pinDotSize, height: pinDotSize) + Circle() + .fill(context.viewState.numberOfDigitsEntered > 1 ? .compound.iconPrimary : .compound.bgSubtlePrimary) + .frame(width: pinDotSize, height: pinDotSize) + Circle() + .fill(context.viewState.numberOfDigitsEntered > 2 ? .compound.iconPrimary : .compound.bgSubtlePrimary) + .frame(width: pinDotSize, height: pinDotSize) + Circle() + .fill(context.viewState.numberOfDigitsEntered > 3 ? .compound.iconPrimary : .compound.bgSubtlePrimary) + .frame(width: pinDotSize, height: pinDotSize) + } + } + + func animatePINFailure() async { + withAnimation(.spring(response: 0, dampingFraction: 0.7, blendDuration: 0.0)) { + pinInputFieldOffset = 15 + } + + try? await Task.sleep(for: .milliseconds(50)) + withAnimation(.spring(response: 0.1, dampingFraction: 0.3, blendDuration: 0.1)) { + pinInputFieldOffset = 0 } + + try? await Task.sleep(for: .milliseconds(100)) + context.send(viewAction: .clearPINCode) } } // MARK: - Previews -// Add TestablePreview conformance once we have designs. -struct AppLockScreen_Previews: PreviewProvider { +struct AppLockScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = AppLockScreenViewModel(appLockService: AppLockServiceMock.mock()) static var previews: some View { diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreenPINKeypad.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreenPINKeypad.swift new file mode 100644 index 0000000000..aa6b0aceb5 --- /dev/null +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/View/AppLockScreenPINKeypad.swift @@ -0,0 +1,99 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// The custom keypad shown on the App Lock screen when biometrics are disabled. +struct AppLockScreenPINKeypad: View { + @Binding var pinCode: String + + var body: some View { + Grid(horizontalSpacing: 24, verticalSpacing: 16) { + ForEach(0..<3) { row in + GridRow { + ForEach(1..<4) { column in + let digit = (3 * row) + column + Button("\(digit)") { press(digit) } + } + } + } + GridRow { + Button("") { }.hidden() + Button("0") { press(0) } + Button(action: pressDelete) { + Image(systemSymbol: .deleteBackward) + .symbolVariant(.fill) + .symbolRenderingMode(.palette) + .foregroundStyle(.compound.textPrimary, .compound.bgSubtlePrimary) + } + .buttonStyle(KeypadButtonStyle(isSolid: false)) + } + } + .buttonStyle(KeypadButtonStyle()) + } + + func press(_ digit: Int) { + guard pinCode.count < 4 else { return } + UIDevice.current.playInputClick() + pinCode.append("\(digit)") + } + + func pressDelete() { + guard !pinCode.isEmpty else { return } + withElementAnimation { _ = pinCode.removeLast() } + } +} + +private struct KeypadButtonStyle: ButtonStyle { + var isSolid = true + + func makeBody(configuration: Configuration) -> some View { + Circle() + .fill(isSolid ? .compound.bgSubtlePrimary : .clear) + .frame(width: 80, height: 80) + .overlay { + configuration.label + .font(.compound.headingXLBold) + .foregroundColor(.compound.textPrimary) + } + .opacity(configuration.isPressed ? 0.3 : 1.0) + } +} + +// MARK: - Previews + +struct AppLockScreenPINKeypad_Previews: PreviewProvider { + static var previews: some View { + KeypadTestView() + } + + struct KeypadTestView: View { + @StateObject var model = PreviewModel() + class PreviewModel: ObservableObject { + @Published var pinCode = "" + var output: String { pinCode.isEmpty ? "Enter code" : pinCode } + } + + var body: some View { + VStack(spacing: 32) { + Text(model.output) + .font(.compound.headingMD) + .animation(.noAnimation, value: model.pinCode) + AppLockScreenPINKeypad(pinCode: $model.pinCode) + } + } + } +} diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenModels.swift index 14a80a5aee..911269a537 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenModels.swift @@ -33,14 +33,14 @@ struct AppLockSettingsScreenViewState: BindableState { case .none: return L10n.commonError case .touchID: - return UntranslatedL10n.screenAppLockSettingsEnableTouchIdIos + return L10n.screenAppLockSettingsEnableTouchIdIos case .faceID: - return UntranslatedL10n.screenAppLockSettingsEnableFaceIdIos + return L10n.screenAppLockSettingsEnableFaceIdIos // Requires Xcode 15: // case .opticID: - // UntranslatedL10n.screenAppLockSettingsEnableOpticIdIos + // L10n.screenAppLockSettingsEnableOpticIdIos @unknown default: - return UntranslatedL10n.screenAppLockSettingsEnableBiometricUnlock + return L10n.screenAppLockSettingsEnableBiometricUnlock } } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift index 16b3be5326..0c5c854e7f 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift @@ -55,8 +55,8 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc /// Shows a confirmation alert to the user before removing their PIN code. private func showRemovePINAlert() { state.bindings.alertInfo = .init(id: .confirmRemovePINCode, - title: UntranslatedL10n.screenAppLockSettingsRemovePinAlertTitle, - message: UntranslatedL10n.screenAppLockSettingsRemovePinAlertMessage, + title: L10n.screenAppLockSettingsRemovePinAlertTitle, + message: L10n.screenAppLockSettingsRemovePinAlertMessage, primaryButton: .init(title: L10n.actionYes) { self.completeRemovePIN() }, secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/View/AppLockSettingsScreen.swift b/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/View/AppLockSettingsScreen.swift index a1bac2d678..f5e4c2e64f 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/View/AppLockSettingsScreen.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/View/AppLockSettingsScreen.swift @@ -23,9 +23,9 @@ struct AppLockSettingsScreen: View { var body: some View { Form { Section { - ListRow(label: .plain(title: UntranslatedL10n.screenAppLockSettingsChangePin), + ListRow(label: .plain(title: L10n.screenAppLockSettingsChangePin), kind: .button { context.send(viewAction: .changePINCode) }) - ListRow(label: .plain(title: UntranslatedL10n.screenAppLockSettingsRemovePin, role: .destructive), + ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive), kind: .button { context.send(viewAction: .disable) }) } @@ -40,7 +40,7 @@ struct AppLockSettingsScreen: View { } } .compoundList() - .navigationTitle(UntranslatedL10n.commonScreenLock) + .navigationTitle(L10n.commonScreenLock) .navigationBarTitleDisplayMode(.inline) .alert(item: $context.alertInfo) } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index a8bf67ade1..e853a512f6 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -128,12 +128,12 @@ struct SettingsScreen: View { .accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics) if context.viewState.showAppLockSettings { - ListRow(label: .default(title: UntranslatedL10n.commonScreenLock, + ListRow(label: .default(title: L10n.commonScreenLock, systemIcon: .lock), kind: .navigationLink { context.send(viewAction: .appLock) }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.screenLock) } ListRow(label: .default(title: L10n.commonReportABug, diff --git a/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift index 4c77886585..db146a4a60 100644 --- a/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift @@ -44,7 +44,8 @@ class AppLockScreenViewModelTests: XCTestCase { // When entering it on the lock screen. let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked } - context.send(viewAction: .submitPINCode(pinCode)) + viewModel.context.pinCode = pinCode + context.send(viewAction: .submitPINCode) let result = try await deferred.fulfill() // The app should become unlocked. diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockScreen.1.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockScreen.1.png new file mode 100644 index 0000000000..d3790bb9ac --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockScreen.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d8b859217f475cd3477e96e8f501662d7c288d731800112d97af0d8c7718273 +size 129515 diff --git a/changelog.d/pr-1925.wip b/changelog.d/pr-1925.wip new file mode 100644 index 0000000000..edea689323 --- /dev/null +++ b/changelog.d/pr-1925.wip @@ -0,0 +1 @@ +Implement the AppLockScreen as per the designs. \ No newline at end of file