I'm building a Clean Architecture MVVM app with Jetpack Compose and currently busy with the log in screen. I'll add relevant code snippets below but just to summarize the issue, I have a Firebase Auth sign in function in my repository class that I have converted to a suspend function with suspendCoroutine. I then pass this repository inside the viewModel where I launch a coroutine on the IO thread and invoke the repository's signIn function. I then created a data class encompassing the ui state, created a MutableStateFlow wrapping this data class inside the viewModel and then expose this StateFlow to the Compose UI. The logic for the sign in is as follows.
- Compose button invokes viewModel login function when clicked (onClick)
- ViewModel launches coroutine and sets uiState.isLoading -> true (1st flow emission)
- Invoke userRepository.signIn()
- Update StateFlow with isLoading -> false, response -> userRepository signIn's return (2nd emission)
- Reset uiState to response -> null (3rd emission)
Inside my compose ui, whenever uiState.response == false (bad signIn result), I show a toast. This toast should be shown after my 2nd emission in the event that I signIn with incorrect credentials but it never gets displayed unless I add a delay of +- 400m.s between 2nd and 3rd emission leading me to believe that its almost as if the flow changes 'too quickly' for Compose to react to.
Code snippets/screenshots:
UserRepository:
suspend fun signIn(
username: String,
password: String
): Resource<Boolean> {
return suspendCoroutine { continuation ->
firebaseAuth.signInWithEmailAndPassword(username, password)
.addOnSuccessListener {
currentUser = User(username = username)
continuation.resume(Resource.Success(data = true ))
}
.addOnFailureListener { exception ->
continuation.resume(
Resource.Error(
data = false,
message = exception.message ?: "Error getting message"
)
)
}
}
}
ViewModel (Sorry for all the Logs):
private val _uiState = MutableStateFlow(LoginScreenUiState())
val uiState = _uiState.asStateFlow()
fun signIn(
username: String,
password: String
) {
Log.d("login", "Starting viewModel login")
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(isLoading = true) }
Log.d("login", "Loading set to ${uiState.value.isLoading}")
val response = userRepository.signIn(username = username, password = password)
Log.d("login", "Firebase response acquired")
_uiState.update {
it.copy(
isLoading = false,
response = response,
)
}
Log.d("login",
"State updated to Loading = ${uiState.value.isLoading} \n " +
"with Response details : isError = ${uiState.value.response is Resource.Error} | with data = ${uiState.value.response.data} | and message = ${uiState.value.response.message}"
)
// delay(400) - Initially added this delay to allow Compose to "notice" the emission right after Firebase response acquired, want to find out why there had to be response in the first place
_uiState.update { it.copy(response = Resource.Error(data = null, message = "")) }
Log.d("login", "State reset to default state")
}
}
Compose UI:
Button(
onClick = {
Log.d("login", "button clicked")
signIn(username, password)
keyboardController?.hide()
focusManager.clearFocus(true)
},
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth(0.7f),
enabled = !uiState.isLoading
) {
Text(text = "Login")
}
if (uiState.isLoading) {
CircularProgressIndicator()
}
when (uiState.response) {
is Resource.Success -> {
navigateToHome()
}
is Resource.Error -> {
if (uiState.response.data == false) {
Log.d("login", "showing error message in compose -> ${uiState.response.data}")
Toast.makeText(context, "${uiState.response.message}", Toast.LENGTH_SHORT).show()
}
}
}
StateFlow collection:
val uiState by viewModel.uiState.collectAsState()
Wrapper class for response:
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
class Success<T>(data: T?): Resource<T>(data)
class Error<T>(message: String, data: T? = null): Resource<T>(data, message)
}
uiState data class:
data class LoginScreenUiState(
val isLoading: Boolean = false,
val response: Resource<Boolean> = Resource.Error(data = null, message = ""),
)
Logcat output for incorrect credentials:
Despite this, no toast was seen where as it should be seen after Firebase returns
Resource.Error(data = false, message = "some error message")
Notes:
- The reason why I had to reset to default state at the end was because if the screen recomposed (if I started editing my textfield to fix credentials) then the toast would be shown again.
- I'm aware that this can be solved with 2 alternatives, one being having a flag inside Compose to ensure that the toast is only shown once after button is clicked and the other method being that I can pass callback parameter to viewModel sign in and invoke it inside compose when the success case happens. I am not opposed to the first method if there's no better way but for the second one, I prefer to use the observer pattern.
Things I tried:
- Tried to use SharedFlow and instead of invoking
_uiState.update{ }
, I usedemit()
but this yielded the same result. - As mentioned above, I tried using callbacks passed into the signIn function and invoking on API returns and this worked but I would prefer to use the observer pattern.
- Beyond this, I've gone through a lot of docs/articles, and I couldn't find this issue on StackOverflow.