Merge pull request #6235 from vector-im/ismail/5655_reset_password

This commit is contained in:
ismailgulek
2022-06-06 15:19:14 +03:00
committed by GitHub
31 changed files with 1673 additions and 43 deletions
@@ -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<AuthenticationChoosePasswordErrorType>?
}
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
}
@@ -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<AuthenticationChoosePasswordViewState,
Never,
AuthenticationChoosePasswordViewAction>
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)
}
}
}
@@ -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)
}
@@ -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<Void, Error>? {
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)
}
}
@@ -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))
)
}
}
@@ -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"
}
}
@@ -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.")
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
}
@@ -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)
@@ -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<AuthenticationForgotPasswordErrorType>?
}
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
}
@@ -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<AuthenticationForgotPasswordViewState,
Never,
AuthenticationForgotPasswordViewAction>
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)
}
}
}
@@ -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)
}
@@ -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<Void, Error>? {
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)
}
}
@@ -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))
)
}
}
@@ -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")
}
}
@@ -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.")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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() {
@@ -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.
@@ -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) {
@@ -28,6 +28,8 @@ enum MockAppScreens {
MockAuthenticationVerifyMsisdnScreenState.self,
MockAuthenticationRegistrationScreenState.self,
MockAuthenticationServerSelectionScreenState.self,
MockAuthenticationForgotPasswordScreenState.self,
MockAuthenticationChoosePasswordScreenState.self,
MockOnboardingCelebrationScreenState.self,
MockOnboardingAvatarScreenState.self,
MockOnboardingDisplayNameScreenState.self,
@@ -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)
}
}
}