0

Playing around with Android Compose, just want to build a simple login page that navigates to home page on success.

Below is code for ViewModel and login screen

enum class UserStatus {
    LOADING,
    SUCCESS,
    EMPTY
}

data class LoginUser(
    val token: String,
    val status: UserStatus,
)

class LoginViewModel(
    private val userRepository: DefaultUserRepository = DefaultUserRepository(),
) : ViewModel() {

    val loginUser by lazy { MutableLiveData<LoginUser>() }

    fun login(email: String, password: String) {
        loginUser.postValue(LoginUser("", UserStatus.LOADING))
        val loginBody = LoginBody(email = email, password = password)
        viewModelScope.launch(Dispatchers.IO) {
            val loginResponse = userRepository.login(loginBody)
            loginUser.postValue(LoginUser(loginResponse.access_token, UserStatus.SUCCESS))
        }
    }

}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
    navController: NavController,
    viewModel: LoginViewModel = LoginViewModel(),
) {
    var email = remember { mutableStateOf("") }
    var password = remember { mutableStateOf("") }

    val userState by viewModel.loginUser.observeAsState(LoginUser("", UserStatus.EMPTY))

    when (userState.status) {
        UserStatus.SUCCESS -> navController.navigate("home/" + userState!!.token)
        UserStatus.LOADING -> CircularProgressIndicator()
        UserStatus.EMPTY -> Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize(),
        ) {
            Column(
                modifier = Modifier.align(Alignment.Center),
            ) {
                Text(
                    text = "login",
                    fontSize = 50.sp,
                )
                OutlinedTextField(
                    value = email.value,
                    onValueChange = { email.value = it },
                    label = { Text("Email") }
                )
                OutlinedTextField(
                    value = password.value,
                    onValueChange = { password.value = it },
                    label = { Text("Password") },
                    visualTransformation = PasswordVisualTransformation(),
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                )
                Spacer(modifier = Modifier.height(10.dp))
                Row() {
                    Text(
                        text = "No account? ",
                        fontSize = 18.sp,
                    )
                    ClickableText(
                        text = AnnotatedString("Signup here"),
                        onClick = { navController.navigate("signup") }
                    )
                }
                Spacer(modifier = Modifier.height(10.dp))
                FloatingActionButton(
                    onClick = {
                        viewModel.login(email.value, password.value)
                    }
                ) {
                    Text("Login")
                }
            }
        }
    }

And the code for the repository that gets jwt is

@JsonClass(generateAdapter = true)
data class LoginResponse(
    val access_token: String,
    val token_type: String,
)

data class LoginBody(
    val email: String,
    val password: String,
)

class DefaultUserRepository(
    private val client: OkHttpClient = OkHttpClient(),
    private val moshi: Moshi = Moshi.Builder().build(),
) : UserRepository {

    private val loginAdapter = moshi.adapter(LoginResponse::class.java)

    override suspend fun login(loginBody: LoginBody): LoginResponse {
        val loginObject = JSONObject()
        loginObject.put("email", loginBody.email)
        loginObject.put("password", loginBody.password)
        val mediaType = "application/json".toMediaType()
        val body = loginObject.toString().toRequestBody(mediaType)

        val loginRequest = Request.Builder()
            .url(/* auth endpoint, returns jwt */)
            .post(body)
            .build()

        val response = client.newCall(loginRequest).execute()
        if (!response.isSuccessful) throw IOException("Unexpected code $response")
        val loginResponse = loginAdapter.fromJson(response.body!!.source())
        return loginResponse!!
    }
}

The repository works and returns jwt as expected. When I click the login button, the LiveData variable userState updates to the loading state as expected, displaying a CircularProgressBar, but once the auth request completes it does not update to the success state and navigate to home screen. The only thing I can think of is that the suspend function in repository is responsible and that the loginResponse used in the postValue is empty, but when I log loginResponse before the postValue call it is populated with jwt. Any ideas?

jojeyh
  • 276
  • 1
  • 3
  • 12

1 Answers1

0

Ok I'm not exactly answering the question because I found a better way to do it using the advice here https://stackoverflow.com/a/53317071/6264190

Basically I was overcomplicating the whole matter and using bad form launching a coroutine from a function. I rewrote the ViewModel code as

class LoginViewModel(
    private val userRepository: DefaultUserRepository = DefaultUserRepository(),
) : ViewModel() {

    suspend fun login(email: String, password: String): LoginResponse = withContext(Dispatchers.IO) {
        return@withContext userRepository.login(
            LoginBody(email, password)
        )
    }

}

By making the login function suspend and using withContext I can call the function normally on a main-thread coroutine and use the returned value to navigate to home on success, like so

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
    navController: NavController,
    viewModel: LoginViewModel = LoginViewModel(),
) {
    val composableScope = rememberCoroutineScope()
    var email = remember { mutableStateOf("") }
    var password = remember { mutableStateOf("") }

    val _isLoading = remember { mutableStateOf(false) }

    when (_isLoading.value) {
        true -> CircularProgressIndicator()
        false -> Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize(),
        ) {
            /* unnecessary design code */
                FloatingActionButton(
                    onClick = {
                        _isLoading.value = true
                        composableScope.launch {
                            val loginUser = viewModel.login(email.value, password.value)
                            navController.navigate("home/" + loginUser.access_token)
                        }
                    }
                ) {
                    Text("Login")
                }
            }
        }
    }
}

Of course I need to deal with invalid login and stuffs, but this solves the above problem.

jojeyh
  • 276
  • 1
  • 3
  • 12