diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 35bd7f30d..04f942ae1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt index 6d11c9e34..da89bcbf2 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt @@ -130,12 +130,17 @@ class MainActivity : ComponentActivity() { Routes.InvitationCodeScreen ) - is OnboardingNavigationEvent.OnboardingScreen -> navHostController.navigate(Routes.OnboardingScreen) + is OnboardingNavigationEvent.OnboardingScreen -> navHostController.navigate( + Routes.OnboardingScreen + ) + is OnboardingNavigationEvent.SequentialOnboardingScreen -> navHostController.navigate( Routes.SequentialOnboardingScreen ) - is OnboardingNavigationEvent.ConsentScreen -> navHostController.navigate(Routes.ConsentScreen) + is OnboardingNavigationEvent.ConsentScreen -> navHostController.navigate( + Routes.ConsentScreen + ) } } } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/validated/outlinedtextfield/ValidatedOutlinedTextField.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/validated/outlinedtextfield/ValidatedOutlinedTextField.kt index e14a6f883..da01eb59c 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/validated/outlinedtextfield/ValidatedOutlinedTextField.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/validated/outlinedtextfield/ValidatedOutlinedTextField.kt @@ -2,6 +2,7 @@ package edu.stanford.spezi.core.design.component.validated.outlinedtextfield import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -9,11 +10,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import edu.stanford.spezi.core.design.theme.Colors -import edu.stanford.spezi.core.design.theme.TextStyles.labelSmall +import edu.stanford.spezi.core.utils.ComposableBlock @Composable fun ValidatedOutlinedTextField( @@ -22,6 +23,12 @@ fun ValidatedOutlinedTextField( onValueChange: (String) -> Unit, labelText: String = "", errorText: String? = null, + singleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + visualTransformation: VisualTransformation = VisualTransformation.None, + readOnly: Boolean = false, + trailingIcon: ComposableBlock? = null, + keyboardActions: KeyboardActions = KeyboardActions.Default, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { OutlinedTextField( @@ -31,17 +38,17 @@ fun ValidatedOutlinedTextField( onValueChange(it) }, label = { Text(labelText) }, - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + singleLine = singleLine, + keyboardOptions = keyboardOptions, isError = errorText != null, + readOnly = readOnly, + trailingIcon = trailingIcon, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + supportingText = errorText?.let { + { Text(text = it) } + }, ) - if (errorText != null) { - Text( - text = errorText, - style = labelSmall, - color = Colors.error - ) - } } } diff --git a/core/design/src/main/res/drawable/ic_visibility.xml b/core/design/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 000000000..01604e57f --- /dev/null +++ b/core/design/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/design/src/main/res/drawable/ic_visibility_off.xml b/core/design/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 000000000..39550b7dd --- /dev/null +++ b/core/design/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialLoginManagerAuth.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialLoginManagerAuth.kt index dd2d06f64..7855eed52 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialLoginManagerAuth.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialLoginManagerAuth.kt @@ -1,7 +1,6 @@ package edu.stanford.spezi.module.account.cred.manager import android.content.Context -import androidx.credentials.CreatePasswordRequest import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest @@ -33,13 +32,10 @@ class CredentialLoginManagerAuth @Inject constructor( suspend fun handlePasswordSignIn( username: String, password: String, - ): Boolean { - val createPasswordRequest = CreatePasswordRequest(id = username, password = password) - val createCredential = credentialManager.createCredential(context, createPasswordRequest) - if (createCredential.type == PasswordCredential.TYPE_PASSWORD_CREDENTIAL) { - return firebaseAuthManager.signInWithEmailAndPassword(username, password) + ): Result { + return runCatching { + firebaseAuthManager.signInWithEmailAndPassword(username, password) } - return false } private suspend fun getCredential(filterByAuthorizedAccounts: Boolean): GoogleIdTokenCredential? { @@ -67,7 +63,8 @@ class CredentialLoginManagerAuth @Inject constructor( when (val credential = response.credential) { is CustomCredential -> { if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { - val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + val googleIdTokenCredential = + GoogleIdTokenCredential.createFrom(credential.data) return googleIdTokenCredential } if (credential.type == PasswordCredential.TYPE_PASSWORD_CREDENTIAL) { diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginFormValidator.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginFormValidator.kt new file mode 100644 index 000000000..23222cfcc --- /dev/null +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginFormValidator.kt @@ -0,0 +1,12 @@ +package edu.stanford.spezi.module.account.login + +import edu.stanford.spezi.module.account.register.FormValidator +import javax.inject.Inject + +internal class LoginFormValidator @Inject constructor() : FormValidator() { + + fun isFormValid(uiState: UiState): Boolean { + return isValidEmail(uiState.email.value).isValid && + isValidPassword(uiState.password.value).isValid + } +} diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt index 7df2ec673..b521bffab 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt @@ -2,6 +2,7 @@ package edu.stanford.spezi.module.account.login +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,11 +10,18 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.Button -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -21,6 +29,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -28,6 +39,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.hilt.navigation.compose.hiltViewModel +import edu.stanford.spezi.core.design.component.validated.outlinedtextfield.ValidatedOutlinedTextField import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.SpeziTheme import edu.stanford.spezi.core.design.theme.TextStyles.bodyLarge @@ -35,6 +47,9 @@ import edu.stanford.spezi.core.design.theme.TextStyles.titleLarge import edu.stanford.spezi.core.utils.extensions.testIdentifier import edu.stanford.spezi.module.account.login.components.SignInWithGoogleButton import edu.stanford.spezi.module.account.login.components.TextDivider +import edu.stanford.spezi.module.account.register.FieldState +import edu.stanford.spezi.module.account.register.IconLeadingContent +import edu.stanford.spezi.core.design.R as DesignR @Composable fun LoginScreen( @@ -54,11 +69,23 @@ internal fun LoginScreen( uiState: UiState, onAction: (Action) -> Unit, ) { + val keyboardController = LocalSoftwareKeyboardController.current + Column( modifier = Modifier .testIdentifier(LoginScreenTestIdentifier.ROOT) .fillMaxSize() - .padding(Spacings.medium), + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(Spacings.medium) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + println("Hide Keyboard") + keyboardController?.hide() + } + ) + }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -74,33 +101,57 @@ You may login to your existing account or create a new one if you don't have one style = bodyLarge, ) Spacer(modifier = Modifier.height(Spacings.large)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.email, - onValueChange = { email -> - onAction(Action.TextFieldUpdate(email, TextFieldType.EMAIL)) - }, - label = { Text("E-Mail Address") }, - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next) - ) + IconLeadingContent( + icon = Icons.Outlined.Email, + content = { + ValidatedOutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.email.value, + errorText = uiState.email.error, + onValueChange = { email -> + onAction(Action.TextFieldUpdate(email, TextFieldType.EMAIL)) + }, + labelText = "E-Mail Address", + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next) + ) + }) Spacer(modifier = Modifier.height(Spacings.small)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.password, - onValueChange = { - onAction(Action.TextFieldUpdate(it, TextFieldType.PASSWORD)) - }, - label = { Text("Password") }, - singleLine = true, - visualTransformation = if (uiState.passwordVisibility) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { onAction(Action.TogglePasswordVisibility) }) - ) + IconLeadingContent( + icon = Icons.Outlined.Lock, + content = { + ValidatedOutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.password.value, + errorText = uiState.password.error, + onValueChange = { + onAction(Action.TextFieldUpdate(it, TextFieldType.PASSWORD)) + }, + labelText = "Password", + visualTransformation = if (uiState.passwordVisibility) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + onAction(Action.PasswordSignInOrSignUp) + }), + trailingIcon = { + IconButton(onClick = { onAction(Action.TogglePasswordVisibility) }) { + val iconId = if (uiState.passwordVisibility) { + DesignR.drawable.ic_visibility + } else { + DesignR.drawable.ic_visibility_off + } + Icon( + painter = painterResource(id = iconId), + contentDescription = if (uiState.passwordVisibility) "Hide password" else "Show password" + ) + } + } + ) + }) TextButton( onClick = { onAction(Action.ForgotPassword) @@ -111,14 +162,10 @@ You may login to your existing account or create a new one if you don't have one Spacer(modifier = Modifier.height(Spacings.medium)) Button( onClick = { - if (uiState.isAlreadyRegistered) { - onAction(Action.PasswordCredentialSignIn) - } else { - onAction(Action.NavigateToRegister) - } + onAction(Action.PasswordSignInOrSignUp) }, modifier = Modifier.fillMaxWidth(), - enabled = uiState.email.isNotEmpty() && uiState.password.isNotEmpty() + enabled = uiState.isPasswordSignInEnabled ) { Text( text = if (uiState.isAlreadyRegistered) "Login" else "Register" @@ -145,11 +192,7 @@ You may login to your existing account or create a new one if you don't have one Spacer(modifier = Modifier.height(Spacings.medium)) SignInWithGoogleButton( onButtonClick = { - if (uiState.isAlreadyRegistered) { - onAction(Action.GoogleSignIn) - } else { - onAction(Action.GoogleSignUp) - } + onAction(Action.GoogleSignInOrSignUp) }, isAlreadyRegistered = uiState.isAlreadyRegistered, ) @@ -169,12 +212,12 @@ private fun LoginScreenPreview( private class LoginScreenPreviewProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( UiState( - email = "", - password = "", + email = FieldState(""), + password = FieldState(""), passwordVisibility = false, ), UiState( - email = "test@test.de", - password = "password", + email = FieldState("test@test.de"), + password = FieldState("password"), passwordVisibility = true, isAlreadyRegistered = true ) diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt index 1f7d04743..b524b0f25 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt @@ -8,6 +8,7 @@ import edu.stanford.spezi.core.utils.MessageNotifier import edu.stanford.spezi.module.account.AccountEvents import edu.stanford.spezi.module.account.AccountNavigationEvent import edu.stanford.spezi.module.account.cred.manager.CredentialLoginManagerAuth +import edu.stanford.spezi.module.account.register.FieldState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -15,58 +16,84 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class LoginViewModel @Inject constructor( +internal class LoginViewModel @Inject constructor( private val navigator: Navigator, private val credentialLoginManagerAuth: CredentialLoginManagerAuth, private val accountEvents: AccountEvents, private val messageNotifier: MessageNotifier, + private val validator: LoginFormValidator, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() + private var hasAttemptedSubmit: Boolean = false + fun onAction(action: Action) { - _uiState.update { - when (action) { - is Action.TextFieldUpdate -> { + when (action) { + is Action.TextFieldUpdate -> { + _uiState.update { updateTextField(action, it) } + } - is Action.TogglePasswordVisibility -> { + is Action.TogglePasswordVisibility -> { + _uiState.update { it.copy(passwordVisibility = !it.passwordVisibility) } + } - is Action.NavigateToRegister -> { - navigateToRegister() - it - } + is Action.NavigateToRegister -> { + navigateToRegister() + } - is Action.GoogleSignIn -> { + is Action.GoogleSignInOrSignUp -> { + if (uiState.value.isAlreadyRegistered) { googleSignIn() - it + } else { + googleSignUp() } + } - is Action.SetIsAlreadyRegistered -> { + is Action.SetIsAlreadyRegistered -> { + _uiState.update { handleIsAlreadyRegistered(action, it) } + } - is Action.PasswordCredentialSignIn -> { - passwordSignIn() - it - } - - is Action.ForgotPassword -> { - forgotPassword() - it - } + is Action.ForgotPassword -> { + forgotPassword() + } - Action.GoogleSignUp -> { - googleSignUp() - it - } + Action.PasswordSignInOrSignUp -> { + handleLoginOrRegister() } } } + private fun handleLoginOrRegister() { + val uiState = _uiState.value + if (uiState.isAlreadyRegistered && validator.isFormValid(uiState)) { + passwordSignIn() + } + + if (!uiState.isAlreadyRegistered && validator.isFormValid(uiState)) { + navigateToRegister() + } + + hasAttemptedSubmit = true + _uiState.update { + it.copy( + email = it.email.copy( + error = validator.isValidEmail(email = uiState.email.value).errorMessageOrNull() + ), + password = it.password.copy( + error = validator.isValidPassword(password = uiState.password.value) + .errorMessageOrNull() + ), + ) + } + } + private fun handleIsAlreadyRegistered( action: Action.SetIsAlreadyRegistered, it: UiState, @@ -85,28 +112,43 @@ class LoginViewModel @Inject constructor( navigator.navigateTo( AccountNavigationEvent.RegisterScreen( isGoogleSignUp = false, - email = uiState.value.email, - password = uiState.value.password, + email = uiState.value.email.value, + password = uiState.value.password.value, ) ) } private fun updateTextField( action: Action.TextFieldUpdate, - it: UiState, + uiState: UiState, ): UiState { - val newValue = action.newValue + val newValue = FieldState(action.newValue) + val result = when (action.type) { + TextFieldType.PASSWORD -> validator.isValidPassword(action.newValue) + TextFieldType.EMAIL -> validator.isValidEmail(action.newValue) + } + val error = + if (hasAttemptedSubmit && result.isValid.not()) result.errorMessageOrNull() else null return when (action.type) { - TextFieldType.PASSWORD -> it.copy(password = newValue) - TextFieldType.EMAIL -> it.copy(email = newValue) + TextFieldType.PASSWORD -> uiState.copy( + password = newValue.copy(error = error), + isFormValid = validator.isFormValid(uiState), + isPasswordSignInEnabled = uiState.email.value.isNotEmpty() && newValue.value.isNotEmpty() + ) + + TextFieldType.EMAIL -> uiState.copy( + email = newValue.copy(error = error), + isFormValid = validator.isFormValid(uiState), + isPasswordSignInEnabled = newValue.value.isNotEmpty() && uiState.password.value.isNotEmpty() + ) } } private fun forgotPassword() { - if (uiState.value.email.isEmpty()) { - messageNotifier.notify("Please enter your email") + if (validator.isValidEmail(uiState.value.email.value).isValid) { + sendForgotPasswordEmail(uiState.value.email.value) } else { - sendForgotPasswordEmail(uiState.value.email) + messageNotifier.notify("Please enter a valid email") } } @@ -114,8 +156,8 @@ class LoginViewModel @Inject constructor( navigator.navigateTo( AccountNavigationEvent.RegisterScreen( isGoogleSignUp = true, - email = uiState.value.email, - password = uiState.value.password + email = uiState.value.email.value, + password = uiState.value.password.value ) ) } @@ -133,10 +175,10 @@ class LoginViewModel @Inject constructor( private fun passwordSignIn() { viewModelScope.launch { val result = credentialLoginManagerAuth.handlePasswordSignIn( - _uiState.value.email, - _uiState.value.password, + _uiState.value.email.value, + _uiState.value.password.value, ) - if (result) { + if (result.isSuccess) { accountEvents.emit(event = AccountEvents.Event.SignInSuccess) } else { accountEvents.emit(event = AccountEvents.Event.SignInFailure) diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/UiState.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/UiState.kt index f8cc16512..cc873e87c 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/UiState.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/UiState.kt @@ -1,12 +1,16 @@ package edu.stanford.spezi.module.account.login +import edu.stanford.spezi.module.account.register.FieldState + data class UiState( - val password: String = "", - val email: String = "", + val password: FieldState = FieldState(), + val email: FieldState = FieldState(), val passwordVisibility: Boolean = false, val showProgress: Boolean = false, val showFilterByAuthorizedAccounts: Boolean = true, + val isFormValid: Boolean = false, val isAlreadyRegistered: Boolean = false, + val isPasswordSignInEnabled: Boolean = false, ) enum class TextFieldType { @@ -17,13 +21,8 @@ sealed interface Action { data class TextFieldUpdate(val newValue: String, val type: TextFieldType) : Action data object TogglePasswordVisibility : Action data object NavigateToRegister : Action - data object GoogleSignIn : Action - - data object GoogleSignUp : Action - + data object GoogleSignInOrSignUp : Action data object ForgotPassword : Action - - data object PasswordCredentialSignIn : Action - + data object PasswordSignInOrSignUp : Action data class SetIsAlreadyRegistered(val isAlreadyRegistered: Boolean) : Action } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/FormValidator.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/FormValidator.kt new file mode 100644 index 000000000..172062abf --- /dev/null +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/FormValidator.kt @@ -0,0 +1,36 @@ +package edu.stanford.spezi.module.account.register + +import androidx.core.util.PatternsCompat + +internal abstract class FormValidator { + + fun isValidEmail(email: String): Result { + return if (PatternsCompat.EMAIL_ADDRESS.matcher(email).matches()) { + Result.Valid + } else { + Result.Invalid("Invalid email") + } + } + + fun isValidPassword(password: String): Result { + return if (password.length >= MIN_PASSWORD_LENGTH) { + Result.Valid + } else { + Result.Invalid("Password must be at least $MIN_PASSWORD_LENGTH characters") + } + } + + internal companion object { + const val MIN_PASSWORD_LENGTH = 6 // Minimum for firebase + } + + sealed interface Result { + data object Valid : Result + data class Invalid(val message: String) : Result + + val isValid: Boolean + get() = this is Valid + + fun errorMessageOrNull() = if (this is Invalid) message else null + } +} diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/IconLeadingContent.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/IconLeadingContent.kt new file mode 100644 index 000000000..6caa8cd21 --- /dev/null +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/IconLeadingContent.kt @@ -0,0 +1,76 @@ +package edu.stanford.spezi.module.account.register + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import edu.stanford.spezi.core.design.component.validated.outlinedtextfield.ValidatedOutlinedTextField +import edu.stanford.spezi.core.design.theme.Sizes +import edu.stanford.spezi.core.design.theme.Spacings + +@Composable +internal fun IconLeadingContent( + modifier: Modifier = Modifier, + icon: ImageVector? = null, + content: @Composable () -> Unit = {}, +) { + Row( + verticalAlignment = Alignment.Top, + modifier = modifier.fillMaxWidth() + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .size(Sizes.Icon.small) + .offset(y = Spacings.small) + ) + Spacer(modifier = Modifier.width(Spacings.medium)) + } else { + // Reserve the space for the icon + Spacer(modifier = Modifier.width(Sizes.Icon.small + Spacings.small + Spacings.small)) + } + content() + } +} + +@Preview +@Composable +private fun IconLeadingContentPreview( + @PreviewParameter(IconLeadingContentPreviewProvider::class) params: Pair, +) { + IconLeadingContent( + icon = params.first, + content = { + ValidatedOutlinedTextField( + labelText = params.second, + value = "", + onValueChange = {}, + errorText = null + ) + } + ) +} + +private class IconLeadingContentPreviewProvider : + PreviewParameterProvider> { + override val values: Sequence> = sequenceOf( + Pair(Icons.Default.AccountBox, "Account Information"), + Pair(Icons.Default.Person, "Personal Information"), + Pair(null, "No Icon") + ) +} diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterFormValidator.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterFormValidator.kt index 3d89d68ea..336af2574 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterFormValidator.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterFormValidator.kt @@ -3,19 +3,7 @@ package edu.stanford.spezi.module.account.register import java.time.LocalDate import javax.inject.Inject -class RegisterFormValidator @Inject internal constructor() { - - fun emailResult(email: String): Result = if (android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { - Result.Valid - } else { - Result.Invalid("Invalid email") - } - - fun passwordResult(password: String): Result = if (password.length >= MIN_PASSWORD_LENGTH) { - Result.Valid - } else { - Result.Invalid("Password must be at least $MIN_PASSWORD_LENGTH characters") - } +internal class RegisterFormValidator @Inject constructor() : FormValidator() { fun firstnameResult(firstName: String): Result = if (firstName.isNotEmpty()) Result.Valid else Result.Invalid("First name cannot be empty") @@ -24,7 +12,16 @@ class RegisterFormValidator @Inject internal constructor() { if (lastName.isNotEmpty()) Result.Valid else Result.Invalid("Last name cannot be empty") fun isGenderValid(gender: String): Result = - if (listOf("Male", "Female", "Other").contains(gender)) Result.Valid else Result.Invalid("Please select valid gender") + if (listOf( + "Male", + "Female", + "Other" + ).contains(gender) + ) { + Result.Valid + } else { + Result.Invalid("Please select valid gender") + } fun birthdayResult(dateOfBirth: LocalDate?): Result = if (dateOfBirth != null && dateOfBirth.isBefore(LocalDate.now())) { @@ -36,27 +33,16 @@ class RegisterFormValidator @Inject internal constructor() { fun isFormValid(uiState: RegisterUiState): Boolean { val passwordConditionSatisfied = { if (uiState.isGoogleSignUp) { - passwordResult(uiState.password.value) is Result.Valid + isValidPassword(uiState.password.value).isValid } else { true } } - return emailResult(uiState.email.value) is Result.Valid && - firstnameResult(uiState.firstName.value) is Result.Valid && - lastnameResult(uiState.lastName.value) is Result.Valid && - isGenderValid(uiState.selectedGender.value) is Result.Valid && - birthdayResult(uiState.dateOfBirth) is Result.Valid && + return isValidEmail(uiState.email.value).isValid && + firstnameResult(uiState.firstName.value).isValid && + lastnameResult(uiState.lastName.value).isValid && + isGenderValid(uiState.selectedGender.value).isValid && + birthdayResult(uiState.dateOfBirth).isValid && passwordConditionSatisfied() } - - sealed interface Result { - data object Valid : Result - data class Invalid(val message: String) : Result - - fun errorMessageOrNull() = if (this is Invalid) message else null - } - - private companion object { - const val MIN_PASSWORD_LENGTH = 6 // Minimum for firebase - } } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt index af7f7c061..31ad3b02f 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt @@ -1,23 +1,26 @@ package edu.stanford.spezi.module.account.register +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imeNestedScroll import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -28,29 +31,31 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.hilt.navigation.compose.hiltViewModel import edu.stanford.spezi.core.design.component.VerticalSpacer -import edu.stanford.spezi.core.design.component.validated.textfield.ValidatedTextField +import edu.stanford.spezi.core.design.component.validated.outlinedtextfield.ValidatedOutlinedTextField import edu.stanford.spezi.core.design.theme.Colors.primary import edu.stanford.spezi.core.design.theme.Sizes import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.SpeziTheme -import edu.stanford.spezi.core.design.theme.TextStyles.labelLarge import edu.stanford.spezi.core.design.theme.TextStyles.titleLarge import edu.stanford.spezi.core.design.theme.TextStyles.titleSmall import edu.stanford.spezi.core.utils.extensions.testIdentifier import java.time.LocalDate -import java.time.format.DateTimeFormatter @Composable fun RegisterScreen( @@ -72,20 +77,28 @@ fun RegisterScreen( ) } -@OptIn(ExperimentalLayoutApi::class) @Composable fun RegisterScreen( uiState: RegisterUiState, onAction: (Action) -> Unit, ) { + val keyboardController = LocalSoftwareKeyboardController.current + val genderFocus = remember { FocusRequester() } + val dateOfBirthFocus = remember { FocusRequester() } Column( modifier = Modifier .testIdentifier(RegisterScreenTestIdentifier.ROOT) .fillMaxSize() .padding(Spacings.medium) .imePadding() - .imeNestedScroll() - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + keyboardController?.hide() + } + ) + }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -107,55 +120,97 @@ fun RegisterScreen( style = titleSmall, ) VerticalSpacer(height = Spacings.large) - Text("CREDENTIALS", style = labelLarge) - ValidatedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.email.value, - onValueChange = { - onAction(Action.TextFieldUpdate(it, TextFieldType.EMAIL)) - }, - labelText = "E-Mail Address", - errorText = uiState.email.error, - ) + IconLeadingContent( + icon = Icons.Outlined.Email, + content = { + ValidatedOutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.email.value, + onValueChange = { + onAction(Action.TextFieldUpdate(it, TextFieldType.EMAIL)) + }, + labelText = "E-Mail Address", + errorText = uiState.email.error, + ) + }) VerticalSpacer(height = Spacings.small) if (!uiState.isGoogleSignUp) { - ValidatedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.password.value, - onValueChange = { - onAction(Action.TextFieldUpdate(it, TextFieldType.PASSWORD)) - }, - labelText = "Password", - errorText = uiState.password.error, - visualTransformation = PasswordVisualTransformation(), - ) + IconLeadingContent( + icon = Icons.Outlined.Lock, + content = { + ValidatedOutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.password.value, + onValueChange = { + onAction(Action.TextFieldUpdate(it, TextFieldType.PASSWORD)) + }, + labelText = "Password", + errorText = uiState.password.error, + trailingIcon = { + IconButton(onClick = { onAction(Action.TogglePasswordVisibility) }) { + val iconId: Int = if (uiState.isPasswordVisible) { + edu.stanford.spezi.core.design.R.drawable.ic_visibility + } else { + edu.stanford.spezi.core.design.R.drawable.ic_visibility_off + } + Icon( + painter = painterResource(id = iconId), + contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password" + ) + } + }, + visualTransformation = if (uiState.isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + ) + }) } - VerticalSpacer() - Text("NAME", style = labelLarge) - ValidatedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.firstName.value, - onValueChange = { onAction(Action.TextFieldUpdate(it, TextFieldType.FIRST_NAME)) }, - labelText = "First Name", - errorText = uiState.firstName.error, - ) + IconLeadingContent( + icon = Icons.Outlined.Person, + content = { + ValidatedOutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.firstName.value, + onValueChange = { + onAction( + Action.TextFieldUpdate( + it, + TextFieldType.FIRST_NAME + ) + ) + }, + labelText = "First Name", + errorText = uiState.firstName.error, + ) + }) VerticalSpacer(height = Spacings.small) - ValidatedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.lastName.value, - onValueChange = { - onAction(Action.TextFieldUpdate(it, TextFieldType.LAST_NAME)) - }, - labelText = "Last Name", - errorText = uiState.lastName.error, - ) - VerticalSpacer() - Text("PERSONAL DETAILS", style = labelLarge) + IconLeadingContent( + content = { + ValidatedOutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.lastName.value, + onValueChange = { + onAction(Action.TextFieldUpdate(it, TextFieldType.LAST_NAME)) + }, + labelText = "Last Name", + errorText = uiState.lastName.error, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { + genderFocus.requestFocus() + if (uiState.selectedGender.value.isEmpty()) { + onAction(Action.DropdownMenuExpandedUpdate(true)) + } + }) + ) + }) DropdownMenu( modifier = Modifier.fillMaxWidth(), expanded = uiState.isDropdownMenuExpanded, onDismissRequest = { onAction(Action.DropdownMenuExpandedUpdate(false)) + dateOfBirthFocus.requestFocus() } ) { uiState.genderOptions.forEach { gender -> @@ -163,52 +218,77 @@ fun RegisterScreen( onClick = { onAction(Action.TextFieldUpdate(gender, TextFieldType.GENDER)) onAction(Action.DropdownMenuExpandedUpdate(false)) + dateOfBirthFocus.requestFocus() }) } } - - ValidatedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.selectedGender.value, - onValueChange = { - onAction(Action.TextFieldUpdate(it, TextFieldType.GENDER)) - }, - labelText = "Gender", - readOnly = true, - trailingIcon = { - IconButton(onClick = { - onAction(Action.DropdownMenuExpandedUpdate(true)) - }) { - Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select Gender") - } - }, - errorText = uiState.selectedGender.error, - ) + IconLeadingContent( + content = { + ValidatedOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(genderFocus), + value = uiState.selectedGender.value, + onValueChange = { + onAction(Action.TextFieldUpdate(it, TextFieldType.GENDER)) + }, + labelText = "Gender", + readOnly = true, + trailingIcon = { + IconButton(onClick = { + onAction(Action.DropdownMenuExpandedUpdate(true)) + }) { + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = "Select Gender", + ) + } + }, + errorText = uiState.selectedGender.error, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { + dateOfBirthFocus.requestFocus() + if (uiState.selectedGender.value.isNotEmpty()) { + onAction(Action.SetIsDatePickerOpen(true)) + } + }), + ) + }) VerticalSpacer(height = Spacings.small) - var isDatePickerDialogOpen by remember { mutableStateOf(false) } - ValidatedTextField( - modifier = Modifier.fillMaxWidth(), - value = uiState.dateOfBirth?.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) ?: "", - onValueChange = { /* Do nothing as we handle the date through the DatePicker */ }, - labelText = "Date of Birth", - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), - trailingIcon = { - IconButton(onClick = { isDatePickerDialogOpen = true }) { - Icon(Icons.Filled.Edit, contentDescription = "Select Date") - } - }, - readOnly = true - ) + IconLeadingContent( + content = { + ValidatedOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(dateOfBirthFocus), + value = uiState.formattedDateOfBirth, + onValueChange = { /* Do nothing as we handle the date through the DatePicker */ }, + labelText = "Date of Birth", + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + trailingIcon = { + IconButton(onClick = { + onAction(Action.SetIsDatePickerOpen(true)) + }) { + Icon(Icons.Filled.Edit, contentDescription = "Select Date") + } + }, + readOnly = true, + keyboardActions = KeyboardActions(onDone = { + onAction(Action.OnRegisterPressed) + }) + ) + }) - if (isDatePickerDialogOpen) { + if (uiState.isDatePickerDialogOpen) { DatePickerDialog( onDateSelected = { date -> onAction(Action.DateFieldUpdate(date)) - isDatePickerDialogOpen = false }, - onDismiss = { isDatePickerDialogOpen = false } + onDismiss = { + onAction(Action.SetIsDatePickerOpen(false)) + } ) } @@ -218,7 +298,7 @@ fun RegisterScreen( onAction(Action.OnRegisterPressed) }, modifier = Modifier.fillMaxWidth(), - enabled = uiState.isFormValid + enabled = uiState.isRegisterButtonEnabled ) { Text("Signup") } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterUiState.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterUiState.kt index b14b77c89..c2c23d6f6 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterUiState.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterUiState.kt @@ -9,11 +9,15 @@ data class RegisterUiState( val lastName: FieldState = FieldState(), val selectedGender: FieldState = FieldState(), val dateOfBirth: LocalDate? = null, + val formattedDateOfBirth: String = "", val dateOfBirthError: String? = null, val isDropdownMenuExpanded: Boolean = false, + val isDatePickerDialogOpen: Boolean = false, val isFormValid: Boolean = false, val genderOptions: List = listOf("Male", "Female", "Other"), val isGoogleSignUp: Boolean = false, + val isPasswordVisible: Boolean = false, + val isRegisterButtonEnabled: Boolean = false, ) data class FieldState( @@ -34,6 +38,7 @@ sealed interface Action { data class DateFieldUpdate(val newValue: LocalDate) : Action data class DropdownMenuExpandedUpdate(val isExpanded: Boolean) : Action data object OnRegisterPressed : Action - + data object TogglePasswordVisibility : Action data class SetIsGoogleSignUp(val isGoogleSignUp: Boolean) : Action + data class SetIsDatePickerOpen(val isOpen: Boolean) : Action } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt index 6c2c9b529..d6b9120d7 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.format.DateTimeFormatter +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -27,6 +29,13 @@ class RegisterViewModel @Inject internal constructor( private var googleCredential: String? = null + private val birthdayDateFormatter by lazy { + DateTimeFormatter.ofPattern( + "dd MMMM yyyy", + Locale.getDefault() + ) + } + fun onAction(action: Action) { _uiState.update { when (action) { @@ -39,19 +48,29 @@ class RegisterViewModel @Inject internal constructor( TextFieldType.LAST_NAME -> it.copy(lastName = newValue) TextFieldType.GENDER -> it.copy(selectedGender = newValue) } - updatedUiState.copy(isFormValid = validator.isFormValid(updatedUiState)) + updatedUiState.copy( + isFormValid = validator.isFormValid(updatedUiState), + isRegisterButtonEnabled = isRegisterButtonEnabled(updatedUiState) + ) } is Action.DateFieldUpdate -> { - val updatedUiState = it.copy(dateOfBirth = action.newValue) - updatedUiState.copy(isFormValid = validator.isFormValid(updatedUiState)) + val updatedUiState = it.copy( + dateOfBirth = action.newValue, + formattedDateOfBirth = birthdayDateFormatter.format(action.newValue), + isDatePickerDialogOpen = false, + ) + updatedUiState.copy( + isFormValid = validator.isFormValid(updatedUiState), + isRegisterButtonEnabled = isRegisterButtonEnabled(updatedUiState) + ) } is Action.DropdownMenuExpandedUpdate -> { it.copy(isDropdownMenuExpanded = action.isExpanded) } - Action.OnRegisterPressed -> { + is Action.OnRegisterPressed -> { onRegisteredPressed() } @@ -61,6 +80,14 @@ class RegisterViewModel @Inject internal constructor( } it.copy(isGoogleSignUp = action.isGoogleSignUp) } + + is Action.TogglePasswordVisibility -> { + it.copy(isPasswordVisible = !it.isPasswordVisible) + } + + is Action.SetIsDatePickerOpen -> { + it.copy(isDatePickerDialogOpen = action.isOpen) + } } } } @@ -125,9 +152,11 @@ class RegisterViewModel @Inject internal constructor( uiState } else { uiState.copy( - email = uiState.email.copy(error = validator.emailResult(uiState.email.value).errorMessageOrNull()), + email = uiState.email.copy( + error = validator.isValidEmail(uiState.email.value).errorMessageOrNull() + ), password = uiState.password.copy( - error = validator.passwordResult(uiState.password.value).errorMessageOrNull() + error = validator.isValidPassword(uiState.password.value).errorMessageOrNull() ), firstName = uiState.firstName.copy( error = validator.firstnameResult(uiState.firstName.value).errorMessageOrNull() @@ -136,11 +165,21 @@ class RegisterViewModel @Inject internal constructor( error = validator.lastnameResult(uiState.lastName.value).errorMessageOrNull() ), selectedGender = uiState.selectedGender.copy( - error = validator.isGenderValid(uiState.selectedGender.value).errorMessageOrNull() + error = validator.isGenderValid(uiState.selectedGender.value) + .errorMessageOrNull() ), - dateOfBirthError = validator.birthdayResult(uiState.dateOfBirth).errorMessageOrNull(), + dateOfBirthError = validator.birthdayResult(uiState.dateOfBirth) + .errorMessageOrNull(), isFormValid = validator.isFormValid(uiState) ) } } + + private fun isRegisterButtonEnabled(uiState: RegisterUiState): Boolean { + return uiState.email.value.isNotEmpty() && + uiState.firstName.value.isNotEmpty() && + uiState.lastName.value.isNotEmpty() && + uiState.selectedGender.value.isNotEmpty() && + uiState.dateOfBirth != null + } } diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuthTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuthTest.kt new file mode 100644 index 000000000..b2ea4ad99 --- /dev/null +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuthTest.kt @@ -0,0 +1,99 @@ +package edu.stanford.spezi.module.account.cred.manager + +import android.content.Context +import androidx.credentials.CredentialManager +import com.google.common.truth.Truth.assertThat +import edu.stanford.spezi.core.testing.runTestUnconfined +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.Before +import org.junit.Test +import java.time.LocalDate + +class CredentialRegisterManagerAuthTest { + + private lateinit var credentialRegisterManagerAuth: CredentialRegisterManagerAuth + private val firebaseAuthManager: FirebaseAuthManager = mockk() + private val credentialManager: CredentialManager = mockk() + private val context: Context = mockk() + + @Before + fun setUp() { + credentialRegisterManagerAuth = CredentialRegisterManagerAuth( + firebaseAuthManager, + credentialManager, + context + ) + } + + @Test + fun `given valid data when googleSignUp is called then return true`() = runTestUnconfined { + // Given + val idToken = "idToken" + val firstName = "Leland" + val lastName = "Stanford" + val email = "Leland.Stanford@example.com" + val selectedGender = "Male" + val dateOfBirth = LocalDate.now() + + coEvery { firebaseAuthManager.linkUserToGoogleAccount(idToken) } returns true + coEvery { + firebaseAuthManager.saveUserData( + firstName, + lastName, + email, + selectedGender, + dateOfBirth + ) + } returns true + + // When + val result = credentialRegisterManagerAuth.googleSignUp( + idToken, + firstName, + lastName, + email, + selectedGender, + dateOfBirth + ) + + // Then + assertThat(result.getOrNull()).isTrue() + } + + @Test + fun `given valid data when passwordAndEmailSignUp is called then return true`() = + runTestUnconfined { + // Given + val email = "Leland.Stanford@example.com" + val password = "password123" + val firstName = "Leland" + val lastName = "Stanford" + val selectedGender = "Male" + val dateOfBirth = LocalDate.now() + + coEvery { + firebaseAuthManager.signUpWithEmailAndPassword( + email, + password, + firstName, + lastName, + selectedGender, + dateOfBirth + ) + } returns Result.success(true) + + // When + val result = credentialRegisterManagerAuth.passwordAndEmailSignUp( + email, + password, + firstName, + lastName, + selectedGender, + dateOfBirth + ) + + // Then + assertThat(result.getOrNull()).isTrue() + } +} diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginFormValidatorTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginFormValidatorTest.kt new file mode 100644 index 000000000..8636c35d9 --- /dev/null +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginFormValidatorTest.kt @@ -0,0 +1,92 @@ +package edu.stanford.spezi.module.account.login + +import com.google.common.truth.Truth.assertThat +import edu.stanford.spezi.module.account.register.FieldState +import org.junit.Test + +class LoginFormValidatorTest { + + private val loginFormValidator = LoginFormValidator() + + @Test + fun `given valid email when isValidEmail is called then return Valid`() { + // Given + val validEmail = "test@test.com" + + // When + val result = loginFormValidator.isValidEmail(validEmail) + + // Then + assertThat(result.isValid).isTrue() + } + + @Test + fun `given invalid email when isValidEmail is called then return Invalid`() { + // Given + val invalidEmail = "invalidEmail" + + // When + val result = loginFormValidator.isValidEmail(invalidEmail) + + // Then + assertThat(result.isValid).isFalse() + } + + @Test + fun `given valid password when isValidPassword is called then return Valid`() { + // Given + val validPassword = "password123" + + // When + val result = loginFormValidator.isValidPassword(validPassword) + + // Then + assertThat(result.isValid).isTrue() + } + + @Test + fun `given invalid password when isValidPassword is called then return Invalid`() { + // Given + val invalidPassword = "pass" + + // When + val result = loginFormValidator.isValidPassword(invalidPassword) + + // Then + assertThat(result.isValid).isFalse() + } + + @Test + fun `given valid form when isFormValid is called then return true`() { + // Given + val validEmail = "test@test.com" + val validPassword = "password123" + val uiState = UiState( + email = FieldState(value = validEmail), + password = FieldState(value = validPassword) + ) + + // When + val result = loginFormValidator.isFormValid(uiState) + + // Then + assertThat(result).isTrue() + } + + @Test + fun `given invalid form when isFormValid is called then return false`() { + // Given + val invalidEmail = "invalidEmail" + val invalidPassword = "pass" + val uiState = UiState( + email = FieldState(value = invalidEmail), + password = FieldState(value = invalidPassword) + ) + + // When + val result = loginFormValidator.isFormValid(uiState) + + // Then + assertThat(result).isFalse() + } +} diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginViewModelTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginViewModelTest.kt new file mode 100644 index 000000000..4adb8dcea --- /dev/null +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginViewModelTest.kt @@ -0,0 +1,135 @@ +package edu.stanford.spezi.module.account.login + +import com.google.common.truth.Truth.assertThat +import edu.stanford.spezi.core.navigation.Navigator +import edu.stanford.spezi.core.testing.CoroutineTestRule +import edu.stanford.spezi.core.testing.runTestUnconfined +import edu.stanford.spezi.core.utils.MessageNotifier +import edu.stanford.spezi.module.account.AccountEvents +import edu.stanford.spezi.module.account.AccountNavigationEvent +import edu.stanford.spezi.module.account.cred.manager.CredentialLoginManagerAuth +import io.mockk.Runs +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class LoginViewModelTest { + + private lateinit var loginViewModel: LoginViewModel + private val credentialLoginManagerAuth: CredentialLoginManagerAuth = mockk(relaxed = true) + private val messageNotifier: MessageNotifier = mockk(relaxed = true) + private val accountEvents: AccountEvents = mockk(relaxed = true) + private val validator: LoginFormValidator = LoginFormValidator() + private val navigator: Navigator = mockk(relaxed = true) + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + @Before + fun setUp() { + loginViewModel = LoginViewModel( + credentialLoginManagerAuth = credentialLoginManagerAuth, + messageNotifier = messageNotifier, + accountEvents = accountEvents, + navigator = navigator, + validator = validator + ) + every { navigator.navigateTo(any()) } just Runs + } + + @Test + fun `given TextFieldUpdate action when onAction is called then update LoginUiState`() = + runTestUnconfined { + // Given + val email = "test@test.com" + val action = Action.TextFieldUpdate(email, TextFieldType.EMAIL) + + // When + loginViewModel.onAction(action) + + // Then + val uiState = loginViewModel.uiState.value + assertThat(uiState.email.value).isEqualTo(email) + } + + @Test + fun `given TogglePasswordVisibility action when onAction is called then update LoginUiState`() = + runTestUnconfined { + // Given + val action = Action.TogglePasswordVisibility + + // When + loginViewModel.onAction(action) + + // Then + val uiState = loginViewModel.uiState.value + assertThat(uiState.passwordVisibility).isTrue() + } + + @Test + fun `given NavigateToRegister action when onAction is called then navigate to RegisterScreen`() = + runTestUnconfined { + // Given + val action = Action.NavigateToRegister + val expectedNavigationEvent = AccountNavigationEvent.RegisterScreen( + isGoogleSignUp = false, + email = loginViewModel.uiState.value.email.value, + password = loginViewModel.uiState.value.password.value, + ) + + // When + loginViewModel.onAction(action) + + // Then + verify { navigator.navigateTo(expectedNavigationEvent) } + } + + @Test + fun `given SetIsAlreadyRegistered action when onAction is called then update isAlreadyRegistered in LoginUiState`() = + runTestUnconfined { + // Given + val action = Action.SetIsAlreadyRegistered(true) + + // When + loginViewModel.onAction(action) + + // Then + val uiState = loginViewModel.uiState.value + assertThat(uiState.isAlreadyRegistered).isTrue() + } + + @Test + fun `given ForgotPassword action with valid email when onAction is called then send forgot password email`() = + runTestUnconfined { + // Given + val action = Action.ForgotPassword + val validEmail = "test@test.com" + + // When + loginViewModel.onAction(Action.TextFieldUpdate(validEmail, TextFieldType.EMAIL)) + loginViewModel.onAction(action) + + // Then + coVerify { credentialLoginManagerAuth.sendForgotPasswordEmail(validEmail) } + } + + @Test + fun `given ForgotPassword action with invalid email when onAction is called then do not send forgot password email`() = + runTestUnconfined { + // Given + val action = Action.ForgotPassword + val invalidEmail = "invalidEmail" + + // When + loginViewModel.onAction(Action.TextFieldUpdate(invalidEmail, TextFieldType.EMAIL)) + loginViewModel.onAction(action) + + // Then + coVerify(exactly = 0) { credentialLoginManagerAuth.sendForgotPasswordEmail(invalidEmail) } + } +} diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterFormValidatorTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterFormValidatorTest.kt index c1c4ee8b7..bea11db9d 100644 --- a/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterFormValidatorTest.kt +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterFormValidatorTest.kt @@ -9,27 +9,27 @@ class RegisterFormValidatorTest { private val registerFormValidator = RegisterFormValidator() @Test - fun `given valid password when passwordResult is called then return Valid`() { + fun `given valid password when isValidPassword is called then return Valid`() { // Given val validPassword = "password123" // When - val result = registerFormValidator.passwordResult(validPassword) + val result = registerFormValidator.isValidPassword(validPassword) // Then - assertThat(result).isEqualTo(RegisterFormValidator.Result.Valid) + assertThat(result.isValid).isTrue() } @Test - fun `given invalid password when passwordResult is called then return Invalid`() { + fun `given invalid password when isValidPassword is called then return Invalid`() { // Given val invalidPassword = "pass" // When - val result = registerFormValidator.passwordResult(invalidPassword) + val result = registerFormValidator.isValidPassword(invalidPassword) // Then - assertThat(result).isInstanceOf(RegisterFormValidator.Result.Invalid::class.java) + assertThat(result.isValid).isFalse() } @Test @@ -41,7 +41,7 @@ class RegisterFormValidatorTest { val result = registerFormValidator.firstnameResult(validFirstName) // Then - assertThat(result).isEqualTo(RegisterFormValidator.Result.Valid) + assertThat(result.isValid).isTrue() } @Test @@ -53,7 +53,7 @@ class RegisterFormValidatorTest { val result = registerFormValidator.firstnameResult(invalidFirstName) // Then - assertThat(result).isInstanceOf(RegisterFormValidator.Result.Invalid::class.java) + assertThat(result.isValid).isFalse() } @Test @@ -65,7 +65,7 @@ class RegisterFormValidatorTest { val result = registerFormValidator.lastnameResult(validLastName) // Then - assertThat(result).isEqualTo(RegisterFormValidator.Result.Valid) + assertThat(result.isValid).isTrue() } @Test @@ -77,7 +77,7 @@ class RegisterFormValidatorTest { val result = registerFormValidator.lastnameResult(invalidLastName) // Then - assertThat(result).isInstanceOf(RegisterFormValidator.Result.Invalid::class.java) + assertThat(result.isValid).isFalse() } @Test @@ -89,7 +89,7 @@ class RegisterFormValidatorTest { val result = registerFormValidator.birthdayResult(validDateOfBirth) // Then - assertThat(result).isEqualTo(RegisterFormValidator.Result.Valid) + assertThat(result.isValid).isTrue() } @Test @@ -101,6 +101,6 @@ class RegisterFormValidatorTest { val result = registerFormValidator.birthdayResult(invalidDateOfBirth) // Then - assertThat(result).isInstanceOf(RegisterFormValidator.Result.Invalid::class.java) + assertThat(result.isValid).isFalse() } }