Add choose password screen

This commit is contained in:
ismailgulek 2022-06-01 18:58:32 +03:00
parent bbe1c9f554
commit 81f385bb11
No known key found for this signature in database
GPG key ID: E96336D42D9470A9
9 changed files with 701 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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
}
/// Sends a validation email to the supplied address and then begins polling the server.
@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)
}
}

View file

@ -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))
)
}
}

View file

@ -0,0 +1,108 @@
//
// 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.textFields["passwordTextField"]
XCTAssertTrue(passwordTextField.exists, "The text field should be shown.")
XCTAssertEqual(passwordTextField.value as? String, "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 doNotSignoutAllDevicesImage = app.images["doNotSignoutAllDevices"]
XCTAssertTrue(doNotSignoutAllDevicesImage.exists, "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.textFields["passwordTextField"]
XCTAssertTrue(passwordTextField.exists, "The text field should be shown.")
XCTAssertEqual(passwordTextField.value as? String, "1234", "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 doNotSignoutAllDevicesImage = app.images["doNotSignoutAllDevices"]
XCTAssertTrue(doNotSignoutAllDevicesImage.exists, "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.textFields["passwordTextField"]
XCTAssertTrue(passwordTextField.exists, "The text field should be shown.")
XCTAssertEqual(passwordTextField.value as? String, "12345678", "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 doNotSignoutAllDevicesImage = app.images["doNotSignoutAllDevices"]
XCTAssertTrue(doNotSignoutAllDevicesImage.exists, "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.textFields["passwordTextField"]
XCTAssertTrue(passwordTextField.exists, "The text field should be shown.")
XCTAssertEqual(passwordTextField.value as? String, "12345678", "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 signoutAllDevicesImage = app.images["signoutAllDevices"]
XCTAssertTrue(signoutAllDevicesImage.exists, "Sign out all devices should be checked")
}
}

View file

@ -0,0 +1,46 @@
//
// 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.viewState.bindings.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.")
}
@MainActor func testToggleSignoutAllDevices() async {
let viewModel = AuthenticationChoosePasswordViewModel()
let context = viewModel.context
viewModel.process(viewAction: .toggleSignoutAllDevices)
XCTAssertTrue(context.signoutAllDevices, "The view model should update signoutAllDevices.")
viewModel.process(viewAction: .toggleSignoutAllDevices)
XCTAssertFalse(context.signoutAllDevices, "The view model should update signoutAllDevices again.")
}
}

View file

@ -0,0 +1,126 @@
//
// 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 AuthenticationChoosePasswordForm: 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 {
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.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 mainContent: some View {
VStack(alignment: .leading, spacing: 12) {
if #available(iOS 15.0, *) {
textField
.onSubmit(submit)
} else {
textField
}
HStack(alignment: .center, spacing: 8) {
signoutAllDevicesImage
.foregroundColor(theme.colors.accent)
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 {
TextField(VectorL10n.authenticationChoosePasswordTextFieldPlaceholder, text: $viewModel.password) {
isEditingTextField = $0
}
.textFieldStyle(BorderedInputFieldStyle(isEditing: isEditingTextField, isError: false))
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
.accessibilityIdentifier("passwordTextField")
}
var signoutAllDevicesImage: some View {
if viewModel.signoutAllDevices {
return Image(uiImage: Asset.Images.selectionTick.image)
.accessibilityIdentifier("signoutAllDevices")
} else {
return Image(uiImage: Asset.Images.selectionUntick.image)
.accessibilityIdentifier("doNotSignoutAllDevices")
}
}
/// 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)
}
}

View file

@ -0,0 +1,65 @@
//
// 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
// MARK: Public
@ObservedObject var viewModel: AuthenticationChoosePasswordViewModel.Context
// MARK: Views
var body: some View {
GeometryReader { geometry in
VStack {
ScrollView {
mainContent
.readableFrame()
.padding(.horizontal, 16)
}
}
}
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
@ViewBuilder
var mainContent: some View {
AuthenticationChoosePasswordForm(viewModel: viewModel)
}
}
// 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)
}
}