1

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.

  1. Compose button invokes viewModel login function when clicked (onClick)
  2. ViewModel launches coroutine and sets uiState.isLoading -> true (1st flow emission)
  3. Invoke userRepository.signIn()
  4. Update StateFlow with isLoading -> false, response -> userRepository signIn's return (2nd emission)
  5. 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:

Logcat screenshot

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:

  1. Tried to use SharedFlow and instead of invoking _uiState.update{ }, I used emit() but this yielded the same result.
  2. 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.
  3. Beyond this, I've gone through a lot of docs/articles, and I couldn't find this issue on StackOverflow.

1 Answers1

0

You should create function in your ViewModel, say toastDisplayed(), that would reset the state to default. Your UI will get the error update, show the toast, and call toastDisplayed(), which will clear the error. This approach is described in Android App architecture docs here.

Jan Bína
  • 3,447
  • 14
  • 16
  • Hey thanks for sharing, that makes sense. With regards to the weird behavior right now, is my assumption correct that the consecutive stateflow updates happen too quickly for Compose to consume? If so, is that intended behavior? – db_abstractt Jan 19 '23 at 15:09
  • Yes, your assumption is correct. I wouldn't say intended, but it's expected. StateFlow documentation says: "Updates to the value are always conflated. So a slow collector skips fast updates, but always collects the most recently emitted value." – Jan Bína Jan 19 '23 at 15:41
  • Ah I see, thank you so much. Appreciate the insight! – db_abstractt Jan 19 '23 at 16:53
  • how about changing ui state to Shared flow? – Mostafa Imani Mar 08 '23 at 14:41