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.