Add login screen.

This commit is contained in:
Doug 2022-05-20 11:14:17 +01:00
parent b69772edf4
commit 0f12447748
19 changed files with 943 additions and 70 deletions

View file

@ -23,13 +23,17 @@
// MARK: Onboarding Authentication WIP
"authentication_registration_title" = "Create your account";
"authentication_registration_message" = "Well need some info to get you set up.";
"authentication_registration_server_title" = "Choose your server to store your data";
"authentication_registration_matrix_description" = "Join millions for free on the largest public server";
"authentication_registration_username" = "Username";
"authentication_registration_password" = "Password";
"authentication_registration_username_footer" = "You cant change this later";
"authentication_registration_password_footer" = "Must be 8 characters or more";
"authentication_login_title" = "Welcome back!";
"authentication_login_username" = "Username or Email";
"authentication_login_forgot_password" = "Forgot password";
"authentication_server_info_title" = "Choose your server to store your data";
"authentication_server_info_matrix_description" = "Join millions for free on the largest public server";
"authentication_server_selection_title" = "Choose your server";
"authentication_server_selection_message" = "What is the address of your server? A server is like a home for all your data.";
"authentication_server_selection_server_url" = "Server URL";

View file

@ -34,4 +34,8 @@ import UIKit
return userInterfaceIdiom == .phone
}
var initialDisplayName: String {
isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
}
}

View file

@ -14,30 +14,30 @@ public extension VectorL10n {
static var authenticationCancelFlowConfirmationMessage: String {
return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message")
}
/// Forgot password
static var authenticationLoginForgotPassword: String {
return VectorL10n.tr("Untranslated", "authentication_login_forgot_password")
}
/// Welcome back!
static var authenticationLoginTitle: String {
return VectorL10n.tr("Untranslated", "authentication_login_title")
}
/// Username or Email
static var authenticationLoginUsername: String {
return VectorL10n.tr("Untranslated", "authentication_login_username")
}
/// This server would like to make sure you are not a robot
static var authenticationRecaptchaMessage: String {
return VectorL10n.tr("Untranslated", "authentication_recaptcha_message")
}
/// Join millions for free on the largest public server
static var authenticationRegistrationMatrixDescription: String {
return VectorL10n.tr("Untranslated", "authentication_registration_matrix_description")
}
/// Well need some info to get you set up.
static var authenticationRegistrationMessage: String {
return VectorL10n.tr("Untranslated", "authentication_registration_message")
}
/// Password
static var authenticationRegistrationPassword: String {
return VectorL10n.tr("Untranslated", "authentication_registration_password")
}
/// Must be 8 characters or more
static var authenticationRegistrationPasswordFooter: String {
return VectorL10n.tr("Untranslated", "authentication_registration_password_footer")
}
/// Choose your server to store your data
static var authenticationRegistrationServerTitle: String {
return VectorL10n.tr("Untranslated", "authentication_registration_server_title")
}
/// Create your account
static var authenticationRegistrationTitle: String {
return VectorL10n.tr("Untranslated", "authentication_registration_title")
@ -50,6 +50,14 @@ public extension VectorL10n {
static var authenticationRegistrationUsernameFooter: String {
return VectorL10n.tr("Untranslated", "authentication_registration_username_footer")
}
/// Join millions for free on the largest public server
static var authenticationServerInfoMatrixDescription: String {
return VectorL10n.tr("Untranslated", "authentication_server_info_matrix_description")
}
/// Choose your server to store your data
static var authenticationServerInfoTitle: String {
return VectorL10n.tr("Untranslated", "authentication_server_info_title")
}
/// Cannot find a server at this URL, please check it is correct.
static var authenticationServerSelectionGenericError: String {
return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error")

View file

@ -144,6 +144,43 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
callback?(.cancel(.register))
}
// MARK: - Login
/// Shows the login screen.
@MainActor private func showLoginScreen() {
MXLog.debug("[AuthenticationCoordinator] showLoginScreen")
let homeserver = authenticationService.state.homeserver
let parameters = AuthenticationLoginCoordinatorParameters(navigationRouter: navigationRouter,
authenticationService: authenticationService,
loginMode: homeserver.preferredLoginMode)
let coordinator = AuthenticationLoginCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
self.loginCoordinator(coordinator, didCompleteWith: result)
}
coordinator.start()
add(childCoordinator: coordinator)
if navigationRouter.modules.isEmpty {
navigationRouter.setRootModule(coordinator, popCompletion: nil)
} else {
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
}
/// Displays the next view in the flow after the registration screen.
@MainActor private func loginCoordinator(_ coordinator: AuthenticationLoginCoordinator,
didCompleteWith result: AuthenticationLoginCoordinatorResult) {
switch result {
case .success(let session):
onSessionCreated(session: session, flow: .login)
}
}
// MARK: - Registration
/// Pushes the server selection screen into the flow (other screens may also present it modally later).
@ -298,12 +335,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
}
}
/// Shows the login screen.
@MainActor private func showLoginScreen() {
MXLog.debug("[AuthenticationCoordinator] showLoginScreen")
}
// MARK: - Registration Handlers
/// Determines the next screen to show from the flow result and pushes it.
@MainActor private func handleRegistrationResult(_ result: RegistrationResult) {
@ -378,7 +409,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
verificationListener.start()
self.verificationListener = verificationListener
#warning("Add authentication type to the new flow")
#warning("Add authentication type to the new flow.")
callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other))
}
@ -397,7 +428,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
/// Present the key verification screen modally.
private func presentCompleteSecurity() {
guard let session = session else {
MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.")
MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.")
authenticationDidComplete()
return
}
@ -427,7 +458,7 @@ extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate {
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
if let crypto = session?.crypto,
!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled {
MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
}

View file

@ -163,7 +163,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
case .register:
showUseCaseSelectionScreen()
case .login:
showLegacyAuthenticationScreen()
beginAuthentication(with: .login, onStart: coordinator.stop)
}
}
@ -232,6 +232,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
self.cancelAuthentication(flow: flow)
}
}
authenticationCoordinator = coordinator
add(childCoordinator: coordinator)
coordinator.start()
@ -256,7 +257,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
// These results are only sent by the new flow.
break
}
}
// Due to needing to preload the authVC, this breaks the Coordinator init/start pattern.
@ -567,7 +567,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
}
guard authenticationFinished else {
MXLog.debug("[OnboardingCoordinator] Allowing LegacyAuthenticationCoordinator to display any remaining screens.")
MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.")
authenticationCoordinator.presentPendingScreensIfNecessary()
return
}

View file

@ -0,0 +1,65 @@
//
// 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 AuthenticationServerInfoSection: View {
// MARK: - Private
@Environment(\.theme) private var theme
// MARK: - Public
let address: String
let description: String?
let editAction: () -> Void
// MARK: - Views
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(VectorL10n.authenticationServerInfoTitle)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.secondaryContent)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(address)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
if let description = description {
Text(description)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.tertiaryContent)
.accessibilityIdentifier("serverDescriptionText")
}
}
Spacer()
Button(action: editAction) {
Text(VectorL10n.edit)
.font(theme.fonts.body)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent))
}
}
}
}
}

View file

@ -0,0 +1,90 @@
//
// 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
// MARK: View model
enum AuthenticationLoginViewModelResult {
/// The user would like to select another server.
case selectServer
/// Parse the username and update the homeserver if included.
case parseUsername(String)
/// The user would like to reset their password.
case forgotPassword
/// Login using the supplied credentials.
case login(username: String, password: String)
}
// MARK: View
struct AuthenticationLoginViewState: BindableState {
/// The address of the homeserver.
var homeserverAddress: String
/// Whether or not to show the username and password text fields with the next button
var showLoginForm: Bool
/// An array containing the available SSO options for login.
var ssoIdentityProviders: [SSOIdentityProvider]
/// View state that can be bound to from SwiftUI.
var bindings: AuthenticationLoginBindings
/// A description that can be shown for the currently selected homeserver.
var serverDescription: String? {
guard homeserverAddress == "matrix.org" else { return nil }
return VectorL10n.authenticationServerInfoMatrixDescription
}
/// Whether to show any SSO buttons.
var showSSOButtons: Bool {
!ssoIdentityProviders.isEmpty
}
/// `true` if it is possible to continue, otherwise `false`.
var hasValidCredentials: Bool {
!bindings.username.isEmpty && !bindings.password.isEmpty
}
}
struct AuthenticationLoginBindings {
/// The username input by the user.
var username = ""
/// The password input by the user.
var password = ""
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<AuthenticationLoginErrorType>?
}
enum AuthenticationLoginViewAction {
/// The user would like to select another server.
case selectServer
/// Parse the username to detect if a homeserver is included.
case parseUsername
/// The user would like to reset their password.
case forgotPassword
/// Continue using the input username and password.
case next
/// Login using the supplied SSO provider ID.
case continueWithSSO(id: String)
}
enum AuthenticationLoginErrorType: Hashable {
/// An error response from the homeserver.
case mxError(String)
/// The current homeserver address isn't valid.
case invalidHomeserver
/// The response from the homeserver was unexpected.
case unknown
}

View file

@ -0,0 +1,80 @@
//
// 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 AuthenticationLoginViewModelType = StateStoreViewModel<AuthenticationLoginViewState,
Never,
AuthenticationLoginViewAction>
class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, AuthenticationLoginViewModelProtocol {
// MARK: - Properties
// MARK: Public
@MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)?
// MARK: - Setup
init(homeserverAddress: String, showLoginForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) {
let bindings = AuthenticationLoginBindings()
let viewState = AuthenticationLoginViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress),
showLoginForm: showLoginForm,
ssoIdentityProviders: ssoIdentityProviders,
bindings: bindings)
super.init(initialViewState: viewState)
}
// MARK: - Public
override func process(viewAction: AuthenticationLoginViewAction) {
switch viewAction {
case .selectServer:
Task { await callback?(.selectServer) }
case .parseUsername:
Task { await callback?(.parseUsername(state.bindings.username)) }
case .forgotPassword:
Task { await callback?(.forgotPassword) }
case .next:
Task { await callback?(.login(username: state.bindings.username, password: state.bindings.password)) }
case .continueWithSSO(let id):
break
}
}
@MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) {
state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress)
state.showLoginForm = showLoginForm
state.ssoIdentityProviders = ssoIdentityProviders
}
@MainActor func displayError(_ type: AuthenticationLoginErrorType) {
switch type {
case .mxError(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: message)
case .invalidHomeserver:
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: VectorL10n.authenticationServerSelectionGenericError)
case .unknown:
state.bindings.alertInfo = AlertInfo(id: type)
}
}
}

View file

@ -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 Foundation
protocol AuthenticationLoginViewModelProtocol {
@MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? { get set }
var context: AuthenticationLoginViewModelType.Context { get }
/// Update the view with new homeserver information.
/// - Parameters:
/// - homeserverAddress: The homeserver string to be shown to the user.
/// - showLoginForm: Whether or not to display the username and password text fields.
/// - ssoIdentityProviders: The supported SSO login options.
@MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider])
/// Display an error to the user.
@MainActor func displayError(_ type: AuthenticationLoginErrorType)
}

View file

@ -0,0 +1,231 @@
//
// 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
import MatrixSDK
struct AuthenticationLoginCoordinatorParameters {
let navigationRouter: NavigationRouterType
let authenticationService: AuthenticationService
/// The login mode to allow SSO buttons to be shown when available.
let loginMode: LoginMode
}
enum AuthenticationLoginCoordinatorResult {
/// Login was successful with the associated session created.
case success(MXSession)
}
final class AuthenticationLoginCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationLoginCoordinatorParameters
private let authenticationLoginHostingController: VectorHostingController
private var authenticationLoginViewModel: AuthenticationLoginViewModelProtocol
private var currentTask: Task<Void, Error>? {
willSet {
currentTask?.cancel()
}
}
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var waitingIndicator: UserIndicator?
/// The authentication service used for the login.
private var authenticationService: AuthenticationService { parameters.authenticationService }
/// The wizard used to handle the login flow. Will only be `nil` if there is a misconfiguration.
private var loginWizard: LoginWizard? { parameters.authenticationService.loginWizard }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
@MainActor var callback: ((AuthenticationLoginCoordinatorResult) -> Void)?
// MARK: - Setup
@MainActor init(parameters: AuthenticationLoginCoordinatorParameters) {
self.parameters = parameters
let homeserver = parameters.authenticationService.state.homeserver
let viewModel = AuthenticationLoginViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow,
ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? [])
authenticationLoginViewModel = viewModel
let view = AuthenticationLoginScreen(viewModel: viewModel.context)
authenticationLoginHostingController = VectorHostingController(rootView: view)
authenticationLoginHostingController.vc_removeBackTitle()
authenticationLoginHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationLoginHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationLoginCoordinator] did start.")
Task { await setupViewModel() }
}
func toPresentable() -> UIViewController {
authenticationLoginHostingController
}
// MARK: - Private
/// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`.
@MainActor private func setupViewModel() {
authenticationLoginViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationLoginCoordinator] AuthenticationLoginViewModel did callback with result: \(result).")
switch result {
case .selectServer:
self.presentServerSelectionScreen()
case .parseUsername(let username):
self.parseUsername(username)
case .forgotPassword:
#warning("Show the forgot password flow.")
case .login(let username, let password):
self.login(username: username, password: password)
}
}
}
/// Show a blocking activity indicator whilst saving.
@MainActor private func startLoading(isInteractionBlocking: Bool) {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() {
waitingIndicator = nil
}
/// Login with the supplied username and password.
@MainActor private func login(username: String, password: String) {
guard let loginWizard = loginWizard else {
MXLog.failure("[AuthenticationLoginCoordinator] The login wizard was requested before getting the login flow.")
return
}
startLoading(isInteractionBlocking: true)
currentTask = Task { [weak self] in
do {
let session = try await loginWizard.login(login: username,
password: password,
initialDeviceName: UIDevice.current.initialDisplayName)
guard !Task.isCancelled else { return }
callback?(.success(session))
self?.stopLoading()
} 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) {
authenticationLoginViewModel.displayError(.mxError(mxError.error))
return
}
if let authenticationError = error as? AuthenticationError {
switch authenticationError {
case .invalidHomeserver:
authenticationLoginViewModel.displayError(.invalidHomeserver)
case .loginFlowNotCalled:
#warning("Reset the flow")
case .missingMXRestClient:
#warning("Forget the soft logout session")
}
return
}
authenticationLoginViewModel.displayError(.unknown)
}
@MainActor private func parseUsername(_ username: String) {
guard MXTools.isMatrixUserIdentifier(username) else { return }
let domain = username.split(separator: ":")[1]
let homeserverAddress = HomeserverAddress.sanitized(String(domain))
startLoading(isInteractionBlocking: false)
currentTask = Task { [weak self] in
do {
try await authenticationService.startFlow(.login, for: homeserverAddress)
guard !Task.isCancelled else { return }
updateViewModel()
self?.stopLoading()
} catch {
self?.stopLoading()
self?.handleError(error)
}
}
}
/// Presents the server selection screen as a modal.
@MainActor private func presentServerSelectionScreen() {
MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen")
let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService,
hasModalPresentation: true)
let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
self.serverSelectionCoordinator(coordinator, didCompleteWith: result)
}
coordinator.start()
add(childCoordinator: coordinator)
let modalRouter = NavigationRouter()
modalRouter.setRootModule(coordinator)
navigationRouter.present(modalRouter, animated: true)
}
/// Handles the result from the server selection modal, dismissing it after updating the view.
@MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator,
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
if result == .updated {
updateViewModel()
}
navigationRouter.dismissModule(animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
@MainActor private func updateViewModel() {
let homeserver = authenticationService.state.homeserver
authenticationLoginViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow,
ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? [])
}
}

View file

@ -0,0 +1,67 @@
//
// 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.
enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case matrixDotOrg
case passwordOnly
case passwordWithCredentials
case ssoOnly
/// The associated screen
var screenType: Any.Type {
AuthenticationLoginScreen.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: AuthenticationLoginViewModel
switch self {
case .matrixDotOrg:
viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil),
SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil),
SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil),
SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil)
])
case .passwordOnly:
viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
case .passwordWithCredentials:
viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
viewModel.context.username = "alice"
viewModel.context.password = "password"
case .ssoOnly:
viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://company.com",
showLoginForm: false,
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
}
// can simulate service and viewModel actions here if needs be.
return (
[viewModel], AnyView(AuthenticationLoginScreen(viewModel: viewModel.context))
)
}
}

View file

@ -0,0 +1,44 @@
//
// 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 AuthenticationLoginUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockAuthenticationLoginScreenState.self
}
override class func createTest() -> MockScreenTest {
return AuthenticationLoginUITests(selector: #selector(verifyAuthenticationLoginScreen))
}
func verifyAuthenticationLoginScreen() throws {
guard let screenState = screenState as? MockAuthenticationLoginScreenState else { fatalError("no screen") }
switch screenState {
case .promptType(let promptType):
verifyAuthenticationLoginPromptType(promptType: promptType)
}
}
func verifyAuthenticationLoginPromptType(promptType: AuthenticationLoginPromptType) {
let title = app.staticTexts["title"]
XCTAssert(title.exists)
XCTAssertEqual(title.label, promptType.title)
}
}

View file

@ -0,0 +1,48 @@
//
// 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 AuthenticationLoginViewModelTests: XCTestCase {
private enum Constants {
static let counterInitialValue = 0
}
var viewModel: AuthenticationLoginViewModelProtocol!
var context: AuthenticationLoginViewModelType.Context!
override func setUpWithError() throws {
viewModel = AuthenticationLoginViewModel(promptType: .regular, initialCount: Constants.counterInitialValue)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.count, Constants.counterInitialValue)
}
func testCounter() throws {
context.send(viewAction: .incrementCount)
XCTAssertEqual(context.viewState.count, 1)
context.send(viewAction: .incrementCount)
XCTAssertEqual(context.viewState.count, 2)
context.send(viewAction: .decrementCount)
XCTAssertEqual(context.viewState.count, 1)
}
}

View file

@ -0,0 +1,172 @@
//
// 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 AuthenticationLoginScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
/// A boolean that can be toggled to give focus to the password text field.
/// This must be manually set back to `false` when the text field finishes editing.
@State private var isPasswordFocused = false
// MARK: Public
@ObservedObject var viewModel: AuthenticationLoginViewModel.Context
var body: some View {
ScrollView {
VStack(spacing: 0) {
header
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 36)
serverInfo
.padding(.leading, 12)
Rectangle()
.fill(theme.colors.quinaryContent)
.frame(height: 1)
.padding(.vertical, 21)
if viewModel.viewState.showLoginForm {
loginForm
}
if viewModel.viewState.showLoginForm && viewModel.viewState.showSSOButtons {
Text(VectorL10n.or)
.foregroundColor(theme.colors.secondaryContent)
.padding(.top, 16)
}
if viewModel.viewState.showSSOButtons {
ssoButtons
.padding(.top, 16)
}
}
.readableFrame()
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
/// The header containing the icon, title and message.
var header: some View {
Text(VectorL10n.authenticationLoginTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
}
/// The sever information section that includes a button to select a different server.
var serverInfo: some View {
AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress,
description: viewModel.viewState.serverDescription) {
viewModel.send(viewAction: .selectServer)
}
}
/// The form with text fields for username and password, along with a submit button.
var loginForm: some View {
VStack(spacing: 14) {
RoundedBorderTextField(placeHolder: VectorL10n.authenticationLoginUsername,
text: $viewModel.username,
isFirstResponder: false,
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
autocapitalizationType: .none,
autocorrectionType: .no),
onEditingChanged: usernameEditingChanged)
.accessibilityIdentifier("usernameTextField")
Spacer().frame(height: 20)
RoundedBorderTextField(placeHolder: VectorL10n.authPasswordPlaceholder,
text: $viewModel.password,
isFirstResponder: isPasswordFocused,
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
isSecureTextEntry: true),
onEditingChanged: passwordEditingChanged)
.accessibilityIdentifier("passwordTextField")
Button { } label: {
Text(VectorL10n.authenticationLoginForgotPassword)
.font(theme.fonts.body)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.bottom, 8)
Button(action: submit) {
Text(VectorL10n.next)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.hasValidCredentials)
.accessibilityIdentifier("nextButton")
}
}
/// A list of SSO buttons that can be used for login.
var ssoButtons: some View {
VStack(spacing: 16) {
ForEach(viewModel.viewState.ssoIdentityProviders) { provider in
AuthenticationSSOButton(provider: provider) {
viewModel.send(viewAction: .continueWithSSO(id: provider.id))
}
.accessibilityIdentifier("ssoButton")
}
}
}
/// Give focus to the password text field.
func usernameEditingChanged(isEditing: Bool) {
guard !isEditing, !viewModel.username.isEmpty else { return }
viewModel.send(viewAction: .parseUsername)
isPasswordFocused = true
}
/// Submits the form if valid credentials have been input.
func passwordEditingChanged(isEditing: Bool) {
guard !isEditing else { return }
isPasswordFocused = false
submit()
}
/// Sends the `next` view action so long as valid credentials have been input.
func submit() {
guard viewModel.viewState.hasValidCredentials else { return }
viewModel.send(viewAction: .next)
}
}
// MARK: - Previews
@available(iOS 15.0, *)
struct AuthenticationLogin_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationLoginScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
}
}

View file

@ -58,7 +58,7 @@ struct AuthenticationRegistrationViewState: BindableState {
/// A description that can be shown for the currently selected homeserver.
var serverDescription: String? {
guard homeserverAddress == "matrix.org" else { return nil }
return VectorL10n.authenticationRegistrationMatrixDescription
return VectorL10n.authenticationServerInfoMatrixDescription
}
/// Whether to show any SSO buttons.

View file

@ -53,9 +53,9 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
private var waitingIndicator: UserIndicator?
/// The authentication service used for the registration.
var authenticationService: AuthenticationService { parameters.authenticationService }
private var authenticationService: AuthenticationService { parameters.authenticationService }
/// The wizard used to handle the registration flow. May be `nil` when only SSO is supported.
var registrationWizard: RegistrationWizard?
private var registrationWizard: RegistrationWizard? { parameters.authenticationService.registrationWizard }
// MARK: Public
@ -67,7 +67,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
@MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) {
self.parameters = parameters
self.registrationWizard = parameters.authenticationService.registrationWizard
let homeserver = parameters.authenticationService.state.homeserver
let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
@ -112,8 +111,8 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
}
/// Show a blocking activity indicator whilst saving.
@MainActor private func startLoading(label: String? = nil) {
waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true))
@MainActor private func startLoading() {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
@ -149,14 +148,13 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
return
}
// reAuthHelper.data = state.password
let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
startLoading()
currentTask = Task { [weak self] in
do {
let result = try await registrationWizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName)
let result = try await registrationWizard.createAccount(username: username,
password: password,
initialDeviceDisplayName: UIDevice.current.initialDisplayName)
guard !Task.isCancelled else { return }
callback?(.completed(result))
@ -230,8 +228,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
showRegistrationForm: homeserver.registrationFlow != nil,
ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? [])
self.registrationWizard = authenticationService.registrationWizard
}
navigationRouter.dismissModule(animated: true) { [weak self] in

View file

@ -90,35 +90,9 @@ struct AuthenticationRegistrationScreen: View {
/// The sever information section that includes a button to select a different server.
var serverInfo: some View {
VStack(alignment: .leading, spacing: 4) {
Text(VectorL10n.authenticationRegistrationServerTitle)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.secondaryContent)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.viewState.homeserverAddress)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
if let serverDescription = viewModel.viewState.serverDescription {
Text(serverDescription)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.tertiaryContent)
.accessibilityIdentifier("serverDescriptionText")
}
}
Spacer()
Button { viewModel.send(viewAction: .selectServer) } label: {
Text(VectorL10n.edit)
.font(theme.fonts.body)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent))
}
}
AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress,
description: viewModel.viewState.serverDescription) {
viewModel.send(viewAction: .selectServer)
}
}

View file

@ -15,6 +15,7 @@
//
import SwiftUI
import CommonKit
protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable {
var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set }
@ -29,6 +30,9 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
private let onboardingSplashScreenHostingController: VectorHostingController
private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
@ -43,6 +47,8 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
onboardingSplashScreenViewModel = viewModel
onboardingSplashScreenHostingController = VectorHostingController(rootView: view)
onboardingSplashScreenHostingController.vc_removeBackTitle()
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingSplashScreenHostingController)
}
// MARK: - Public
@ -52,13 +58,33 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
MXLog.debug("[OnboardingSplashScreenCoordinator] OnboardingSplashScreenViewModel did complete with result: \(result).")
guard let self = self else { return }
switch result {
case .login, .register:
case .login:
self.startLoading()
self.completion?(result)
case .register:
self.completion?(result)
}
}
}
func toPresentable() -> UIViewController {
return self.onboardingSplashScreenHostingController
return onboardingSplashScreenHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}