mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-30 05:06:58 +02:00
Merge pull request #6235 from vector-im/ismail/5655_reset_password
This commit is contained in:
+63
@@ -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
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+148
@@ -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)
|
||||
}
|
||||
}
|
||||
+57
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
+118
@@ -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"
|
||||
}
|
||||
}
|
||||
+33
@@ -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.")
|
||||
}
|
||||
|
||||
}
|
||||
+129
@@ -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)
|
||||
|
||||
|
||||
+71
@@ -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
|
||||
}
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
+175
@@ -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)
|
||||
}
|
||||
}
|
||||
+55
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
+49
@@ -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.")
|
||||
}
|
||||
}
|
||||
+102
@@ -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)
|
||||
}
|
||||
}
|
||||
+149
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
-1
@@ -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.
|
||||
|
||||
+2
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user