diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_password_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_password_icon.imageset/Contents.json new file mode 100644 index 000000000..63dee5c49 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_password_icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "authentication_password_icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_password_icon.imageset/authentication_password_icon.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_password_icon.imageset/authentication_password_icon.svg new file mode 100644 index 000000000..1009c3175 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_password_icon.imageset/authentication_password_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index a85d5223a..5727d988d 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -50,6 +50,20 @@ "authentication_verify_email_waiting_hint" = "Did not receive an email?"; "authentication_verify_email_waiting_button" = "Resend email"; +"authentication_forgot_password_input_title" = "Enter your email address"; +"authentication_forgot_password_input_message" = "We will send you a verification link."; +"authentication_forgot_password_text_field_placeholder" = "Email Address"; +"authentication_forgot_password_waiting_title" = "Check your email"; +"authentication_forgot_password_waiting_message" = "To confirm your email address, tap the button in the email we just sent to %@"; +"authentication_forgot_password_waiting_hint" = "Did not receive an email?"; +"authentication_forgot_password_waiting_button" = "Resend email"; + +"authentication_choose_password_input_title" = "Choose a new password"; +"authentication_choose_password_input_message" = "Make sure it’s 8 characters or more."; +"authentication_choose_password_text_field_placeholder" = "New Password"; +"authentication_choose_password_signout_all_devices" = "Sign out of all devices"; +"authentication_choose_password_submit_button" = "Reset Password"; + "authentication_verify_msisdn_input_title" = "Enter your phone number"; "authentication_verify_msisdn_input_message" = "This will help verify your account and enables password recovery."; "authentication_verify_msisdn_text_field_placeholder" = "Phone Number"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 485d97ec3..a7dd8ed57 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -32,6 +32,7 @@ internal class Asset: NSObject { internal static let socialLoginButtonTwitter = ImageAsset(name: "social_login_button_twitter") internal static let authenticationEmailIcon = ImageAsset(name: "authentication_email_icon") internal static let authenticationMsisdnIcon = ImageAsset(name: "authentication_msisdn_icon") + internal static let authenticationPasswordIcon = ImageAsset(name: "authentication_password_icon") internal static let authenticationServerSelectionIcon = ImageAsset(name: "authentication_server_selection_icon") internal static let authenticationSsoIconApple = ImageAsset(name: "authentication_sso_icon_apple") internal static let authenticationSsoIconFacebook = ImageAsset(name: "authentication_sso_icon_facebook") diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index cdad0d276..7d0c6ff7c 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -14,6 +14,54 @@ public extension VectorL10n { static var authenticationCancelFlowConfirmationMessage: String { return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message") } + /// Make sure it’s 8 characters or more. + static var authenticationChoosePasswordInputMessage: String { + return VectorL10n.tr("Untranslated", "authentication_choose_password_input_message") + } + /// Choose a new password + static var authenticationChoosePasswordInputTitle: String { + return VectorL10n.tr("Untranslated", "authentication_choose_password_input_title") + } + /// Sign out of all devices + static var authenticationChoosePasswordSignoutAllDevices: String { + return VectorL10n.tr("Untranslated", "authentication_choose_password_signout_all_devices") + } + /// Reset Password + static var authenticationChoosePasswordSubmitButton: String { + return VectorL10n.tr("Untranslated", "authentication_choose_password_submit_button") + } + /// New Password + static var authenticationChoosePasswordTextFieldPlaceholder: String { + return VectorL10n.tr("Untranslated", "authentication_choose_password_text_field_placeholder") + } + /// We will send you a verification link. + static var authenticationForgotPasswordInputMessage: String { + return VectorL10n.tr("Untranslated", "authentication_forgot_password_input_message") + } + /// Enter your email address + static var authenticationForgotPasswordInputTitle: String { + return VectorL10n.tr("Untranslated", "authentication_forgot_password_input_title") + } + /// Email Address + static var authenticationForgotPasswordTextFieldPlaceholder: String { + return VectorL10n.tr("Untranslated", "authentication_forgot_password_text_field_placeholder") + } + /// Resend email + static var authenticationForgotPasswordWaitingButton: String { + return VectorL10n.tr("Untranslated", "authentication_forgot_password_waiting_button") + } + /// Did not receive an email? + static var authenticationForgotPasswordWaitingHint: String { + return VectorL10n.tr("Untranslated", "authentication_forgot_password_waiting_hint") + } + /// To confirm your email address, tap the button in the email we just sent to %@ + static func authenticationForgotPasswordWaitingMessage(_ p1: String) -> String { + return VectorL10n.tr("Untranslated", "authentication_forgot_password_waiting_message", p1) + } + /// Check your email + static var authenticationForgotPasswordWaitingTitle: String { + return VectorL10n.tr("Untranslated", "authentication_forgot_password_waiting_title") + } /// Forgot password static var authenticationLoginForgotPassword: String { return VectorL10n.tr("Untranslated", "authentication_login_forgot_password") diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordModels.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordModels.swift new file mode 100644 index 000000000..4b8d2752a --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordModels.swift @@ -0,0 +1,63 @@ +// +// Copyright 2021 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 + +// MARK: View model + +enum AuthenticationChoosePasswordViewModelResult { + /// Submit with password and sign out of all devices option + case submit(String, Bool) + /// Cancel the flow. + case cancel +} + +// MARK: View + +struct AuthenticationChoosePasswordViewState: BindableState { + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationChoosePasswordBindings + + /// Whether the password is valid and the user can continue. + var hasInvalidPassword: Bool { + bindings.password.count < 8 + } +} + +struct AuthenticationChoosePasswordBindings { + /// The password input by the user. + var password: String + /// The signout all devices checkbox status + var signoutAllDevices: Bool + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum AuthenticationChoosePasswordViewAction { + /// Send an email to the entered address. + case submit + /// Toggle sign out of all devices + case toggleSignoutAllDevices + /// Cancel the flow. + case cancel +} + +enum AuthenticationChoosePasswordErrorType: Hashable { + /// An error response from the homeserver. + case mxError(String) + /// An unknown error occurred. + case unknown +} diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordViewModel.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordViewModel.swift new file mode 100644 index 000000000..97dc9259d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordViewModel.swift @@ -0,0 +1,62 @@ +// +// Copyright 2021 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 + +typealias AuthenticationChoosePasswordViewModelType = StateStoreViewModel +class AuthenticationChoosePasswordViewModel: AuthenticationChoosePasswordViewModelType, AuthenticationChoosePasswordViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var callback: (@MainActor (AuthenticationChoosePasswordViewModelResult) -> Void)? + + // MARK: - Setup + + init(password: String = "", signoutAllDevices: Bool = false) { + let viewState = AuthenticationChoosePasswordViewState(bindings: AuthenticationChoosePasswordBindings(password: password, signoutAllDevices: signoutAllDevices)) + super.init(initialViewState: viewState) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationChoosePasswordViewAction) { + switch viewAction { + case .submit: + Task { await callback?(.submit(state.bindings.password, state.bindings.signoutAllDevices)) } + case .toggleSignoutAllDevices: + state.bindings.signoutAllDevices.toggle() + case .cancel: + Task { await callback?(.cancel) } + } + } + + @MainActor func displayError(_ type: AuthenticationChoosePasswordErrorType) { + switch type { + case .mxError(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: message) + case .unknown: + state.bindings.alertInfo = AlertInfo(id: type) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordViewModelProtocol.swift new file mode 100644 index 000000000..47c57304e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/AuthenticationChoosePasswordViewModelProtocol.swift @@ -0,0 +1,26 @@ +// +// Copyright 2021 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 Foundation + +protocol AuthenticationChoosePasswordViewModelProtocol { + + var callback: (@MainActor (AuthenticationChoosePasswordViewModelResult) -> Void)? { get set } + var context: AuthenticationChoosePasswordViewModelType.Context { get } + + /// Display an error to the user. + @MainActor func displayError(_ type: AuthenticationChoosePasswordErrorType) +} diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/Coordinator/AuthenticationChoosePasswordCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/Coordinator/AuthenticationChoosePasswordCoordinator.swift new file mode 100644 index 000000000..d1a0e40a3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/Coordinator/AuthenticationChoosePasswordCoordinator.swift @@ -0,0 +1,148 @@ +// +// Copyright 2021 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 +import CommonKit + +struct AuthenticationChoosePasswordCoordinatorParameters { + let loginWizard: LoginWizard +} + +enum AuthenticationChoosePasswordCoordinatorResult { + /// Show the display name and/or avatar screens for the user to personalize their profile. + case success + /// Continue the flow by skipping the display name and avatar screens. + case cancel +} + +@available(iOS 14.0, *) +final class AuthenticationChoosePasswordCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationChoosePasswordCoordinatorParameters + private let authenticationChoosePasswordHostingController: VectorHostingController + private var authenticationChoosePasswordViewModel: AuthenticationChoosePasswordViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + /// The wizard used to handle the registration flow. + private var loginWizard: LoginWizard { parameters.loginWizard } + + private var currentTask: Task? { + willSet { + currentTask?.cancel() + } + } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: (@MainActor (AuthenticationChoosePasswordCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationChoosePasswordCoordinatorParameters) { + self.parameters = parameters + + let viewModel = AuthenticationChoosePasswordViewModel() + let view = AuthenticationChoosePasswordScreen(viewModel: viewModel.context) + authenticationChoosePasswordViewModel = viewModel + authenticationChoosePasswordHostingController = VectorHostingController(rootView: view) + authenticationChoosePasswordHostingController.vc_removeBackTitle() + authenticationChoosePasswordHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationChoosePasswordHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationChoosePasswordCoordinator] did start.") + Task { await setupViewModel() } + } + + func toPresentable() -> UIViewController { + return self.authenticationChoosePasswordHostingController + } + + // MARK: - Private + + /// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`. + @MainActor private func setupViewModel() { + authenticationChoosePasswordViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationChoosePasswordCoordinator] AuthenticationChoosePasswordViewModel did complete with result: \(result).") + + switch result { + case .submit(let password, let signoutAllDevices): + self.submitPassword(password, signoutAllDevices: signoutAllDevices) + case .cancel: + self.callback?(.cancel) + } + } + } + + /// Show an activity indicator whilst loading. + @MainActor private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + loadingIndicator = nil + } + + /// Submits a reset password request with signing out of all devices option + @MainActor private func submitPassword(_ password: String, signoutAllDevices: Bool) { + startLoading() + + currentTask = Task { [weak self] in + do { + try await loginWizard.resetPasswordMailConfirmed(newPassword: password, + signoutAllDevices: signoutAllDevices) + + // Shouldn't be reachable but just in case, continue the flow. + + guard !Task.isCancelled else { return } + + self?.stopLoading() + self?.callback?(.success) + } catch is CancellationError { + return + } catch { + self?.stopLoading() + self?.handleError(error) + } + } + } + + /// Processes an error to either update the flow or display it to the user. + @MainActor private func handleError(_ error: Error) { + if let mxError = MXError(nsError: error as NSError) { + authenticationChoosePasswordViewModel.displayError(.mxError(mxError.error)) + return + } + + // TODO: Handle another other error types as needed. + + authenticationChoosePasswordViewModel.displayError(.unknown) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/MockAuthenticationChoosePasswordScreenState.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/MockAuthenticationChoosePasswordScreenState.swift new file mode 100644 index 000000000..da5df989a --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/MockAuthenticationChoosePasswordScreenState.swift @@ -0,0 +1,57 @@ +// +// Copyright 2021 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 Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockAuthenticationChoosePasswordScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case emptyPassword + case enteredInvalidPassword + case enteredValidPassword + case enteredValidPasswordAndSignoutAllDevicesChecked + + /// The associated screen + var screenType: Any.Type { + AuthenticationChoosePasswordScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationChoosePasswordViewModel + switch self { + case .emptyPassword: + viewModel = AuthenticationChoosePasswordViewModel() + case .enteredInvalidPassword: + viewModel = AuthenticationChoosePasswordViewModel(password: "1234") + case .enteredValidPassword: + viewModel = AuthenticationChoosePasswordViewModel(password: "12345678") + case .enteredValidPasswordAndSignoutAllDevicesChecked: + viewModel = AuthenticationChoosePasswordViewModel(password: "12345678", signoutAllDevices: true) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationChoosePasswordScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/Test/UI/AuthenticationChoosePasswordUITests.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/Test/UI/AuthenticationChoosePasswordUITests.swift new file mode 100644 index 000000000..c4e7f564a --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/Test/UI/AuthenticationChoosePasswordUITests.swift @@ -0,0 +1,118 @@ +// +// Copyright 2021 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 XCTest +import RiotSwiftUI + +class AuthenticationChoosePasswordUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationChoosePasswordScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationChoosePasswordUITests(selector: #selector(verifyAuthenticationChoosePasswordScreen)) + } + + func verifyAuthenticationChoosePasswordScreen() throws { + guard let screenState = screenState as? MockAuthenticationChoosePasswordScreenState else { fatalError("no screen") } + switch screenState { + case .emptyPassword: + verifyEmptyPassword() + case .enteredInvalidPassword: + verifyEnteredInvalidPassword() + case .enteredValidPassword: + verifyEnteredValidPassword() + case .enteredValidPasswordAndSignoutAllDevicesChecked: + verifyEnteredValidPasswordAndSignoutAllDevicesChecked() + } + } + + func verifyEmptyPassword() { + XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.") + XCTAssertTrue(app.staticTexts["messageLabel"].exists, "The message should be shown.") + + let passwordTextField = app.secureTextFields["passwordTextField"] + XCTAssertTrue(passwordTextField.exists, "The text field should be shown.") + XCTAssertEqual(passwordTextField.label, "New Password", "The text field should be showing the placeholder before text is input.") + + let submitButton = app.buttons["submitButton"] + XCTAssertTrue(submitButton.exists, "The submit button should be shown.") + XCTAssertFalse(submitButton.isEnabled, "The submit button should be disabled before text is input.") + + let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"] + XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist") + XCTAssertFalse(signoutAllDevicesToggle.isOn, "Sign out all devices should be unchecked") + } + + func verifyEnteredInvalidPassword() { + XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.") + XCTAssertTrue(app.staticTexts["messageLabel"].exists, "The message should be shown.") + + let passwordTextField = app.secureTextFields["passwordTextField"] + XCTAssertTrue(passwordTextField.exists, "The text field should be shown.") + XCTAssertEqual(passwordTextField.value as? String, "••••", "The text field should be showing the placeholder before text is input.") + + let submitButton = app.buttons["submitButton"] + XCTAssertTrue(submitButton.exists, "The submit button should be shown.") + XCTAssertFalse(submitButton.isEnabled, "The submit button should be disabled when password is invalid.") + + let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"] + XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist") + XCTAssertFalse(signoutAllDevicesToggle.isOn, "Sign out all devices should be unchecked") + } + + func verifyEnteredValidPassword() { + XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.") + XCTAssertTrue(app.staticTexts["messageLabel"].exists, "The message should be shown.") + + let passwordTextField = app.secureTextFields["passwordTextField"] + XCTAssertTrue(passwordTextField.exists, "The text field should be shown.") + XCTAssertEqual(passwordTextField.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.") + + let submitButton = app.buttons["submitButton"] + XCTAssertTrue(submitButton.exists, "The submit button should be shown.") + XCTAssertTrue(submitButton.isEnabled, "The submit button should be enabled after password is valid.") + + let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"] + XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist") + XCTAssertFalse(signoutAllDevicesToggle.isOn, "Sign out all devices should be unchecked") + } + + func verifyEnteredValidPasswordAndSignoutAllDevicesChecked() { + XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.") + XCTAssertTrue(app.staticTexts["messageLabel"].exists, "The message should be shown.") + + let passwordTextField = app.secureTextFields["passwordTextField"] + XCTAssertTrue(passwordTextField.exists, "The text field should be shown.") + XCTAssertEqual(passwordTextField.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.") + + let submitButton = app.buttons["submitButton"] + XCTAssertTrue(submitButton.exists, "The submit button should be shown.") + XCTAssertTrue(submitButton.isEnabled, "The submit button should be enabled after password is valid.") + + let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"] + XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist") + XCTAssertTrue(signoutAllDevicesToggle.isOn, "Sign out all devices should be checked") + } + +} + +extension XCUIElement { + var isOn: Bool { + (value as? String) == "1" + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/Test/Unit/AuthenticationChoosePasswordViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/Test/Unit/AuthenticationChoosePasswordViewModelTests.swift new file mode 100644 index 000000000..ac9219ea1 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/Test/Unit/AuthenticationChoosePasswordViewModelTests.swift @@ -0,0 +1,33 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationChoosePasswordViewModelTests: XCTestCase { + + @MainActor func testInitialState() async { + let viewModel = AuthenticationChoosePasswordViewModel() + let context = viewModel.context + + // Given a view model where the user hasn't yet sent the verification email. + XCTAssert(context.password.isEmpty, "The view model should start with an empty password.") + XCTAssert(context.viewState.hasInvalidPassword, "The view model should start with an invalid password.") + XCTAssertFalse(context.signoutAllDevices, "The view model should start with sign out of all devices unchecked.") + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/ChoosePassword/View/AuthenticationChoosePasswordScreen.swift b/RiotSwiftUI/Modules/Authentication/ChoosePassword/View/AuthenticationChoosePasswordScreen.swift new file mode 100644 index 000000000..ff76409d6 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ChoosePassword/View/AuthenticationChoosePasswordScreen.swift @@ -0,0 +1,129 @@ +// +// Copyright 2021 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 + +struct AuthenticationChoosePasswordScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + @State private var isEditingTextField = false + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationChoosePasswordViewModel.Context + + // MARK: Views + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + form + } + .readableFrame() + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + .accentColor(theme.colors.accent) + } + + /// The title, message and icon at the top of the screen. + var header: some View { + VStack(spacing: 8) { + OnboardingIconImage(image: Asset.Images.authenticationPasswordIcon) + .padding(.bottom, 8) + + Text(VectorL10n.authenticationChoosePasswordInputTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationChoosePasswordInputMessage) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .accessibilityIdentifier("messageLabel") + } + } + + /// The text field and submit button where the user enters an email address. + var form: some View { + VStack(alignment: .leading, spacing: 12) { + textField + + HStack(alignment: .center, spacing: 8) { + Toggle(VectorL10n.authenticationChoosePasswordSignoutAllDevices, isOn: $viewModel.signoutAllDevices) + .toggleStyle(AuthenticationTermsToggleStyle()) + .accessibilityIdentifier("signoutAllDevicesToggle") + Text(VectorL10n.authenticationChoosePasswordSignoutAllDevices) + .foregroundColor(theme.colors.secondaryContent) + } + .padding(.bottom, 16) + .onTapGesture(perform: toggleSignoutAllDevices) + + Button(action: submit) { + Text(VectorL10n.authenticationChoosePasswordSubmitButton) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.viewState.hasInvalidPassword) + .accessibilityIdentifier("submitButton") + } + } + + /// The text field, extracted for iOS 15 modifiers to be applied. + var textField: some View { + RoundedBorderTextField(placeHolder: VectorL10n.authenticationChoosePasswordTextFieldPlaceholder, + text: $viewModel.password, + isFirstResponder: isEditingTextField, + configuration: UIKitTextInputConfiguration(returnKeyType: .done, + isSecureTextEntry: true), + onCommit: submit) + .accessibilityIdentifier("passwordTextField") + } + + /// Sends the `send` view action so long as a valid email address has been input. + func submit() { + guard !viewModel.viewState.hasInvalidPassword else { return } + viewModel.send(viewAction: .submit) + } + + /// Sends the `toggleSignoutAllDevices` view action. + func toggleSignoutAllDevices() { + viewModel.send(viewAction: .toggleSignoutAllDevices) + } +} + +// MARK: - Previews + +struct AuthenticationChoosePasswordScreen_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationChoosePasswordScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift index eef20e50d..6af694b5e 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift @@ -89,7 +89,6 @@ enum LoginMode { /// Data obtained when calling `LoginWizard.resetPassword` that will be used /// when calling `LoginWizard.checkResetPasswordMailConfirmed`. struct ResetPasswordData { - let newPassword: String let addThreePIDSessionID: String } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginParameters.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginParameters.swift index 15ef8e84b..4e4bacb15 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginParameters.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginParameters.swift @@ -100,14 +100,18 @@ struct CheckResetPasswordParameters: DictionaryEncodable { let auth: AuthenticationParameters /// The new password let newPassword: String + /// The sign out of all devices flag + let signoutAllDevices: Bool enum CodingKeys: String, CodingKey { case auth case newPassword = "new_password" + case signoutAllDevices = "logout_devices" } - init(clientSecret: String, sessionID: String, newPassword: String) { + init(clientSecret: String, sessionID: String, newPassword: String, signoutAllDevices: Bool) { self.auth = AuthenticationParameters.resetPasswordParameters(clientSecret: clientSecret, sessionID: sessionID) self.newPassword = newPassword + self.signoutAllDevices = signoutAllDevices } } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift index cdc64c70e..1fbd0191b 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift @@ -87,24 +87,26 @@ class LoginWizard { // func loginCustom(data: Codable) async -> MXSession { // // } - + /// Ask the homeserver to reset the user password. The password will not be - /// reset until `checkResetPasswordMailConfirmed` is successfully called. + /// reset until `resetPasswordMailConfirmed` is successfully called. /// - Parameters: /// - email: An email previously associated to the account the user wants the password to be reset. - /// - newPassword: The desired new password - func resetPassword(email: String, newPassword: String) async throws { + func resetPassword(email: String) async throws { let result = try await client.forgetPassword(for: email, clientSecret: state.clientSecret, sendAttempt: state.sendAttempt) state.sendAttempt += 1 - state.resetPasswordData = ResetPasswordData(newPassword: newPassword, addThreePIDSessionID: result) + state.resetPasswordData = ResetPasswordData(addThreePIDSessionID: result) } - + /// Confirm the new password, once the user has checked their email. /// When this method succeeds, the account password will be effectively modified. - func checkResetPasswordMailConfirmed() async throws { + /// - Parameters: + /// - newPassword: The desired new password + /// - signoutAllDevices: The flag to sign out of all devices + func resetPasswordMailConfirmed(newPassword: String, signoutAllDevices: Bool) async throws { guard let resetPasswordData = state.resetPasswordData else { MXLog.error("[LoginWizard] resetPasswordMailConfirmed: Reset password data missing. Call resetPassword first.") throw LoginError.resetPasswordNotStarted @@ -112,7 +114,8 @@ class LoginWizard { let parameters = CheckResetPasswordParameters(clientSecret: state.clientSecret, sessionID: resetPasswordData.addThreePIDSessionID, - newPassword: resetPasswordData.newPassword) + newPassword: newPassword, + signoutAllDevices: signoutAllDevices) try await client.resetPassword(parameters: parameters) diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordModels.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordModels.swift new file mode 100644 index 000000000..1c7f0a388 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordModels.swift @@ -0,0 +1,71 @@ +// +// Copyright 2021 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 + +// MARK: View model + +enum AuthenticationForgotPasswordViewModelResult { + /// Send an email to the associated address. + case send(String) + /// Cancel the flow. + case cancel + /// Email validation is done + case done + /// Go back to the email form + case goBack +} + +// MARK: View + +struct AuthenticationForgotPasswordViewState: BindableState { + /// An email has been sent and the app is waiting for the user to tap the link. + var hasSentEmail = false + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationForgotPasswordBindings + + /// Whether the email address is valid and the user can continue. + var hasInvalidAddress: Bool { + bindings.emailAddress.isEmpty + } +} + +struct AuthenticationForgotPasswordBindings { + /// The email address input by the user. + var emailAddress: String + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum AuthenticationForgotPasswordViewAction { + /// Send an email to the entered address. + case send + /// Send the email once more. + case resend + /// Email validation is done + case done + /// Cancel the flow. + case cancel + /// Go back to enter email adress screen + case goBack +} + +enum AuthenticationForgotPasswordErrorType: Hashable { + /// An error response from the homeserver. + case mxError(String) + /// An unknown error occurred. + case unknown +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordViewModel.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordViewModel.swift new file mode 100644 index 000000000..c2966c4ab --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordViewModel.swift @@ -0,0 +1,74 @@ +// +// Copyright 2021 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 + +typealias AuthenticationForgotPasswordViewModelType = StateStoreViewModel +class AuthenticationForgotPasswordViewModel: AuthenticationForgotPasswordViewModelType, AuthenticationForgotPasswordViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var callback: (@MainActor (AuthenticationForgotPasswordViewModelResult) -> Void)? + + // MARK: - Setup + + init(emailAddress: String = "") { + let viewState = AuthenticationForgotPasswordViewState(bindings: AuthenticationForgotPasswordBindings(emailAddress: emailAddress)) + super.init(initialViewState: viewState) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationForgotPasswordViewAction) { + switch viewAction { + case .send: + Task { await callback?(.send(state.bindings.emailAddress)) } + case .resend: + Task { await callback?(.send(state.bindings.emailAddress)) } + case .done: + Task { await callback?(.done) } + case .cancel: + Task { await callback?(.cancel) } + case .goBack: + Task { await callback?(.goBack) } + } + } + + @MainActor func updateForSentEmail() { + state.hasSentEmail = true + } + + @MainActor func goBackToEnterEmailForm() { + state.hasSentEmail = false + } + + @MainActor func displayError(_ type: AuthenticationForgotPasswordErrorType) { + switch type { + case .mxError(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: message) + case .unknown: + state.bindings.alertInfo = AlertInfo(id: type) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordViewModelProtocol.swift new file mode 100644 index 000000000..673138290 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/AuthenticationForgotPasswordViewModelProtocol.swift @@ -0,0 +1,32 @@ +// +// Copyright 2021 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 Foundation + +protocol AuthenticationForgotPasswordViewModelProtocol { + + var callback: (@MainActor (AuthenticationForgotPasswordViewModelResult) -> Void)? { get set } + var context: AuthenticationForgotPasswordViewModelType.Context { get } + + /// Updates the view to reflect that a verification email was successfully sent. + @MainActor func updateForSentEmail() + + /// Goes back to the email form + @MainActor func goBackToEnterEmailForm() + + /// Display an error to the user. + @MainActor func displayError(_ type: AuthenticationForgotPasswordErrorType) +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/Coordinator/AuthenticationForgotPasswordCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/Coordinator/AuthenticationForgotPasswordCoordinator.swift new file mode 100644 index 000000000..99552be87 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/Coordinator/AuthenticationForgotPasswordCoordinator.swift @@ -0,0 +1,175 @@ +// +// Copyright 2021 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 +import CommonKit + +struct AuthenticationForgotPasswordCoordinatorParameters { + let navigationRouter: NavigationRouterType + let loginWizard: LoginWizard +} + +enum AuthenticationForgotPasswordCoordinatorResult { + /// Forgot password flow succeeded + case success + /// Forgot password flow cancelled + case cancel +} + +@available(iOS 14.0, *) +final class AuthenticationForgotPasswordCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationForgotPasswordCoordinatorParameters + private let authenticationForgotPasswordHostingController: VectorHostingController + private var authenticationForgotPasswordViewModel: AuthenticationForgotPasswordViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + /// The wizard used to handle the registration flow. + private var loginWizard: LoginWizard { parameters.loginWizard } + + private var currentTask: Task? { + willSet { + currentTask?.cancel() + } + } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: (@MainActor (AuthenticationForgotPasswordCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationForgotPasswordCoordinatorParameters) { + self.parameters = parameters + + let viewModel = AuthenticationForgotPasswordViewModel() + let view = AuthenticationForgotPasswordScreen(viewModel: viewModel.context) + authenticationForgotPasswordViewModel = viewModel + authenticationForgotPasswordHostingController = VectorHostingController(rootView: view) + authenticationForgotPasswordHostingController.vc_removeBackTitle() + authenticationForgotPasswordHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationForgotPasswordHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationForgotPasswordCoordinator] did start.") + Task { await setupViewModel() } + } + + func toPresentable() -> UIViewController { + return self.authenticationForgotPasswordHostingController + } + + // MARK: - Private + + /// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`. + @MainActor private func setupViewModel() { + authenticationForgotPasswordViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationForgotPasswordCoordinator] AuthenticationForgotPasswordViewModel did complete with result: \(result).") + + switch result { + case .send(let emailAddress): + self.sendEmail(emailAddress) + case .cancel: + self.callback?(.cancel) + case .done: + self.showChoosePasswordScreen() + case .goBack: + self.authenticationForgotPasswordViewModel.goBackToEnterEmailForm() + } + } + } + + /// Show an activity indicator whilst loading. + @MainActor private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + loadingIndicator = nil + } + + /// Sends a validation email to the supplied address and then begins polling the server. + @MainActor private func sendEmail(_ address: String) { + startLoading() + + currentTask = Task { [weak self] in + do { + try await loginWizard.resetPassword(email: address) + + // Shouldn't be reachable but just in case, continue the flow. + + guard !Task.isCancelled else { return } + authenticationForgotPasswordViewModel.updateForSentEmail() + + self?.stopLoading() + } catch is CancellationError { + return + } catch { + self?.stopLoading() + self?.handleError(error) + } + } + } + + /// Shows the choose password screen + @MainActor private func showChoosePasswordScreen() { + MXLog.debug("[AuthenticationForgotPasswordCoordinator] showChoosePasswordScreen") + + let parameters = AuthenticationChoosePasswordCoordinatorParameters(loginWizard: loginWizard) + let coordinator = AuthenticationChoosePasswordCoordinator(parameters: parameters) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.callback?(.success) + case .cancel: + self.navigationRouter.popModule(animated: true) + } + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true, popCompletion: nil) + } + + /// Processes an error to either update the flow or display it to the user. + @MainActor private func handleError(_ error: Error) { + if let mxError = MXError(nsError: error as NSError) { + authenticationForgotPasswordViewModel.displayError(.mxError(mxError.error)) + return + } + + // TODO: Handle another other error types as needed. + + authenticationForgotPasswordViewModel.displayError(.unknown) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/MockAuthenticationForgotPasswordScreenState.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/MockAuthenticationForgotPasswordScreenState.swift new file mode 100644 index 000000000..9428fb4ef --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/MockAuthenticationForgotPasswordScreenState.swift @@ -0,0 +1,55 @@ +// +// Copyright 2021 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 Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockAuthenticationForgotPasswordScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case emptyAddress + case enteredAddress + case hasSentEmail + + /// The associated screen + var screenType: Any.Type { + AuthenticationForgotPasswordScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationForgotPasswordViewModel + switch self { + case .emptyAddress: + viewModel = AuthenticationForgotPasswordViewModel(emailAddress: "") + case .enteredAddress: + viewModel = AuthenticationForgotPasswordViewModel(emailAddress: "test@example.com") + case .hasSentEmail: + viewModel = AuthenticationForgotPasswordViewModel(emailAddress: "test@example.com") + Task { await viewModel.updateForSentEmail() } + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationForgotPasswordScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/Test/UI/AuthenticationForgotPasswordUITests.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/Test/UI/AuthenticationForgotPasswordUITests.swift new file mode 100644 index 000000000..7daab5eed --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/Test/UI/AuthenticationForgotPasswordUITests.swift @@ -0,0 +1,116 @@ +// +// Copyright 2021 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 XCTest +import RiotSwiftUI + +class AuthenticationForgotPasswordUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationForgotPasswordScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationForgotPasswordUITests(selector: #selector(verifyAuthenticationForgotPasswordScreen)) + } + + func verifyAuthenticationForgotPasswordScreen() throws { + guard let screenState = screenState as? MockAuthenticationForgotPasswordScreenState else { fatalError("no screen") } + switch screenState { + case .emptyAddress: + verifyEmptyAddress() + case .enteredAddress: + verifyEnteredAddress() + case .hasSentEmail: + verifyWaitingForEmailLink() + } + } + + func verifyEmptyAddress() { + XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown before an email is sent.") + XCTAssertTrue(app.staticTexts["messageLabel"].exists, "The message should be shown before an email is sent.") + + let addressTextField = app.textFields["addressTextField"] + XCTAssertTrue(addressTextField.exists, "The text field should be shown before an email is sent.") + XCTAssertEqual(addressTextField.value as? String, "Email Address", "The text field should be showing the placeholder before text is input.") + + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should be shown before an email is sent.") + XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled before text is input.") + + let doneButton = app.buttons["doneButton"] + XCTAssertFalse(doneButton.exists, "The done button should be hidden before an email has been sent.") + + let resendButton = app.buttons["resendButton"] + XCTAssertFalse(resendButton.exists, "The done button should be hidden before an email has been sent.") + + XCTAssertFalse(app.staticTexts["waitingTitleLabel"].exists, "The waiting title should be hidden until an email is sent.") + XCTAssertFalse(app.staticTexts["waitingMessageLabel"].exists, "The waiting message should be hidden until an email is sent.") + + let cancelButton = app.navigationBars.firstMatch.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists, "Cancel button should be shown.") + XCTAssertEqual(cancelButton.label, "Cancel") + } + + func verifyEnteredAddress() { + XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown before an email is sent.") + XCTAssertTrue(app.staticTexts["messageLabel"].exists, "The message should be shown before an email is sent.") + + let addressTextField = app.textFields["addressTextField"] + XCTAssertTrue(addressTextField.exists, "The text field should be shown before an email is sent.") + XCTAssertEqual(addressTextField.value as? String, "test@example.com", "The text field should show the email address that was input.") + + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should be shown before an email is sent.") + XCTAssertTrue(nextButton.isEnabled, "The next button should be enabled once an address has been input.") + + let doneButton = app.buttons["doneButton"] + XCTAssertFalse(doneButton.exists, "The done button should be hidden before an email has been sent.") + + let resendButton = app.buttons["resendButton"] + XCTAssertFalse(resendButton.exists, "The done button should be hidden before an email has been sent.") + + XCTAssertFalse(app.staticTexts["waitingTitleLabel"].exists, "The waiting title should be hidden until an email is sent.") + XCTAssertFalse(app.staticTexts["waitingMessageLabel"].exists, "The waiting message should be hidden until an email is sent.") + + let cancelButton = app.navigationBars.firstMatch.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists, "Cancel button should be shown.") + XCTAssertEqual(cancelButton.label, "Cancel") + } + + func verifyWaitingForEmailLink() { + XCTAssertFalse(app.staticTexts["titleLabel"].exists, "The title should be hidden once an email has been sent.") + XCTAssertFalse(app.staticTexts["messageLabel"].exists, "The message should be hidden once an email has been sent.") + XCTAssertFalse(app.textFields["addressTextField"].exists, "The text field should be hidden once an email has been sent.") + XCTAssertFalse(app.buttons["nextButton"].exists, "The next button should be hidden once an email has been sent.") + + let doneButton = app.buttons["doneButton"] + XCTAssertTrue(doneButton.exists, "The done button should be hidden once an email has been sent.") + XCTAssertTrue(doneButton.isEnabled) + + let resendButton = app.buttons["resendButton"] + XCTAssertTrue(resendButton.exists, "The resend button should be hidden once an email has been sent.") + XCTAssertTrue(resendButton.isEnabled) + + XCTAssertTrue(app.staticTexts["waitingTitleLabel"].exists, "The waiting title should be shown once an email has been sent.") + XCTAssertTrue(app.staticTexts["waitingMessageLabel"].exists, "The waiting title should be shown once an email has been sent.") + + let backButton = app.navigationBars.firstMatch.buttons["cancelButton"] + XCTAssertTrue(backButton.exists, "Back button should be shown.") + XCTAssertEqual(backButton.label, "Back") + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/Test/Unit/AuthenticationForgotPasswordViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/Test/Unit/AuthenticationForgotPasswordViewModelTests.swift new file mode 100644 index 000000000..4e047f0b7 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/Test/Unit/AuthenticationForgotPasswordViewModelTests.swift @@ -0,0 +1,49 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationForgotPasswordViewModelTests: XCTestCase { + + var viewModel: AuthenticationForgotPasswordViewModelProtocol! + var context: AuthenticationForgotPasswordViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationForgotPasswordViewModel() + context = viewModel.context + } + + @MainActor func testSentEmailState() async { + // Given a view model where the user hasn't yet sent the email. + XCTAssertFalse(context.viewState.hasSentEmail, "The view model should start with hasSentEmail equal to false.") + + // When updating to indicate that an email has been sent. + viewModel.updateForSentEmail() + + // Then the view model should update to reflect a sent email. + XCTAssertTrue(context.viewState.hasSentEmail, "The view model should update hasSentEmail after sending an email.") + } + + @MainActor func testGoBack() async { + viewModel.updateForSentEmail() + + viewModel.goBackToEnterEmailForm() + + XCTAssertFalse(context.viewState.hasSentEmail, "The view model should update hasSentEmail after going back.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/View/AuthenticationForgotPasswordForm.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/View/AuthenticationForgotPasswordForm.swift new file mode 100644 index 000000000..3165215e5 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/View/AuthenticationForgotPasswordForm.swift @@ -0,0 +1,102 @@ +// +// Copyright 2022 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 form shown to enter an email address. +struct AuthenticationForgotPasswordForm: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + @State private var isEditingTextField = false + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationForgotPasswordViewModel.Context + + // MARK: Views + + var body: some View { + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + + mainContent + } + } + + /// The title, message and icon at the top of the screen. + var header: some View { + VStack(spacing: 8) { + OnboardingIconImage(image: Asset.Images.authenticationEmailIcon) + .padding(.bottom, 8) + + Text(VectorL10n.authenticationForgotPasswordInputTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationForgotPasswordInputMessage) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .accessibilityIdentifier("messageLabel") + } + } + + /// The text field and submit button where the user enters an email address. + var mainContent: some View { + VStack(alignment: .leading, spacing: 12) { + if #available(iOS 15.0, *) { + textField + .onSubmit(submit) + } else { + textField + } + + Button(action: submit) { + Text(VectorL10n.next) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.viewState.hasInvalidAddress) + .accessibilityIdentifier("nextButton") + } + } + + /// The text field, extracted for iOS 15 modifiers to be applied. + var textField: some View { + TextField(VectorL10n.authenticationForgotPasswordTextFieldPlaceholder, text: $viewModel.emailAddress) { + isEditingTextField = $0 + } + .textFieldStyle(BorderedInputFieldStyle(isEditing: isEditingTextField, isError: false)) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + .accessibilityIdentifier("addressTextField") + } + + /// Sends the `send` view action so long as a valid email address has been input. + func submit() { + guard !viewModel.viewState.hasInvalidAddress else { return } + viewModel.send(viewAction: .send) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ForgotPassword/View/AuthenticationForgotPasswordScreen.swift b/RiotSwiftUI/Modules/Authentication/ForgotPassword/View/AuthenticationForgotPasswordScreen.swift new file mode 100644 index 000000000..c290ebfb3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ForgotPassword/View/AuthenticationForgotPasswordScreen.swift @@ -0,0 +1,149 @@ +// +// Copyright 2021 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 + +struct AuthenticationForgotPasswordScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationForgotPasswordViewModel.Context + + // MARK: Views + + var body: some View { + GeometryReader { geometry in + VStack { + ScrollView { + mainContent + .readableFrame() + .padding(.horizontal, 16) + } + + if viewModel.viewState.hasSentEmail { + waitingFooter + .padding(.bottom, OnboardingMetrics.actionButtonBottomPadding) + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) + .padding(.horizontal, 16) + } + } + } + .background(background.ignoresSafeArea()) + .toolbar { toolbar } + .alert(item: $viewModel.alertInfo) { $0.alert } + .accentColor(theme.colors.accent) + } + + @ViewBuilder + var mainContent: some View { + if viewModel.viewState.hasSentEmail { + waitingContent + } else { + AuthenticationForgotPasswordForm(viewModel: viewModel) + } + } + + var waitingContent: some View { + VStack(spacing: 36) { + waitingHeader + .padding(.top, OnboardingMetrics.breakerScreenTopPadding) + } + } + + /// The instructions shown whilst waiting for the user to tap the link in the email. + var waitingHeader: some View { + VStack(spacing: 8) { + OnboardingIconImage(image: Asset.Images.authenticationEmailIcon) + .padding(.bottom, OnboardingMetrics.breakerScreenIconBottomPadding) + + OnboardingTintedFullStopText(VectorL10n.authenticationForgotPasswordWaitingTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("waitingTitleLabel") + + Text(VectorL10n.authenticationForgotPasswordWaitingMessage(viewModel.emailAddress)) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .accessibilityIdentifier("waitingMessageLabel") + } + } + + /// The footer shown whilst waiting for the user to tap the link in the email. + var waitingFooter: some View { + VStack(spacing: 12) { + Button(action: done) { + Text(VectorL10n.done) + } + .buttonStyle(PrimaryActionButtonStyle()) + .accessibilityIdentifier("doneButton") + + Button { viewModel.send(viewAction: .resend) } label: { + Text(VectorL10n.authenticationForgotPasswordWaitingButton) + .font(theme.fonts.body) + .padding(.vertical, 12) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("resendButton") + } + } + + @ViewBuilder + /// The view's background, which will show a gradient in light mode after sending the email. + var background: some View { + OnboardingBreakerScreenBackground(viewModel.viewState.hasSentEmail) + } + + /// A simple toolbar with a cancel button. + var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button(viewModel.viewState.hasSentEmail ? VectorL10n.back : VectorL10n.cancel) { + if viewModel.viewState.hasSentEmail { + viewModel.send(viewAction: .goBack) + } else { + viewModel.send(viewAction: .cancel) + } + } + .accessibilityIdentifier("cancelButton") + } + } + + /// Sends the `done` view action. + func done() { + guard !viewModel.viewState.hasInvalidAddress else { return } + viewModel.send(viewAction: .done) + } +} + +// MARK: - Previews + +struct AuthenticationForgotPasswordScreen_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationForgotPasswordScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 8f354b957..cd53c70e8 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -53,6 +53,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { private var navigationRouter: NavigationRouterType { parameters.navigationRouter } private var indicatorPresenter: UserIndicatorTypePresenterProtocol private var waitingIndicator: UserIndicator? + private var successIndicator: UserIndicator? /// The authentication service used for the login. private var authenticationService: AuthenticationService { parameters.authenticationService } @@ -106,7 +107,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { case .parseUsername(let username): self.parseUsername(username) case .forgotPassword: - #warning("Show the forgot password flow.") + self.showForgotPasswordScreen() case .login(let username, let password): self.login(username: username, password: password) case .continueWithSSO(let identityProvider): @@ -234,6 +235,40 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { self?.remove(childCoordinator: coordinator) } } + + /// Shows the forgot password screen. + @MainActor private func showForgotPasswordScreen() { + MXLog.debug("[AuthenticationLoginCoordinator] showForgotPasswordScreen") + + guard let loginWizard = loginWizard else { + MXLog.failure("[AuthenticationLoginCoordinator] The login wizard was requested before getting the login flow.") + return + } + + let modalRouter = NavigationRouter() + + let parameters = AuthenticationForgotPasswordCoordinatorParameters(navigationRouter: modalRouter, + loginWizard: loginWizard) + let coordinator = AuthenticationForgotPasswordCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + switch result { + case .success: + self.navigationRouter.dismissModule(animated: true, completion: nil) + self.successIndicator = self.indicatorPresenter.present(.success(label: VectorL10n.done)) + case .cancel: + self.navigationRouter.dismissModule(animated: true, completion: nil) + } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + modalRouter.setRootModule(coordinator) + + navigationRouter.present(modalRouter, animated: true) + } /// Updates the view model to reflect any changes made to the homeserver. @MainActor private func updateViewModel() { diff --git a/RiotSwiftUI/Modules/Authentication/VerifyEmail/AuthenticationVerifyEmailModels.swift b/RiotSwiftUI/Modules/Authentication/VerifyEmail/AuthenticationVerifyEmailModels.swift index 28e4a612f..f72182168 100644 --- a/RiotSwiftUI/Modules/Authentication/VerifyEmail/AuthenticationVerifyEmailModels.swift +++ b/RiotSwiftUI/Modules/Authentication/VerifyEmail/AuthenticationVerifyEmailModels.swift @@ -32,16 +32,6 @@ enum AuthenticationVerifyEmailViewModelResult { // MARK: View struct AuthenticationVerifyEmailViewState: BindableState { - enum Constants { - static let gradientColors = [ - Color(red: 0.646, green: 0.95, blue: 0.879), - Color(red: 0.576, green: 0.929, blue: 0.961), - Color(red: 0.874, green: 0.82, blue: 1) - ] - } - - /// The background gradient used with light mode. - let gradient = Gradient (colors: Constants.gradientColors) /// An email has been sent and the app is waiting for the user to tap the link. var hasSentEmail = false /// View state that can be bound to from SwiftUI. diff --git a/RiotSwiftUI/Modules/Authentication/VerifyEmail/View/AuthenticationVerifyEmailScreen.swift b/RiotSwiftUI/Modules/Authentication/VerifyEmail/View/AuthenticationVerifyEmailScreen.swift index eb654d712..97283a9f9 100644 --- a/RiotSwiftUI/Modules/Authentication/VerifyEmail/View/AuthenticationVerifyEmailScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/VerifyEmail/View/AuthenticationVerifyEmailScreen.swift @@ -111,29 +111,9 @@ struct AuthenticationVerifyEmailScreen: View { @ViewBuilder /// The view's background, which will show a gradient in light mode after sending the email. var background: some View { - GeometryReader { geometry in - ZStack(alignment: .top) { - theme.colors.background - - if viewModel.viewState.hasSentEmail && !theme.isDark { - gradient - .frame(height: geometry.size.height * 0.65) - } - } - } + OnboardingBreakerScreenBackground(viewModel.viewState.hasSentEmail) } - - /// The background gradient shown after sending the email. - var gradient: some View { - LinearGradient(gradient: viewModel.viewState.gradient, - startPoint: .leading, - endPoint: .trailing) - .opacity(0.3) - .mask(LinearGradient(colors: [.white, .clear], - startPoint: .top, - endPoint: .bottom)) - } - + /// A simple toolbar with a cancel button. var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 51dcd92fa..3a77f41b8 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -28,6 +28,8 @@ enum MockAppScreens { MockAuthenticationVerifyMsisdnScreenState.self, MockAuthenticationRegistrationScreenState.self, MockAuthenticationServerSelectionScreenState.self, + MockAuthenticationForgotPasswordScreenState.self, + MockAuthenticationChoosePasswordScreenState.self, MockOnboardingCelebrationScreenState.self, MockOnboardingAvatarScreenState.self, MockOnboardingDisplayNameScreenState.self, diff --git a/RiotSwiftUI/Modules/Onboarding/Common/OnboardingBreakerScreenBackground.swift b/RiotSwiftUI/Modules/Onboarding/Common/OnboardingBreakerScreenBackground.swift new file mode 100644 index 000000000..39fd14915 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Common/OnboardingBreakerScreenBackground.swift @@ -0,0 +1,73 @@ +// +// Copyright 2022 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 + +struct OnboardingBreakerScreenBackground: View { + + @Environment(\.theme) private var theme + + /// Flag indicating whether the gradient enabled on light theme + var isGradientEnabled: Bool + + enum Constants { + static let gradientColors = [ + Color(red: 0.646, green: 0.95, blue: 0.879), + Color(red: 0.576, green: 0.929, blue: 0.961), + Color(red: 0.874, green: 0.82, blue: 1) + ] + } + + /// The background gradient used with light mode. + let gradient = Gradient(colors: Constants.gradientColors) + + init(_ isGradientEnabled: Bool = true) { + self.isGradientEnabled = isGradientEnabled + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .top) { + theme.colors.background + + if isGradientEnabled && !theme.isDark { + LinearGradient(gradient: gradient, + startPoint: .leading, + endPoint: .trailing) + .opacity(0.3) + .mask(LinearGradient(colors: [.white, .clear], + startPoint: .top, + endPoint: .bottom)) + .frame(height: geometry.size.height * 0.65) + } + } + } + .ignoresSafeArea() + } + +} + +// MARK: - Previews + +struct OnboardingBreakerScreenBackground_Previews: PreviewProvider { + static var previews: some View { + Group { + OnboardingBreakerScreenBackground() + OnboardingBreakerScreenBackground() + .theme(.dark).preferredColorScheme(.dark) + } + } +} diff --git a/changelog.d/5655.feature b/changelog.d/5655.feature new file mode 100644 index 000000000..356e7dfda --- /dev/null +++ b/changelog.d/5655.feature @@ -0,0 +1 @@ +AuthenticationLoginCoordinator: Implement forgot password flow.