I need your help in the following problem.
When I navigate to a SignUpScreen
and try to make a signup and force an error (by try to signup with already used email/password combination) I would like to show a snackbar.
Unfortunately it looks like the recompose didn't take place because I didn't see the log messages and also not the snackbar after the state changed.
I see the snackbar with a text of null
at the initial compose which is fine for now because it makes sure the snackbar can be shown and the LaunchedEffect
is triggered.
Here is the code that is involved: State, Viewmodel, Screen, and Log messages.
data class AuthUiState(
val alreadySignUp: Boolean = false,
val isAnonymous: Boolean = false,
val user: FirebaseUser? = null,
val authState: AuthState = AuthState.SignedOut,
val isLoading: Boolean = false,
val error: Throwable? = null,
val idToken: String? = null,
val errorMessage: String? = null,
) {
val isAuthenticated: Boolean
get() = authState == AuthState.SignedIn || authState == AuthState.Authenticated
}
enum class AuthState {
SignedOut, // Not authenticated in Firebase.
SignedIn, // Authenticated in Firebase using one of service providers, and not anonymous.
Authenticated, // Anonymously authenticated in Firebase.
Loading
}
And the Viewmodel:
@HiltViewModel
class AuthViewModel @Inject constructor(
private val signInEmailPasswordUseCase: SignInEmailPasswordUseCase,
private val signInAnonymously: SignInAnonymousUseCase,
private val registerNewAccountUseCase: RegisterNewAccountUseCase,
private val signOutUseCase: SignOutUseCase,
private val signInWithGoogleUseCase: SignInWithGoogleUseCase,
firebaseStringViewModel: FirebaseStringViewModel,
private val userDataRepository: UserDataRepository,
) : ViewModel() {
private val _authUiState = MutableStateFlow(AuthUiState())
val authUiState: StateFlow<AuthUiState> = _authUiState.asStateFlow()
fun onEvent(event: AuthUiEvent) {
when (event) {
is AuthUiEvent.RegisterNewAccount -> registerNewAccount(event.email, event.password)
}
}
private fun registerNewAccount(email: String, password: String) {
viewModelScope.launch() {
_authUiState.update {
Log.d("AuthViewModel", "registerNewAccount: authUiState hashcode: ${it.hashCode()}")
it.copy(
authState = AuthState.Loading,
isLoading = true,
)
}
val result = registerNewAccountUseCase.invoke(email, password)
if (result.succeeded) {
Log.d("AuthViewModel", "registerNewAccount success: $result")
val user = result.getSuccessOrNull()?.user
_authUiState.update {
it.copy(
isLoading = false,
alreadySignUp = true,
error = null,
errorMessage = null,
user = user,
isAnonymous = user?.isAnonymous ?: false,
authState = AuthState.SignedIn,
)
}
} else {
_authUiState.update {
Log.d("AuthViewModel", "registerNewAccount error: authUiState hashcode: ${it.hashCode()}")
it.copy(
isLoading = false,
error = result.getExceptionOrNull(),
errorMessage = result.getExceptionOrNull()?.message,
user = null,
isAnonymous = false,
authState = AuthState.SignedOut,
)
}
Log.d("AuthViewModel", "_authUiState.value.errorMessage: ${_authUiState.value.errorMessage}")
}
}
}
fun clearError() {
_authUiState.update {
it.copy(
error = null,
errorMessage = null,
)
}
}
}
And the Screen:
@Composable
internal fun SignUpRoute(
onEvent: (AuthUiEvent) -> Unit,
onLoginClick: () -> Unit,
onSignUpSuccess: (email: String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
currentLanguageCode: String,
firebaseStringUiState: FirebaseStringUiState,
authViewModel: AuthViewModel = hiltViewModel(),
) {
Log.d("SignUpRoute", "SignUpRoute triggered")
SignupScreen(
onEvent = onEvent,
onLoginClick = onLoginClick,
onSignUpSuccess = onSignUpSuccess,
onClearError = { authViewModel.clearError() },
currentLanguageCode = currentLanguageCode,
onShowSnackbar = onShowSnackbar,
authViewModel = authViewModel,
firebaseStringUiState = firebaseStringUiState,
)
}
@Composable
fun SignupScreen(
onEvent: (AuthUiEvent) -> Unit,
onLoginClick: () -> Unit,
onSignUpSuccess: (email: String) -> Unit,
onClearError: () -> Unit,
currentLanguageCode: String,
onShowSnackbar: suspend (String, String?) -> Boolean,
authViewModel: AuthViewModel = hiltViewModel(),
firebaseStringUiState: FirebaseStringUiState,
modifier: Modifier = Modifier
) {
val authUiState by authViewModel.authUiState.collectAsStateWithLifecycle()
Log.d(
"SignupScreen",
"collectAsStateWithLifecycle: authUiState hashcode: ${authUiState.hashCode()}, errorMessage: ${authUiState.errorMessage}"
)
LaunchedEffect(key1 = authUiState.errorMessage) {
Log.d(
"SignupScreen",
"LaunchedEffect: authUiState hashcode: ${authUiState.hashCode()}, errorMessage: ${authUiState.errorMessage}"
)
onShowSnackbar(authUiState.errorMessage.toString(), null)
}
//TextFields
var email by remember { mutableStateOf(TextFieldValue("[email protected]")) }
var password by remember { mutableStateOf(TextFieldValue("123456")) }
var confirmPassword by remember { mutableStateOf(TextFieldValue("123456")) }
var hasError by remember { mutableStateOf(false) }
var passwordVisualTransformation by remember {
mutableStateOf<VisualTransformation>( PasswordVisualTransformation() )
}
var confirmPasswordVisualTransformation by remember {
mutableStateOf<VisualTransformation>( PasswordVisualTransformation() )
}
val passwordInteractionState = remember { MutableInteractionSource() }
val confirmPasswordInteractionState = remember { MutableInteractionSource() }
val emailInteractionState = remember { MutableInteractionSource() }
val createAccount = firebaseStringUiState.stringValues["create_account"] ?: "Create Account"
val confirmPasswordLabel = firebaseStringUiState.stringValues["password_confirm"] ?: "Confirm Password"
val emailAddress = firebaseStringUiState.stringValues["email_address"] ?: "Email address"
val passwordLabel = firebaseStringUiState.stringValues["password"] ?: "Password"
val registerLabel = firebaseStringUiState.stringValues["register"] ?: "Register"
if (authUiState.alreadySignUp) {
onSignUpSuccess(email.text)
}
if (firebaseStringUiState.isLoading) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
AromenLoadingWheel(contentDesc = "",)
}
} else {
Scaffold(modifier = modifier) { paddingValues ->
LazyColumn(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
// Headline
item { Spacer(modifier = Modifier.height(20.dp)) }
item {
Text(
text = createAccount,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
textAlign = TextAlign.Center
)
}
// Email address
item {
OutlinedTextField(
value = email,
leadingIcon = {
Icon(
imageVector = AromenIcons.Email,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
maxLines = 1,
isError = hasError,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.primary
),
label = { Text(text = emailAddress) },
onValueChange = { email = it },
interactionSource = emailInteractionState,
)
}
// Password
item {
OutlinedTextField(
value = password,
leadingIcon = {
Icon(
imageVector = AromenIcons.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingIcon = {
Icon(
imageVector = AromenIcons.BaselineRemoveRedEye,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = {
passwordVisualTransformation =
if (passwordVisualTransformation != VisualTransformation.None) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
}
})
)
},
colors = OutlinedTextFieldDefaults.colors(unfocusedBorderColor = MaterialTheme.colorScheme.primary),
maxLines = 1,
isError = hasError,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
label = { Text(text = passwordLabel) },
onValueChange = { password = it },
interactionSource = passwordInteractionState,
visualTransformation = passwordVisualTransformation,
)
}
// Confirm Password
item {
OutlinedTextField(
value = confirmPassword,
leadingIcon = {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingIcon = {
Icon(
imageVector = AromenIcons.BaselineRemoveRedEye,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = {
confirmPasswordVisualTransformation =
if (confirmPasswordVisualTransformation != VisualTransformation.None) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
}
}
)
)
},
colors = OutlinedTextFieldDefaults.colors(unfocusedBorderColor = MaterialTheme.colorScheme.primary),
maxLines = 1,
isError = hasError,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
label = { Text(text = confirmPasswordLabel) },
onValueChange = { confirmPassword = it },
interactionSource = confirmPasswordInteractionState,
visualTransformation = confirmPasswordVisualTransformation,
)
}
// Register Button
item {
Button(
onClick = {
if (invalidInput(email.text, password.text, confirmPassword.text)) {
hasError = true
} else {
hasError = false
onEvent(AuthUiEvent.RegisterNewAccount(email.text, password.text))
onClearError
}
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
.height(50.dp)
.clip(CircleShape)
) {
if (authUiState.isLoading) {
AromenLoadingWheel(contentDesc = "")
} else {
Text(text = registerLabel)
}
}
}
item { Spacer(modifier = Modifier.height(50.dp)) }
}
}
}
}
fun invalidInput(email: String, password: String, confirmPassword: String): Boolean {
if (email.isBlank() || password.isBlank() || confirmPassword.isBlank()) return true
if (confirmPassword != password) return true
return false
}
The Logmessages:
SignUpRoute D SignUpRoute triggered
SignupScreen D collectAsStateWithLifecycle: authUiState hashcode: 425474467, errorMessage: null
SignupScreen D LaunchedEffect: authUiState hashcode: 425474467, errorMessage: null
AuthViewModel D registerNewAccount: authUiState hashcode: 425474467
FirebaseAuth I Creating user with [email protected] with empty reCAPTCHA token
AuthRepositoryImpl E Firebase RegisterNewAccount Error: The email address is already in use by another account.
AuthViewModel D registerNewAccount error: authUiState hashcode: -280934312
AuthViewModel D _authUiState.value.errorMessage: The email address is already in use by another account.
From the log messages you can see the the signup process is initiated correctly and also produce the forced error.
You could also see that the error message is correctly written to the authUiState
and that it also generates a new object (because the hashcode is changed)
But I miss the recomposition of the SignupScreen
after the changes of the authUiState
.
Throwable
other than showing the error message in the UI layer, please try removing it from the state and use the string error message only and not complicate the state object with stability issues.