Update use case, registration and login screens.

Remove "Custom server" button from use case screen.
Remove matrix.org description.
Add username availability to registration screen.
This commit is contained in:
Doug 2022-07-13 13:39:27 +01:00 committed by Doug
parent 26bdc9af06
commit ec8100383f
24 changed files with 224 additions and 208 deletions

View file

@ -22,17 +22,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_username" = "Username";
"authentication_registration_username_footer" = "You cant change this later";
/* The placeholder will show the full Matrix ID that has been entered. */
"authentication_registration_username_footer_available" = "Others can discover you %@";
"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_info_title" = "Where your conversations will live";
"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.";
@ -46,6 +46,7 @@
"authentication_verify_email_input_message" = "This will help verify your account and enables password recovery.";
"authentication_verify_email_text_field_placeholder" = "Email Address";
"authentication_verify_email_waiting_title" = "Check your email to verify.";
/* The placeholder will show the email address that was entered. */
"authentication_verify_email_waiting_message" = "To confirm your email address, tap the button in the email we just sent to %@";
"authentication_verify_email_waiting_hint" = "Did not receive an email?";
"authentication_verify_email_waiting_button" = "Resend email";
@ -54,6 +55,7 @@
"authentication_forgot_password_input_message" = "We will send you a verification link.";
"authentication_forgot_password_text_field_placeholder" = "Email Address";
"authentication_forgot_password_waiting_title" = "Check your email";
/* The placeholder will show the email address that was entered. */
"authentication_forgot_password_waiting_message" = "To confirm your email address, tap the button in the email we just sent to %@";
"authentication_forgot_password_waiting_hint" = "Did not receive an email?";
"authentication_forgot_password_waiting_button" = "Resend email";
@ -69,6 +71,7 @@
"authentication_verify_msisdn_text_field_placeholder" = "Phone Number";
"authentication_verify_msisdn_otp_text_field_placeholder" = "Verification Code";
"authentication_verify_msisdn_waiting_title" = "Confirm your phone number";
/* The placeholder will show the phone number that was entered. */
"authentication_verify_msisdn_waiting_message" = "We just sent a code to %@. Enter it below to verify its you.";
"authentication_verify_msisdn_waiting_button" = "Resend code";
"authentication_verify_msisdn_invalid_phone_number" = "Invalid phone number";
@ -82,7 +85,9 @@
// MARK: Password Validation
"password_validation_info_header" = "Your password should meet the criteria below:";
"password_validation_error_header" = "Given password does not meet the criteria below:";
/* The placeholder will show a number */
"password_validation_error_min_length" = "At least %d characters.";
/* The placeholder will show a number */
"password_validation_error_max_length" = "Not exceed %d characters.";
"password_validation_error_contain_lowercase_letter" = "Contain a lower-case letter.";
"password_validation_error_contain_uppercase_letter" = "Contain an upper-case letter.";

View file

@ -114,8 +114,8 @@
"onboarding_use_case_work_messaging" = "Teams";
"onboarding_use_case_community_messaging" = "Communities";
/* The placeholder string contains onboarding_use_case_skip_button as a tappable action */
"onboarding_use_case_not_sure_yet" = "Not sure yet? You can %@";
"onboarding_use_case_skip_button" = "skip this question";
"onboarding_use_case_not_sure_yet" = "Not sure yet? %@";
"onboarding_use_case_skip_button" = "Skip this question";
"onboarding_use_case_existing_server_message" = "Looking to join an existing server?";
"onboarding_use_case_existing_server_button" = "Connect to server";

View file

@ -4047,7 +4047,7 @@ public class VectorL10n: NSObject {
public static var onboardingUseCaseMessage: String {
return VectorL10n.tr("Vector", "onboarding_use_case_message")
}
/// Not sure yet? You can %@
/// Not sure yet? %@
public static func onboardingUseCaseNotSureYet(_ p1: String) -> String {
return VectorL10n.tr("Vector", "onboarding_use_case_not_sure_yet", p1)
}
@ -4055,7 +4055,7 @@ public class VectorL10n: NSObject {
public static var onboardingUseCasePersonalMessaging: String {
return VectorL10n.tr("Vector", "onboarding_use_case_personal_messaging")
}
/// skip this question
/// Skip this question
public static var onboardingUseCaseSkipButton: String {
return VectorL10n.tr("Vector", "onboarding_use_case_skip_button")
}

View file

@ -78,10 +78,6 @@ public extension VectorL10n {
static var authenticationRecaptchaMessage: String {
return VectorL10n.tr("Untranslated", "authentication_recaptcha_message")
}
/// Well need some info to get you set up.
static var authenticationRegistrationMessage: String {
return VectorL10n.tr("Untranslated", "authentication_registration_message")
}
/// Must be 8 characters or more
static var authenticationRegistrationPasswordFooter: String {
return VectorL10n.tr("Untranslated", "authentication_registration_password_footer")
@ -98,11 +94,11 @@ 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")
/// Others can discover you %@
static func authenticationRegistrationUsernameFooterAvailable(_ p1: String) -> String {
return VectorL10n.tr("Untranslated", "authentication_registration_username_footer_available", p1)
}
/// Choose your server to store your data
/// Where your conversations will live
static var authenticationServerInfoTitle: String {
return VectorL10n.tr("Untranslated", "authentication_server_info_title")
}

View file

@ -32,7 +32,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
enum EntryPoint {
case registration
case selectServerForRegistration
case login
}
@ -131,15 +130,13 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
}
let flow: AuthenticationFlow = initialScreen == .login ? .login : .register
if initialScreen != .selectServerForRegistration {
do {
let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address
try await authenticationService.startFlow(flow, for: homeserverAddress)
} catch {
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
displayError(message: error.localizedDescription)
return
}
do {
let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address
try await authenticationService.startFlow(flow, for: homeserverAddress)
} catch {
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
displayError(message: error.localizedDescription)
return
}
switch initialScreen {
@ -149,8 +146,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
} else {
showRegistrationScreen()
}
case .selectServerForRegistration:
showServerSelectionScreen()
case .login:
if authenticationService.state.homeserver.needsLoginFallback {
showFallback(for: flow)
@ -312,6 +307,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
// MARK: - Registration
#warning("Unused.")
/// Pushes the server selection screen into the flow (other screens may also present it modally later).
@MainActor private func showServerSelectionScreen() {
MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen")
@ -788,15 +784,9 @@ extension AuthenticationCoordinator: UIAdaptivePresentationControllerDelegate {
// MARK: - Unused conformances
extension AuthenticationCoordinator {
var customServerFieldsVisible: Bool {
get { false }
set { /* no-op */ }
}
func update(authenticationFlow: AuthenticationFlow) {
// unused
}
}
// MARK: - AuthFallBackViewControllerDelegate

View file

@ -36,9 +36,6 @@ enum AuthenticationCoordinatorResult {
protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable {
var callback: ((AuthenticationCoordinatorResult) -> Void)? { get set }
/// Whether the custom homeserver checkbox is enabled for the user to enter a homeserver URL.
var customServerFieldsVisible: Bool { get set }
/// Update the screen to display registration or login.
func update(authenticationFlow: AuthenticationFlow)

View file

@ -48,13 +48,6 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
var childCoordinators: [Coordinator] = []
var callback: ((AuthenticationCoordinatorResult) -> Void)?
var customServerFieldsVisible = false {
didSet {
guard customServerFieldsVisible != oldValue else { return }
authenticationViewController.setCustomServerFieldsVisible(customServerFieldsVisible)
}
}
// MARK: - Setup
init(parameters: LegacyAuthenticationCoordinatorParameters) {

View file

@ -205,11 +205,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
return
}
if result == .customServer {
beginAuthentication(with: .selectServerForRegistration, onStart: coordinator.stop)
} else {
beginAuthentication(with: .registration, onStart: coordinator.stop)
}
beginAuthentication(with: .registration, onStart: coordinator.stop)
}
// MARK: - Authentication
@ -266,8 +262,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
}
}
coordinator.customServerFieldsVisible = useCaseResult == .customServer
authenticationCoordinator = coordinator
coordinator.start()
@ -630,7 +624,7 @@ extension OnboardingSplashScreenViewModelResult {
extension OnboardingUseCaseViewModelResult {
/// The result converted into the type stored in the user session.
var userSessionPropertyValue: UserSessionProperties.UseCase? {
var userSessionPropertyValue: UserSessionProperties.UseCase {
switch self {
case .personalMessaging:
return .personalMessaging
@ -640,8 +634,6 @@ extension OnboardingUseCaseViewModelResult {
return .communityMessaging
case .skipped:
return .skipped
case .customServer:
return nil
}
}
}

View file

@ -20,8 +20,6 @@ import Foundation
struct AuthenticationHomeserverViewData: Equatable {
/// The homeserver string to be shown to the user.
let address: String
/// Whether or not the homeserver is matrix.org.
let isMatrixDotOrg: Bool
/// Whether or not to display the username and password text fields during login.
let showLoginForm: Bool
/// Whether or not to display the username and password text fields during registration.
@ -36,7 +34,6 @@ extension AuthenticationHomeserverViewData {
/// A mock homeserver that is configured just like matrix.org.
static var mockMatrixDotOrg: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "matrix.org",
isMatrixDotOrg: true,
showLoginForm: true,
showRegistrationForm: true,
ssoIdentityProviders: [
@ -51,7 +48,6 @@ extension AuthenticationHomeserverViewData {
/// A mock homeserver that supports login and registration via a password but has no SSO providers.
static var mockBasicServer: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "example.com",
isMatrixDotOrg: false,
showLoginForm: true,
showRegistrationForm: true,
ssoIdentityProviders: [])
@ -60,7 +56,6 @@ extension AuthenticationHomeserverViewData {
/// A mock homeserver that supports only supports authentication via a single SSO provider.
static var mockEnterpriseSSO: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "company.com",
isMatrixDotOrg: false,
showLoginForm: false,
showRegistrationForm: false,
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
@ -69,7 +64,6 @@ extension AuthenticationHomeserverViewData {
/// A mock homeserver that supports only supports authentication via fallback.
static var mockFallback: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "company.com",
isMatrixDotOrg: false,
showLoginForm: false,
showRegistrationForm: false,
ssoIdentityProviders: [])

View file

@ -27,7 +27,6 @@ struct AuthenticationServerInfoSection: View {
// MARK: - Public
let address: String
let showMatrixDotOrgInfo: Bool
let editAction: () -> Void
// MARK: - Views
@ -39,18 +38,9 @@ struct AuthenticationServerInfoSection: View {
.foregroundColor(theme.colors.secondaryContent)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(address)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
if showMatrixDotOrgInfo {
Text(VectorL10n.authenticationServerInfoMatrixDescription)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.tertiaryContent)
.accessibilityIdentifier("serverDescriptionText")
}
}
Text(address)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
Spacer()

View file

@ -65,7 +65,6 @@ struct AuthenticationState {
/// The homeserver mapped into view data that is ready for display.
var viewData: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: displayableAddress,
isMatrixDotOrg: isMatrixDotOrg,
showLoginForm: preferredLoginMode.supportsPasswordFlow,
showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback,
ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? [])

View file

@ -32,12 +32,10 @@ class AuthenticationLoginUITests: MockScreenTest {
switch screenState {
case .matrixDotOrg:
let state = "matrix.org"
validateServerDescriptionIsVisible(for: state)
validateLoginFormIsVisible(for: state)
validateSSOButtonsAreShown(for: state)
case .passwordOnly:
let state = "a password only server"
validateServerDescriptionIsHidden(for: state)
validateLoginFormIsVisible(for: state)
validateSSOButtonsAreHidden(for: state)
@ -47,7 +45,6 @@ class AuthenticationLoginUITests: MockScreenTest {
validateNextButtonIsEnabled(for: state)
case .ssoOnly:
let state = "an SSO only server"
validateServerDescriptionIsHidden(for: state)
validateLoginFormIsHidden(for: state)
validateSSOButtonsAreShown(for: state)
case .fallback:
@ -56,20 +53,6 @@ class AuthenticationLoginUITests: MockScreenTest {
}
}
/// Checks that the server description label is shown.
func validateServerDescriptionIsVisible(for state: String) {
let descriptionLabel = app.staticTexts["serverDescriptionText"]
XCTAssertTrue(descriptionLabel.exists, "The server description should be shown for \(state).")
XCTAssertEqual(descriptionLabel.label, VectorL10n.authenticationServerInfoMatrixDescription, "The server description should be correct for \(state).")
}
/// Checks that the server description label is hidden.
func validateServerDescriptionIsHidden(for state: String) {
let descriptionLabel = app.staticTexts["serverDescriptionText"]
XCTAssertFalse(descriptionLabel.exists, "The server description should be shown for \(state).")
}
/// Checks that the username and password text fields are shown along with the next button.
func validateLoginFormIsVisible(for state: String) {
let usernameTextField = app.textFields.element

View file

@ -37,15 +37,16 @@ struct AuthenticationLoginScreen: View {
VStack(spacing: 0) {
header
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 36)
.padding(.bottom, 28)
serverInfo
.padding(.leading, 12)
.padding(.bottom, 16)
Rectangle()
.fill(theme.colors.quinaryContent)
.frame(height: 1)
.padding(.vertical, 21)
.padding(.bottom, 22)
if viewModel.viewState.homeserver.showLoginForm {
loginForm
@ -86,8 +87,7 @@ struct AuthenticationLoginScreen: View {
/// The sever information section that includes a button to select a different server.
var serverInfo: some View {
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address,
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address) {
viewModel.send(viewAction: .selectServer)
}
}

View file

@ -50,6 +50,15 @@ enum AuthenticationRegistrationViewModelResult: CustomStringConvertible {
// MARK: View
struct AuthenticationRegistrationViewState: BindableState {
enum UsernameAvailability {
/// The availability of the username is unknown.
case unknown
/// The username is available.
case available
/// The username is invalid for the following reason.
case invalid(String)
}
/// Data about the selected homeserver.
var homeserver: AuthenticationHomeserverViewData
/// View state that can be bound to from SwiftUI.
@ -63,12 +72,20 @@ struct AuthenticationRegistrationViewState: BindableState {
/// This is used to delay showing an error state until the user has tried 1 password.
var hasEditedPassword = false
/// An error message to be shown in the username text field footer.
var usernameErrorMessage: String?
/// The availability of the currently enetered username.
var usernameAvailability: UsernameAvailability = .unknown
/// The message to show in the username text field footer.
var usernameFooterMessage: String {
usernameErrorMessage ?? VectorL10n.authenticationRegistrationUsernameFooter
switch usernameAvailability {
case .unknown:
return VectorL10n.authenticationRegistrationUsernameFooter
case .invalid(let errorMessage):
return errorMessage
case .available:
let userID = "@\(bindings.username):\(homeserver.address)"
return VectorL10n.authenticationRegistrationUsernameFooterAvailable(userID)
}
}
/// Whether to show any SSO buttons.
@ -76,19 +93,23 @@ struct AuthenticationRegistrationViewState: BindableState {
!homeserver.ssoIdentityProviders.isEmpty
}
/// Whether the current `username` is valid.
var isUsernameValid: Bool {
!bindings.username.isEmpty && usernameErrorMessage == nil
/// Whether the current `username` is invalid.
var isUsernameInvalid: Bool {
if case .invalid = usernameAvailability {
return true
} else {
return bindings.username.isEmpty
}
}
/// Whether the current `password` is valid.
var isPasswordValid: Bool {
bindings.password.count >= 8
/// Whether the current `password` is invalid.
var isPasswordInvalid: Bool {
bindings.password.count < 8
}
/// `true` if it is possible to continue, otherwise `false`.
var hasValidCredentials: Bool {
isUsernameValid && isPasswordValid
!isUsernameInvalid && !isPasswordInvalid
}
}
@ -108,8 +129,8 @@ enum AuthenticationRegistrationViewAction {
case validateUsername
/// Allows password validation to take place (sent after editing the password for the first time).
case enablePasswordValidation
/// Clear any errors being shown in the username text field footer.
case clearUsernameError
/// Clear any availability messages being shown in the username text field footer.
case resetUsernameAvailability
/// Continue using the input username and password.
case next
/// Continue using the supplied SSO provider.

View file

@ -48,8 +48,8 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
Task { await validateUsername() }
case .enablePasswordValidation:
Task { await enablePasswordValidation() }
case .clearUsernameError:
Task { await clearUsernameError() }
case .resetUsernameAvailability:
Task { await resetUsernameAvailability() }
case .next:
Task { await callback?(.createAccount(username: state.bindings.username, password: state.bindings.password)) }
case .continueWithSSO(let provider):
@ -63,10 +63,15 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
state.homeserver = homeserver
}
@MainActor func confirmUsernameAvailability(_ username: String) {
guard username == state.bindings.username else { return }
state.usernameAvailability = .available
}
@MainActor func displayError(_ type: AuthenticationRegistrationErrorType) {
switch type {
case .usernameUnavailable(let message):
state.usernameErrorMessage = message
state.usernameAvailability = .invalid(message)
case .mxError(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
@ -101,9 +106,9 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
state.hasEditedPassword = true
}
/// Clear any errors being shown in the username text field footer.
@MainActor private func clearUsernameError() {
guard state.usernameErrorMessage != nil else { return }
state.usernameErrorMessage = nil
/// Reset the username's availability, clearing any messages being shown in the username text field footer.
@MainActor private func resetUsernameAvailability() {
if case .unknown = state.usernameAvailability { return }
state.usernameAvailability = .unknown
}
}

View file

@ -25,6 +25,10 @@ protocol AuthenticationRegistrationViewModelProtocol {
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
@MainActor func update(homeserver: AuthenticationHomeserverViewData)
/// Update the view to confirm that the chosen username is available.
/// - Parameter username: The username that was checked.
@MainActor func confirmUsernameAvailability(_ username: String)
/// Display an error to the user.
/// - Parameter type: The type of error to be displayed.
@MainActor func displayError(_ type: AuthenticationRegistrationErrorType)

View file

@ -149,6 +149,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
currentTask = Task {
do {
_ = try await registrationWizard.registrationAvailable(username: username)
authenticationRegistrationViewModel.confirmUsernameAvailability(username)
} catch {
guard !Task.isCancelled, let mxError = MXError(nsError: error as NSError) else { return }
if mxError.errcode == kMXErrCodeStringUserInUse

View file

@ -47,6 +47,7 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable {
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
viewModel.context.username = "alice"
viewModel.context.password = "password"
Task { await viewModel.confirmUsernameAvailability("alice") }
case .passwordWithUsernameError:
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
viewModel.state.hasEditedUsername = true

View file

@ -36,7 +36,8 @@ class AuthenticationRegistrationUITests: MockScreenTest {
validateSSOButtonsAreShown(for: state)
validateFallbackButtonIsHidden(for: state)
validateNoErrorsAreShown(for: state)
validateUnknownUsernameAvailability(for: state)
validateNoPasswordErrorsAreShown(for: state)
case .passwordOnly:
let state = "a password only server"
validateRegistrationFormIsVisible(for: state)
@ -45,7 +46,8 @@ class AuthenticationRegistrationUITests: MockScreenTest {
validateNextButtonIsDisabled(for: state)
validateNoErrorsAreShown(for: state)
validateUnknownUsernameAvailability(for: state)
validateNoPasswordErrorsAreShown(for: state)
case .passwordWithCredentials:
let state = "a password only server with credentials entered"
validateRegistrationFormIsVisible(for: state)
@ -54,7 +56,8 @@ class AuthenticationRegistrationUITests: MockScreenTest {
validateNextButtonIsEnabled(for: state)
validateNoErrorsAreShown(for: state)
validateUsernameAvailable(for: state)
validateNoPasswordErrorsAreShown(for: state)
case .passwordWithUsernameError:
let state = "a password only server with an invalid username"
validateRegistrationFormIsVisible(for: state)
@ -147,15 +150,24 @@ class AuthenticationRegistrationUITests: MockScreenTest {
XCTAssertEqual(usernameFooter.label, VectorL10n.authInvalidUserName, "The username footer should be showing an error for \(state).")
}
/// Checks that neither the username or password text field footers are showing an error.
func validateNoErrorsAreShown(for state: String) {
func validateUsernameAvailable(for state: String) {
let usernameFooter = textFieldFooter(for: "usernameTextField")
XCTAssertTrue(usernameFooter.exists, "The username footer should be shown for \(state).")
XCTAssertTrue(usernameFooter.label.starts(with: VectorL10n.authenticationRegistrationUsernameFooterAvailable("")),
"The username footer should be showing the username as available for \(state).")
}
func validateUnknownUsernameAvailability(for state: String) {
let usernameFooter = textFieldFooter(for: "usernameTextField")
let passwordFooter = textFieldFooter(for: "passwordTextField")
XCTAssertTrue(usernameFooter.exists, "The username footer should be shown for \(state).")
XCTAssertTrue(passwordFooter.exists, "The password footer should be shown for \(state).")
XCTAssertEqual(usernameFooter.label, VectorL10n.authenticationRegistrationUsernameFooter,
"The username footer should be showing the default message for \(state).")
}
/// Checks that neither the username or password text field footers are showing an error.
func validateNoPasswordErrorsAreShown(for state: String) {
let passwordFooter = textFieldFooter(for: "passwordTextField")
XCTAssertTrue(passwordFooter.exists, "The password footer should be shown for \(state).")
XCTAssertEqual(passwordFooter.label, VectorL10n.authenticationRegistrationPasswordFooter,
"The password footer should be showing the default message for \(state).")
}

View file

@ -63,40 +63,89 @@ import Combine
}
func testUsernameError() async throws {
// Given a form with a valid username.
// Given a form with an entered username.
context.username = "bob"
XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error when the view model is created.")
XCTAssertEqual(context.viewState.usernameAvailability, .unknown, "The username availability should be unknown when the view model is created.")
XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown.")
XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid if there is no error.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid if there is no error.")
// When displaying the error as a username error.
let errorMessage = "Username unavailable"
viewModel.displayError(.usernameUnavailable(errorMessage))
// Then the error should be shown in the footer.
XCTAssertEqual(context.viewState.usernameErrorMessage, errorMessage, "The error message should be stored.")
guard case let .invalid(displayedError) = context.viewState.usernameAvailability else {
XCTFail("The username should be invalid when an error is shown.")
return
}
XCTAssertEqual(displayedError, errorMessage, "The error message should match.")
XCTAssertEqual(context.viewState.usernameFooterMessage, errorMessage, "The error message should replace the standard footer message.")
XCTAssertFalse(context.viewState.isUsernameValid, "The username should be invalid when an error is shown.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "The username should be invalid when an error is shown.")
// When clearing the error.
context.send(viewAction: .clearUsernameError)
context.send(viewAction: .resetUsernameAvailability)
// Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors.
try await Task.sleep(nanoseconds: 100_000_000)
await Task.yield()
// Then the error should be hidden again.
XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error anymore.")
XCTAssertEqual(context.viewState.usernameAvailability, .unknown, "The username availability should return to an unknown state.")
XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown again.")
XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when an error is cleared.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid when an error is cleared.")
}
func testUsernameAvailability() async throws {
// Given a form with an entered username.
context.username = "bob"
XCTAssertEqual(context.viewState.usernameAvailability, .unknown, "The username availability should be unknown when the view model is created.")
XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid if there is no error.")
// When updating the state for an available username
viewModel.confirmUsernameAvailability("bob")
// Then the error should be shown in the footer.
XCTAssertEqual(context.viewState.usernameAvailability, .available,
"The username should be detected as available.")
XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooterAvailable("@bob:matrix.org"),
"The footer message should display that the username is available.")
XCTAssertFalse(context.viewState.isUsernameInvalid,
"The username should continue to be valid when it is available.")
// When clearing the error.
context.send(viewAction: .resetUsernameAvailability)
// Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors.
await Task.yield()
// Then the error should be hidden again.
XCTAssertEqual(context.viewState.usernameAvailability, .unknown, "The username availability should return to an unknown state.")
XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown again.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid when an error is cleared.")
}
func testUsernameAvailabilityWhenChanged() async throws {
// Given a form with an entered username.
context.username = "robert"
XCTAssertEqual(context.viewState.usernameAvailability, .unknown, "The username availability should be unknown when the view model is created.")
XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid if there is no error.")
// When updating the state for an available username that was previously entered.
viewModel.confirmUsernameAvailability("bob")
// Then the username should not be shown as available.
XCTAssertEqual(context.viewState.usernameAvailability, .unknown, "The username availability should not be updated.")
XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should continue to be valid when unverified.")
}
func testEmptyUsernameWithShortPassword() {
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.")
XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.")
XCTAssertTrue(context.viewState.isPasswordInvalid, "An empty password should be invalid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a password of 7 characters without a username.
@ -104,8 +153,8 @@ import Combine
context.password = "1234567"
// Then the credentials should remain invalid.
XCTAssertFalse(context.viewState.isPasswordValid, "A 7-character password should be invalid.")
XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.")
XCTAssertTrue(context.viewState.isPasswordInvalid, "A 7-character password should be invalid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
}
@ -113,8 +162,8 @@ import Combine
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.")
XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.")
XCTAssertTrue(context.viewState.isPasswordInvalid, "An empty password should be invalid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a password of 8 characters without a username.
@ -122,8 +171,8 @@ import Combine
context.password = "12345678"
// Then the password should be valid but the credentials should still be invalid.
XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.")
XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.isPasswordInvalid, "An 8-character password should be valid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
}
@ -131,8 +180,8 @@ import Combine
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.")
XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.")
XCTAssertTrue(context.viewState.isPasswordInvalid, "An empty password should be invalid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a username without a password.
@ -140,8 +189,8 @@ import Combine
context.password = ""
// Then the username should be valid but the credentials should still be invalid.
XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.")
XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when there is no error.")
XCTAssertTrue(context.viewState.isPasswordInvalid, "An empty password should be invalid.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid when unverified.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
}
@ -149,8 +198,8 @@ import Combine
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.")
XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.")
XCTAssertTrue(context.viewState.isPasswordInvalid, "An empty password should be invalid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a username and password and encountering a username error
@ -161,8 +210,8 @@ import Combine
viewModel.displayError(.usernameUnavailable(errorMessage))
// Then the password should be valid but the credentials should still be invalid.
XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.")
XCTAssertFalse(context.viewState.isUsernameValid, "The username should be invalid when an error is shown.")
XCTAssertFalse(context.viewState.isPasswordInvalid, "An 8-character password should be valid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "The username should be invalid when an error is shown.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
}
@ -170,8 +219,8 @@ import Combine
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.")
XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.")
XCTAssertTrue(context.viewState.isPasswordInvalid, "An empty password should be invalid.")
XCTAssertTrue(context.viewState.isUsernameInvalid, "An empty username should be invalid.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a username and an 8-character password.
@ -179,8 +228,24 @@ import Combine
context.password = "12345678"
// Then the credentials should be considered valid.
XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.")
XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when there is no error.")
XCTAssertFalse(context.viewState.isPasswordInvalid, "An 8-character password should be valid.")
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid when unverified.")
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.")
}
}
extension AuthenticationRegistrationViewState.UsernameAvailability: Equatable {
public static func == (lhs: AuthenticationRegistrationViewState.UsernameAvailability,
rhs: AuthenticationRegistrationViewState.UsernameAvailability) -> Bool {
switch (lhs, rhs) {
case (.unknown, .unknown):
return true
case (.available, .available):
return true
case (.invalid, .invalid):
return true
default:
return false
}
}
}

View file

@ -35,15 +35,16 @@ struct AuthenticationRegistrationScreen: View {
VStack(spacing: 0) {
header
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 36)
.padding(.bottom, 28)
serverInfo
.padding(.leading, 12)
.padding(.bottom, 16)
Rectangle()
.fill(theme.colors.quinaryContent)
.frame(height: 1)
.padding(.vertical, 21)
.padding(.bottom, 22)
if viewModel.viewState.homeserver.showRegistrationForm {
registrationForm
@ -84,18 +85,12 @@ struct AuthenticationRegistrationScreen: View {
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
Text(VectorL10n.authenticationRegistrationMessage)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
}
}
/// The sever information section that includes a button to select a different server.
var serverInfo: some View {
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address,
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address) {
viewModel.send(viewAction: .selectServer)
}
}
@ -107,21 +102,21 @@ struct AuthenticationRegistrationScreen: View {
placeHolder: VectorL10n.authenticationRegistrationUsername,
text: $viewModel.username,
footerText: viewModel.viewState.usernameFooterMessage,
isError: viewModel.viewState.hasEditedUsername && !viewModel.viewState.isUsernameValid,
isError: viewModel.viewState.hasEditedUsername && viewModel.viewState.isUsernameInvalid,
isFirstResponder: false,
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
autocapitalizationType: .none,
autocorrectionType: .no),
onEditingChanged: usernameEditingChanged,
onCommit: { isPasswordFocused = true })
.onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .clearUsernameError) }
.onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .resetUsernameAvailability) }
.accessibilityIdentifier("usernameTextField")
RoundedBorderTextField(title: nil,
placeHolder: VectorL10n.authPasswordPlaceholder,
text: $viewModel.password,
footerText: VectorL10n.authenticationRegistrationPasswordFooter,
isError: viewModel.viewState.hasEditedPassword && !viewModel.viewState.isPasswordValid,
isError: viewModel.viewState.hasEditedPassword && viewModel.viewState.isPasswordInvalid,
isFirstResponder: isPasswordFocused,
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
isSecureTextEntry: true),

View file

@ -57,9 +57,7 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
MXLog.debug("[OnboardingUseCaseSelectionCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).")
// Show a loading indicator which can be dismissed externally by calling `stop`.
if result != .customServer {
self.startLoading()
}
self.startLoading()
self.completion?(result)
}
}

View file

@ -29,7 +29,6 @@ enum OnboardingUseCaseViewModelResult {
case workMessaging
case communityMessaging
case skipped
case customServer
}
// MARK: View

View file

@ -29,6 +29,23 @@ struct OnboardingUseCaseSelectionScreen: View {
@ObservedObject var viewModel: OnboardingUseCaseViewModel.Context
var body: some View {
ScrollView {
VStack(spacing: 0) {
titleContent
.padding(.bottom, 36)
useCaseButtons
}
.readableFrame()
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 8)
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
.accentColor(theme.colors.accent)
}
/// The screen's title and instructions.
var titleContent: some View {
VStack(spacing: 8) {
@ -76,47 +93,6 @@ struct OnboardingUseCaseSelectionScreen: View {
.padding(.top, 8)
}
}
/// A footer showing a button to connect to a server.
var serverFooter: some View {
VStack(spacing: 14) {
Text(VectorL10n.onboardingUseCaseExistingServerMessage)
.font(theme.fonts.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.tertiaryContent)
Button { viewModel.send(viewAction: .answer(.customServer)) } label: {
Text(VectorL10n.onboardingUseCaseExistingServerButton)
.font(theme.fonts.body)
}
}
}
var body: some View {
GeometryReader { geometry in
VStack {
ScrollView {
VStack(spacing: 0) {
titleContent
.padding(.bottom, 36)
useCaseButtons
}
.readableFrame()
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 8)
.padding(.horizontal, 16)
}
serverFooter
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
}
.background(theme.colors.background.ignoresSafeArea())
.accentColor(theme.colors.accent)
}
}
// MARK: - Previews