Rework inputs to registration screen component

This commit is contained in:
Z. Charles Dziura 2025-03-17 20:58:09 -04:00
parent 2dbf6d61d0
commit 878ff48d95
10 changed files with 367 additions and 201 deletions

View file

@ -13,6 +13,8 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -26,7 +28,9 @@ import dagger.hilt.android.AndroidEntryPoint
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.ConfirmationData import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.ConfirmationData
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.ConfirmationScreen import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.ConfirmationScreen
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login.LoginScreen import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login.LoginScreen
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login.LoginScreenViewModel
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register.RegistrationScreen import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register.RegistrationScreen
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register.RegistrationScreenViewModel
import ing.bikeshedengineer.debtpirate.app.screen.home.presentation.overview.OverviewScreen import ing.bikeshedengineer.debtpirate.app.screen.home.presentation.overview.OverviewScreen
import ing.bikeshedengineer.debtpirate.domain.usecase.GetStoredTokensUseCase import ing.bikeshedengineer.debtpirate.domain.usecase.GetStoredTokensUseCase
import ing.bikeshedengineer.debtpirate.navigation.Destination import ing.bikeshedengineer.debtpirate.navigation.Destination
@ -96,8 +100,42 @@ class MainActivity : ComponentActivity() {
navigation<Destination.AuthGraph>( navigation<Destination.AuthGraph>(
startDestination = Destination.AuthLogin startDestination = Destination.AuthLogin
) { ) {
composable<Destination.AuthLogin> { LoginScreen() } composable<Destination.AuthLogin> {
composable<Destination.AuthRegistration> { RegistrationScreen() } val viewModel = hiltViewModel<LoginScreenViewModel>()
LoginScreen(
emailAddress = viewModel.emailAddress.collectAsState(""),
isEmailAddressValid = viewModel.isEmailAddressValid.collectAsState(true),
password = viewModel.password.collectAsState(""),
isPasswordValid = viewModel.isPasswordValid.collectAsState(true),
toastMessages = viewModel.toastMessages.collectAsState(""),
onAction = viewModel::onAction,
handleCredentialManagerSignIn = viewModel::handleCredentialManagerSignIn,
onRegisterButtonClick = viewModel::onRegisterButtonClick
)
}
composable<Destination.AuthRegistration> {
val viewModel = hiltViewModel<RegistrationScreenViewModel>()
RegistrationScreen(
emailAddress = viewModel.emailAddress.collectAsState(""),
emailAddressError = viewModel.emailAddressError.collectAsState(""),
isEmailAddressValid = viewModel.isEmailAddressValid.collectAsState(true),
name = viewModel.name.collectAsState(""),
nameError = viewModel.nameError.collectAsState(""),
isNameValid = viewModel.isNameValid.collectAsState(true),
password = viewModel.password.collectAsState(""),
passwordError = viewModel.passwordError.collectAsState(""),
isPasswordValid = viewModel.isPasswordValid.collectAsState(true),
confirmPassword = viewModel.confirmPassword.collectAsState(""),
confirmPasswordError = viewModel.confirmPasswordError.collectAsState(""),
isConfirmPasswordValid = viewModel.isConfirmPasswordValid.collectAsState(true),
onAction = viewModel::onAction,
onRegistrationMessage = viewModel.onRegistrationComplete.collectAsState(null),
navigateToConfirmationScreen = viewModel::navigateToConfirmationScreen,
onNavigateUp = viewModel::navigateUp
)
}
composable<Destination.AuthRegistrationConfirmation> { composable<Destination.AuthRegistrationConfirmation> {
val (emailAddress) = it.toRoute<Destination.AuthRegistrationConfirmation>() val (emailAddress) = it.toRoute<Destination.AuthRegistrationConfirmation>()
ConfirmationScreen( ConfirmationScreen(

View file

@ -3,7 +3,7 @@ package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -31,10 +31,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@ -44,6 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.credentials.CredentialManager import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.GetPasswordOption import androidx.credentials.GetPasswordOption
import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException import androidx.credentials.exceptions.NoCredentialException
@ -52,9 +51,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun LoginScreen( fun LoginScreen(
viewModel: LoginScreenViewModel = hiltViewModel<LoginScreenViewModel>() emailAddress: State<String>,
isEmailAddressValid: State<Boolean>,
password: State<String>,
isPasswordValid: State<Boolean>,
toastMessages: State<String>,
onAction: (LoginScreenStateAction) -> Unit,
handleCredentialManagerSignIn: (GetCredentialResponse) -> Unit,
onRegisterButtonClick: () -> Unit,
) { ) {
val context = LocalContext.current as ComponentActivity val context = LocalActivity.current!!
val credentialManager = CredentialManager.create(context) val credentialManager = CredentialManager.create(context)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
@ -64,7 +70,7 @@ fun LoginScreen(
) )
) )
viewModel.handleCredentialManagerSignIn(result) handleCredentialManagerSignIn(result)
} catch (err: GetCredentialException) { } catch (err: GetCredentialException) {
when (err) { when (err) {
is NoCredentialException -> { is NoCredentialException -> {
@ -79,7 +85,6 @@ fun LoginScreen(
} }
} }
val toastMessages = viewModel.toastMessages.collectAsState("")
LaunchedEffect(toastMessages.value) { LaunchedEffect(toastMessages.value) {
val message = toastMessages.value val message = toastMessages.value
@ -103,37 +108,28 @@ fun LoginScreen(
.weight(3f) .weight(3f)
.padding(16.dp) .padding(16.dp)
) { ) {
val emailAddress = viewModel.emailAddress.collectAsState()
val isValidEmailAddress = viewModel.isValidEmailAddress.collectAsState(true)
val password = viewModel.password.collectAsState()
val isValidPassword = viewModel.isValidPassword.collectAsState(true)
LoginComponent( LoginComponent(
emailAddress, emailAddress,
onUpdateEmailAddress = { onUpdateEmailAddress = {
viewModel.onAction( onAction(
LoginScreenStateAction.UpdateEmailAddress( LoginScreenStateAction.UpdateEmailAddress(
it it
) )
) )
}, },
isValidEmailAddress, isEmailAddressValid,
password, password,
onUpdatePassword = { onUpdatePassword = {
viewModel.onAction( onAction(
LoginScreenStateAction.UpdatePassword( LoginScreenStateAction.UpdatePassword(
it it
) )
) )
}, },
isValidPassword, isPasswordValid,
submitLoginRequest = { submitLoginRequest = {
viewModel.onAction( onAction(
LoginScreenStateAction.ValidateCredentials( LoginScreenStateAction.ValidateCredentials
emailAddress.value,
password.value
)
) )
} }
) )
@ -147,7 +143,7 @@ fun LoginScreen(
) )
) )
RegisterButton(viewModel = viewModel) RegisterButton(onRegisterButtonClick)
} }
} }
} }
@ -258,9 +254,9 @@ private fun Separator(modifier: Modifier = Modifier) {
} }
@Composable @Composable
private fun RegisterButton(viewModel: LoginScreenViewModel) { private fun RegisterButton(onRegisterButtonClick: () -> Unit) {
OutlinedButton( OutlinedButton(
onClick = { viewModel.onRegisterButtonClick() }, onClick = { onRegisterButtonClick() },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
) { ) {

View file

@ -0,0 +1,10 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
data class LoginScreenState(
val emailAddress: String,
val password: String,
val isEmailAddressPristine: Boolean,
val isPasswordPristine: Boolean,
val isEmailAddressValid: Boolean,
val isPasswordValid: Boolean,
)

View file

@ -3,6 +3,6 @@ package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
sealed interface LoginScreenStateAction { sealed interface LoginScreenStateAction {
data class UpdateEmailAddress(val emailAddress: String) : LoginScreenStateAction data class UpdateEmailAddress(val emailAddress: String) : LoginScreenStateAction
data class UpdatePassword(val password: String) : LoginScreenStateAction data class UpdatePassword(val password: String) : LoginScreenStateAction
data class ValidateCredentials(val emailAddress: String, val password: String) : LoginScreenStateAction data object ValidateCredentials : LoginScreenStateAction
data class SubmitLoginRequest(val emailAddress: String, val password: String) : LoginScreenStateAction data object SubmitLoginRequest : LoginScreenStateAction
} }

View file

@ -1,6 +1,7 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
import android.util.Log import android.util.Log
import android.util.Patterns
import androidx.credentials.GetCredentialResponse import androidx.credentials.GetCredentialResponse
import androidx.credentials.PasswordCredential import androidx.credentials.PasswordCredential
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -17,9 +18,11 @@ import ing.bikeshedengineer.debtpirate.navigation.Navigator
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -30,19 +33,48 @@ class LoginScreenViewModel @Inject constructor(
private val validateLoginCredentials: ValidateLoginCredentialsUseCase, private val validateLoginCredentials: ValidateLoginCredentialsUseCase,
private val updateStoreData: UpdateStoreDataUseCase private val updateStoreData: UpdateStoreDataUseCase
) : ViewModel() { ) : ViewModel() {
private val _isEmailAddressPristine = MutableStateFlow(true) private val _state = MutableStateFlow(
private val _emailAddress = MutableStateFlow("") LoginScreenState(
val emailAddress = _emailAddress.asStateFlow() emailAddress = "",
val isValidEmailAddress = password = "",
_isEmailAddressPristine.combine(_emailAddress.map { it.isNotBlank() }) { isPristine, isValid -> isEmailAddressPristine = true,
isPristine || isValid isPasswordPristine = true,
} isEmailAddressValid = true,
isPasswordValid = true,
)
)
private val _isPasswordPristine = MutableStateFlow(true) val emailAddress = _state.map { it.emailAddress }
private val _password = MutableStateFlow("") val password = _state.map { it.password }
val password = _password.asStateFlow() val isEmailAddressPristine = _state.map { it.isEmailAddressPristine }
val isValidPassword = _isPasswordPristine.combine(_password.map { it.isNotBlank() }) { isPristine, isValid -> val isPasswordPristine = _state.map { it.isPasswordPristine }
isPristine || isValid val isEmailAddressValid = _state.map { it.isEmailAddressValid }
val isPasswordValid = _state.map { it.isPasswordValid }
init {
_state.distinctUntilChangedBy { it.emailAddress }
.map { it.emailAddress.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(it.emailAddress).matches() }
.onEach { isEmailAddressValid ->
_state.update {
it.copy(
isEmailAddressPristine = false,
isEmailAddressValid = it.isEmailAddressPristine || isEmailAddressValid
)
}
}
.launchIn(viewModelScope)
_state.distinctUntilChangedBy { it.password }
.map { it.password.isNotBlank() }
.onEach { isPasswordValid ->
_state.update {
it.copy(
isPasswordPristine = false,
isPasswordValid = it.isPasswordPristine || isPasswordValid
)
}
}
.launchIn(viewModelScope)
} }
private val _toastMessages = MutableSharedFlow<String>() private val _toastMessages = MutableSharedFlow<String>()
@ -51,37 +83,32 @@ class LoginScreenViewModel @Inject constructor(
fun onAction(action: LoginScreenStateAction) { fun onAction(action: LoginScreenStateAction) {
when (action) { when (action) {
is LoginScreenStateAction.UpdateEmailAddress -> { is LoginScreenStateAction.UpdateEmailAddress -> {
_emailAddress.value = action.emailAddress _state.update { it.copy(emailAddress = action.emailAddress) }
_isEmailAddressPristine.value = false
} }
is LoginScreenStateAction.UpdatePassword -> { is LoginScreenStateAction.UpdatePassword -> {
_password.value = action.password _state.update { it.copy(password = action.password) }
_isPasswordPristine.value = false
} }
is LoginScreenStateAction.ValidateCredentials -> { is LoginScreenStateAction.ValidateCredentials -> {
val (emailAddress, password) = action onValidateLoginCredentials(_state.value.emailAddress, _state.value.password)
onValidateLoginCredentials(emailAddress, password)
} }
is LoginScreenStateAction.SubmitLoginRequest -> { is LoginScreenStateAction.SubmitLoginRequest -> {
viewModelScope.launch { viewModelScope.launch {
val (emailAddress, password) = action onSubmitLoginRequest(_state.value.emailAddress, _state.value.password)
onSubmitLoginRequest(emailAddress, password)
} }
} }
} }
} }
private fun onValidateLoginCredentials(emailAddress: String, password: String) { private fun onValidateLoginCredentials(emailAddress: String, password: String) {
_isEmailAddressPristine.value = false _state.update { it.copy(isEmailAddressPristine = true, isPasswordPristine = true) }
_isPasswordPristine.value = false
val validationResult = validateLoginCredentials(emailAddress, password) val validationResult = validateLoginCredentials(emailAddress, password)
when (validationResult) { when (validationResult) {
is LoginCredentialsValidationResult.ValidCredentials -> { is LoginCredentialsValidationResult.ValidCredentials -> {
onAction(LoginScreenStateAction.SubmitLoginRequest(emailAddress, password)) onAction(LoginScreenStateAction.SubmitLoginRequest)
} }
else -> { else -> {
@ -131,7 +158,7 @@ class LoginScreenViewModel @Inject constructor(
val emailAddress = credentials.id val emailAddress = credentials.id
val password = credentials.password val password = credentials.password
onAction(LoginScreenStateAction.SubmitLoginRequest(emailAddress, password)) onAction(LoginScreenStateAction.SubmitLoginRequest)
} }
else -> { else -> {

View file

@ -29,7 +29,6 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -49,17 +48,32 @@ import androidx.compose.ui.unit.sp
import androidx.credentials.CreatePasswordRequest import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.CreateCredentialException import androidx.credentials.exceptions.CreateCredentialException
import androidx.hilt.navigation.compose.hiltViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable() @Composable()
fun RegistrationScreen( fun RegistrationScreen(
viewModel: RegistrationScreenViewModel = hiltViewModel<RegistrationScreenViewModel>() emailAddress: State<String>,
emailAddressError: State<String>,
isEmailAddressValid: State<Boolean>,
name: State<String>,
nameError: State<String>,
isNameValid: State<Boolean>,
password: State<String>,
passwordError: State<String>,
isPasswordValid: State<Boolean>,
confirmPassword: State<String>,
confirmPasswordError: State<String>,
isConfirmPasswordValid: State<Boolean>,
onAction: (RegistrationScreenAction) -> Unit,
onRegistrationMessage: State<Pair<String, String>?>,
navigateToConfirmationScreen: (String) -> Unit,
onNavigateUp: () -> Unit,
) { ) {
val localContext = LocalContext.current val localContext = LocalContext.current
val onRegistrationMessage = viewModel.onRegistrationComplete.collectAsState(null)
LaunchedEffect(onRegistrationMessage.value) { LaunchedEffect(onRegistrationMessage.value) {
val message = onRegistrationMessage.value val message = onRegistrationMessage.value
if (message != null) { if (message != null) {
val (username, password) = message val (username, password) = message
val credentialManager = CredentialManager.create(localContext) val credentialManager = CredentialManager.create(localContext)
@ -72,13 +86,13 @@ fun RegistrationScreen(
// TODO: Display a toast... // TODO: Display a toast...
} }
viewModel.navigateToConfirmationScreen(username) navigateToConfirmationScreen(username)
} }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold( Scaffold(
topBar = { RegistrationTopAppBar(onNavigateUp = viewModel::navigateUp) }, topBar = { RegistrationTopAppBar(onNavigateUp) },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
@ -89,7 +103,19 @@ fun RegistrationScreen(
.padding(16.dp) .padding(16.dp)
) { ) {
RegistrationComponent( RegistrationComponent(
viewModel, emailAddress = emailAddress,
emailAddressError = emailAddressError,
isEmailAddressValid = isEmailAddressValid,
name = name,
nameError = nameError,
isNameValid = isNameValid,
password = password,
passwordError = passwordError,
isPasswordValid = isPasswordValid,
confirmPassword = confirmPassword,
confirmPasswordError = confirmPasswordError,
isConfirmPasswordValid = isConfirmPasswordValid,
onAction = onAction
) )
} }
} }
@ -126,25 +152,21 @@ private fun RegistrationTopAppBar(onNavigateUp: () -> Unit, modifier: Modifier =
@Composable @Composable
private fun RegistrationComponent( private fun RegistrationComponent(
viewModel: RegistrationScreenViewModel, emailAddress: State<String>,
emailAddressError: State<String>,
isEmailAddressValid: State<Boolean>,
name: State<String>,
nameError: State<String>,
isNameValid: State<Boolean>,
password: State<String>,
passwordError: State<String>,
isPasswordValid: State<Boolean>,
confirmPassword: State<String>,
confirmPasswordError: State<String>,
isConfirmPasswordValid: State<Boolean>,
onAction: (RegistrationScreenAction) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val emailAddress = viewModel.emailAddress.collectAsState()
val emailAddressError = viewModel.emailAddressError.collectAsState()
val isInvalidEmailAddress = viewModel.isInvalidEmailAddress.collectAsState(false)
val name = viewModel.name.collectAsState()
val nameError = viewModel.nameError.collectAsState()
val isInvalidName = viewModel.isInvalidName.collectAsState(false)
val password = viewModel.password.collectAsState()
val passwordError = viewModel.passwordError.collectAsState()
val isInvalidPassword = viewModel.isInvalidPassword.collectAsState(false)
val confirmPassword = viewModel.confirmPassword.collectAsState()
val confirmPasswordError = viewModel.confirmPasswordError.collectAsState()
val isInvalidConfirmPassword = viewModel.isInvalidConfirmPassword.collectAsState(false)
Column(modifier = modifier.verticalScroll(rememberScrollState())) { Column(modifier = modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField( OutlinedTextField(
value = emailAddress.value, value = emailAddress.value,
@ -153,13 +175,13 @@ private fun RegistrationComponent(
supportingText = { Text(text = emailAddressError.value) }, supportingText = { Text(text = emailAddressError.value) },
leadingIcon = { Icon(Icons.Outlined.Mail, "Email") }, leadingIcon = { Icon(Icons.Outlined.Mail, "Email") },
singleLine = true, singleLine = true,
isError = isInvalidEmailAddress.value, isError = !isEmailAddressValid.value,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateEmailAddress(it)) }, onValueChange = { onAction(RegistrationScreenAction.UpdateEmailAddress(it)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@ -170,13 +192,13 @@ private fun RegistrationComponent(
supportingText = { Text(text = nameError.value) }, supportingText = { Text(text = nameError.value) },
leadingIcon = { Icon(Icons.Outlined.Person, "Name") }, leadingIcon = { Icon(Icons.Outlined.Person, "Name") },
singleLine = true, singleLine = true,
isError = isInvalidName.value, isError = !isNameValid.value,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words, capitalization = KeyboardCapitalization.Words,
keyboardType = KeyboardType.Text, keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateName(it)) }, onValueChange = { onAction(RegistrationScreenAction.UpdateName(it)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(PaddingValues(top = 4.dp)) .padding(PaddingValues(top = 4.dp))
@ -194,14 +216,14 @@ private fun RegistrationComponent(
) )
}, },
singleLine = true, singleLine = true,
isError = isInvalidPassword.value, isError = !isPasswordValid.value,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdatePassword(it)) }, onValueChange = { onAction(RegistrationScreenAction.UpdatePassword(it)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(PaddingValues(top = 4.dp)) .padding(PaddingValues(top = 4.dp))
@ -219,22 +241,17 @@ private fun RegistrationComponent(
) )
}, },
singleLine = true, singleLine = true,
isError = isInvalidConfirmPassword.value, isError = !isConfirmPasswordValid.value,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
imeAction = ImeAction.Send imeAction = ImeAction.Send
), ),
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateConfirmPassword(it)) }, onValueChange = { onAction(RegistrationScreenAction.UpdateConfirmPassword(it)) },
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onSend = { onSend = {
viewModel.registerNewAccount( onAction(RegistrationScreenAction.RegisterNewUser)
emailAddress.value,
name.value,
password.value,
confirmPassword.value
)
} }
), ),
modifier = Modifier modifier = Modifier
@ -266,31 +283,18 @@ private fun RegistrationComponent(
) )
RegisterButton( RegisterButton(
emailAddress, onAction = onAction
name,
password,
confirmPassword,
registerNewAccount = viewModel::registerNewAccount
) )
} }
} }
@Composable @Composable
private fun RegisterButton( private fun RegisterButton(
emailAddress: State<String>, onAction: (RegistrationScreenAction) -> Unit
name: State<String>,
password: State<String>,
confirmPassword: State<String>,
registerNewAccount: (String, String, String, String) -> Unit
) { ) {
Button( Button(
onClick = { onClick = {
registerNewAccount( onAction(RegistrationScreenAction.RegisterNewUser)
emailAddress.value,
name.value,
password.value,
confirmPassword.value
)
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -6,10 +6,5 @@ sealed interface RegistrationScreenAction {
data class UpdatePassword(val password: String) : RegistrationScreenAction data class UpdatePassword(val password: String) : RegistrationScreenAction
data class UpdateConfirmPassword(val confirmPassword: String) : RegistrationScreenAction data class UpdateConfirmPassword(val confirmPassword: String) : RegistrationScreenAction
data object ResetFields : RegistrationScreenAction data object ResetFields : RegistrationScreenAction
data class RegisterNewUser( data object RegisterNewUser : RegistrationScreenAction
val emailAddress: String,
val name: String,
val password: String,
val confirmPassword: String
) : RegistrationScreenAction
} }

View file

@ -0,0 +1,20 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register
data class RegistrationScreenState(
val emailAddress: String,
val name: String,
val password: String,
val confirmPassword: String,
val isEmailAddressPristine: Boolean,
val isNamePristine: Boolean,
val isPasswordPristine: Boolean,
val isConfirmPasswordPristine: Boolean,
val emailAddressError: String,
val nameError: String,
val passwordError: String,
val confirmPasswordError: String,
val isEmailAddressValid: Boolean,
val isNameValid: Boolean,
val isPasswordValid: Boolean,
val isConfirmPasswordValid: Boolean
)

View file

@ -1,6 +1,6 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register
import androidx.credentials.CreatePasswordRequest import android.util.Patterns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -13,8 +13,11 @@ import ing.bikeshedengineer.debtpirate.navigation.Navigator
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -25,6 +28,144 @@ class RegistrationScreenViewModel @Inject constructor(
private val submitAccountRegistrationRequest: SubmitAccountRegistrationRequestUseCase, private val submitAccountRegistrationRequest: SubmitAccountRegistrationRequestUseCase,
private val updateStoreData: UpdateStoreDataUseCase, private val updateStoreData: UpdateStoreDataUseCase,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(
RegistrationScreenState(
emailAddress = "",
name = "",
password = "",
confirmPassword = "",
isEmailAddressPristine = true,
isNamePristine = true,
isPasswordPristine = true,
isConfirmPasswordPristine = true,
emailAddressError = "",
nameError = "",
passwordError = "",
confirmPasswordError = "",
isEmailAddressValid = false,
isNameValid = false,
isPasswordValid = false,
isConfirmPasswordValid = false
)
)
val emailAddress = _state.map { it.emailAddress }
val name = _state.map { it.name }
val password = _state.map { it.password }
val confirmPassword = _state.map { it.confirmPassword }
val isEmailAddressPristine = _state.map { it.isEmailAddressPristine }
val isNamePristine = _state.map { it.isNamePristine }
val isPasswordPristine = _state.map { it.isPasswordPristine }
val isConfirmPasswordPristine = _state.map { it.isConfirmPasswordPristine }
val emailAddressError = _state.map { it.emailAddressError }
val nameError = _state.map { it.nameError }
val passwordError = _state.map { it.passwordError }
val confirmPasswordError = _state.map { it.confirmPasswordError }
val isEmailAddressValid = _state.map { it.isEmailAddressValid }
val isNameValid = _state.map { it.isNameValid }
val isPasswordValid = _state.map { it.isPasswordValid }
val isConfirmPasswordValid = _state.map { it.isConfirmPasswordValid }
private val _onRegistrationComplete = MutableSharedFlow<Pair<String, String>?>()
val onRegistrationComplete = _onRegistrationComplete.asSharedFlow()
init {
_state.distinctUntilChangedBy { it.emailAddress }
.map { it.emailAddress.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(it.emailAddress).matches() }
.onEach { isEmailAddressValid ->
_state.update {
it.copy(
isEmailAddressPristine = false,
isEmailAddressValid = it.isEmailAddressPristine || isEmailAddressValid
)
}
}
.launchIn(viewModelScope)
_state.distinctUntilChangedBy { it.name }
.map { it.name.isNotBlank() }
.onEach { isNameValid ->
_state.update {
it.copy(
isNamePristine = false,
isNameValid = it.isNamePristine || isNameValid
)
}
}
.launchIn(viewModelScope)
_state.distinctUntilChangedBy { it.password }
.map { it.password.isNotBlank() }
.onEach { isPasswordValid ->
_state.update {
it.copy(
isPasswordPristine = false,
isPasswordValid = it.isPasswordPristine || isPasswordValid
)
}
}
.launchIn(viewModelScope)
_state.distinctUntilChangedBy { it.confirmPassword }
.map { it.confirmPassword.isNotBlank() }
.onEach { isConfirmPasswordValid ->
_state.update {
it.copy(
isConfirmPasswordPristine = false,
isConfirmPasswordValid = it.isConfirmPasswordPristine || isConfirmPasswordValid
)
}
}
.launchIn(viewModelScope)
}
fun onAction(action: RegistrationScreenAction) {
when (action) {
is RegistrationScreenAction.UpdateEmailAddress -> {
_state.update { it.copy(emailAddress = action.emailAddress) }
}
is RegistrationScreenAction.UpdateName -> {
_state.update { it.copy(name = action.name) }
}
is RegistrationScreenAction.UpdatePassword -> {
_state.update { it.copy(password = action.password) }
}
is RegistrationScreenAction.UpdateConfirmPassword -> {
_state.update { it.copy(confirmPassword = action.confirmPassword) }
}
is RegistrationScreenAction.ResetFields -> {
_state.update {
it.copy(
emailAddress = "",
isEmailAddressPristine = true,
name = "",
isNamePristine = true,
password = "",
isPasswordPristine = true,
confirmPassword = "",
isConfirmPasswordPristine = true
)
}
}
is RegistrationScreenAction.RegisterNewUser -> {
registerNewAccount(
_state.value.emailAddress,
_state.value.name,
_state.value.password,
_state.value.confirmPassword
)
}
}
}
fun navigateUp() { fun navigateUp() {
viewModelScope.launch { viewModelScope.launch {
navigator.navigateUp() navigator.navigateUp()
@ -37,70 +178,6 @@ class RegistrationScreenViewModel @Inject constructor(
} }
} }
private val _emailAddress = MutableStateFlow("")
val emailAddress = _emailAddress.asStateFlow()
private val _emailAddressError = MutableStateFlow("")
val emailAddressError = _emailAddressError.asStateFlow()
val isInvalidEmailAddress = _emailAddressError.asStateFlow().map { it.isNotEmpty() }
private val _name = MutableStateFlow("")
val name = _name.asStateFlow()
private val _nameError = MutableStateFlow("")
val nameError = _nameError.asStateFlow()
val isInvalidName = _nameError.asStateFlow().map { it.isNotEmpty() }
private val _password = MutableStateFlow("")
val password = _password.asStateFlow()
private val _passwordError = MutableStateFlow("")
val passwordError = _passwordError.asStateFlow()
val isInvalidPassword = _passwordError.asStateFlow().map { it.isNotEmpty() }
private val _confirmPassword = MutableStateFlow("")
val confirmPassword = _confirmPassword.asStateFlow()
private val _confirmPasswordError = MutableStateFlow("")
val confirmPasswordError = _confirmPasswordError.asStateFlow()
val isInvalidConfirmPassword = _confirmPasswordError.asStateFlow().map { it.isNotEmpty() }
private val _onRegistrationComplete = MutableSharedFlow<Pair<String, String>>()
val onRegistrationComplete = _onRegistrationComplete.asSharedFlow()
fun onAction(action: RegistrationScreenAction) {
when (action) {
is RegistrationScreenAction.UpdateEmailAddress -> {
_emailAddress.value = action.emailAddress
}
is RegistrationScreenAction.UpdateName -> {
_name.value = action.name
}
is RegistrationScreenAction.UpdatePassword -> {
_password.value = action.password
}
is RegistrationScreenAction.UpdateConfirmPassword -> {
_confirmPassword.value = action.confirmPassword
}
is RegistrationScreenAction.ResetFields -> {
resetFields()
}
is RegistrationScreenAction.RegisterNewUser -> {
registerNewAccount(
action.emailAddress,
action.name,
action.password,
action.confirmPassword
)
}
}
}
fun registerNewAccount( fun registerNewAccount(
emailAddress: String, emailAddress: String,
name: String, name: String,
@ -140,37 +217,43 @@ class RegistrationScreenViewModel @Inject constructor(
val validationResults = validateNewAccount(emailAddress, name, password, confirmPassword) val validationResults = validateNewAccount(emailAddress, name, password, confirmPassword)
validationResults.forEach { validationResults.forEach {
if (it == NewAccountRegistrationValidationResult.EmptyEmailAddressField) { if (it == NewAccountRegistrationValidationResult.EmptyEmailAddressField) {
_emailAddressError.value = "Enter an email address" _state.update {
it.copy(emailAddressError = "Enter an email address")
}
} else if (it == NewAccountRegistrationValidationResult.InvalidEmailAddress) { } else if (it == NewAccountRegistrationValidationResult.InvalidEmailAddress) {
_emailAddressError.value = "Enter a valid email address" _state.update {
it.copy(emailAddressError = "Enter a valid email address")
}
} else if (it == NewAccountRegistrationValidationResult.ValidEmailAddress) { } else if (it == NewAccountRegistrationValidationResult.ValidEmailAddress) {
_emailAddressError.value = "" _state.update {
it.copy(emailAddressError = "")
}
} }
if (it == NewAccountRegistrationValidationResult.EmptyNameField) { if (it == NewAccountRegistrationValidationResult.EmptyNameField) {
_nameError.value = "Enter your name" _state.update { it.copy(nameError = "Enter your name") }
} else if (it == NewAccountRegistrationValidationResult.ValidName) { } else if (it == NewAccountRegistrationValidationResult.ValidName) {
_nameError.value = "" _state.update { it.copy(nameError = "") }
} }
if (it == NewAccountRegistrationValidationResult.EmptyPasswordField) { if (it == NewAccountRegistrationValidationResult.EmptyPasswordField) {
_passwordError.value = "Enter a password" _state.update { it.copy(passwordError = "Enter a password") }
} else if (it == NewAccountRegistrationValidationResult.PasswordTooShort) { } else if (it == NewAccountRegistrationValidationResult.PasswordTooShort) {
_passwordError.value = "Password must be more than 8 characters" _state.update { it.copy(passwordError = "Password must be more than 8 characters long") }
} else if (it == NewAccountRegistrationValidationResult.ValidPassword) { } else if (it == NewAccountRegistrationValidationResult.ValidPassword) {
_passwordError.value = "" _state.update { it.copy(passwordError = "") }
} }
if (it == NewAccountRegistrationValidationResult.EmptyConfirmPasswordField) { if (it == NewAccountRegistrationValidationResult.EmptyConfirmPasswordField) {
_confirmPasswordError.value = "Enter a password" _state.update { it.copy(confirmPasswordError = "Enter a password") }
} else if (it == NewAccountRegistrationValidationResult.PasswordsDontMatch) { } else if (it == NewAccountRegistrationValidationResult.PasswordsDontMatch) {
_confirmPasswordError.value = "Passwords don't match" _state.update { it.copy(confirmPasswordError = "Passwords don't match") }
} else if (it == NewAccountRegistrationValidationResult.ValidConfirmPassword) { } else if (it == NewAccountRegistrationValidationResult.ValidConfirmPassword) {
_confirmPasswordError.value = "" _state.update { it.copy(confirmPasswordError = "") }
} }
} }
return validationResults.filter { return validationResults.none {
when (it) { when (it) {
NewAccountRegistrationValidationResult.ValidEmailAddress, NewAccountRegistrationValidationResult.ValidEmailAddress,
NewAccountRegistrationValidationResult.ValidName, NewAccountRegistrationValidationResult.ValidName,
@ -180,13 +263,6 @@ class RegistrationScreenViewModel @Inject constructor(
else -> true else -> true
} }
}.isEmpty() }
}
private fun resetFields() {
_emailAddress.value = ""
_name.value = ""
_password.value = ""
_confirmPassword.value = ""
} }
} }