Android Jetpack Compose Codelab
Android Jetpack Compose | Part 4 | Login and Registration using Clean Architecture, ViewModel, Repository, ApiService, Dagger-Hilt, Retrofit, Coroutines
Introduction
In Part 3 of the Jetpack Compose Codelab, we learnt how to implement Navigation Drawer.
In this part, we continue on to implement Login and Registration using ViewModel, Repository, ApiService, Dagger-Hilt, Retrofit and Coroutines.
Prerequisite 1 : Dagger-Hilt
Ensure you have Dagger-Hilt dependencies in Project level build.gradle
buildscript {
...
dependencies {
...
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.5'
}
}
Ensure you have Dagger-Hilt dependencies in App-level build.gradle
:
plugins {
...
id 'dagger.hilt.android.plugin'
}
dependencies {
implementation 'com.google.dagger:hilt-android:2.40.5'
kapt 'com.google.dagger:hilt-android-compiler:2.40.5'
...
}
Also, add the Java 8 compatibility in the android
block if it's not already there:
android {
...
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
Prerequisite 2 : Application class
Create an Application class annotated with @HiltAndroidApp
to initiate Hilt.
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApplication : Application()
Prerequisite 3 : Retrofit Setup
First, define an interface for your Retrofit API calls. Ensure we have Retrofit and its converter-gson library added to your module build.gradle
file.
// Add these dependencies in your build.gradle file
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
Prerequisite 4: Inject Repository in ViewModel
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
...
}
Prerequisite 5 : MainActivity Injection
Use @AndroidEntryPoint
annotation in your Activity that hosts the Composable function. Then, you can get an instance of your ViewModel in the Composable function with viewModel()
.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
// NavigationHost goes here, where LoginScreen is a destination
}
}
}
}
Prerequisite 6: LoginScreen Injection
Inject authViewModel in LoginScreen as below
@Destination
@Composable
fun LoginScreen(
navController: NavController,
navigator: DestinationsNavigator,
authViewModel: AuthViewModel = hiltViewModel()
) {
...
}
Step 1 : Constants
Create network/common/Constants.kt as below
class Constants {
companion object {
const val BASE_URL = "https://codelabspro.com/"
// POST endpoint for login
const val LOGIN_URL = "auth/jwt/create/"
// POST endpoint for registration
const val REGISTRATION_URL = "auth/users/"
}
}
Step 2: Create User, LoginRequest and LoginResponse classes
network/data/model/User.kt can be declared as below
data class User(
val id: Int,
val email: String,
val username: String
)
network/data/model/LoginRequest.kt can be declared as below
data class LoginRequest(
val email: String,
val password: String,
val username: String
)
network/data/model/LoginResponse.kt can be declared as below
data class LoginResponse(
val token: String,
val user: User
)
Step 3: ApiService
Declare loginUser function in network/service/ApiService.kt as below
// ApiService.kt
import .network.common.Constants
import ai.offside.fr8proapp.network.data.model.LoginRequest
import ai.offside.fr8proapp.network.data.model.LoginResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface ApiService {
@POST(Constants.LOGIN_URL)
suspend fun loginUser(
@Body request: LoginRequest
): Response<LoginResponse>
}
Step 4: AuthRepository
Implement repo/AuthRepository as below
class AuthRepository(
private val apiService: ApiService
) {
suspend fun loginUser(email: String, password: String, username: String): Result<LoginResponse> {
return try {
val response = apiService.loginUser(LoginRequest(email, password, username))
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error logging in"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
Step 5: AuthViewModel
Implement network/viewmodel/AuthViewModel as below
class AuthViewModel(private val loginUseCase: LoginUseCase) : ViewModel() {
private val _loginUiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val loginUiState: StateFlow<LoginUiState> = _loginUiState.asStateFlow()
private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow()
init {
viewModelScope.launch {
authRepository.readUsernameFromDataStore.collect { usernameValue ->
_username.value = usernameValue ?: "-"
}
}
}
fun saveUsernameToDataStore(username: String) = viewModelScope.launch(Dispatchers.IO) {
authRepository.saveUsernameToDataStore(username)
}
fun saveAccessTokenToDataStore(accessToken: String) = viewModelScope.launch(Dispatchers.IO) {
authRepository.saveAccessTokenToDataStore(accessToken)
}
fun saveRefreshTokenToDataStore(refreshToken: String) = viewModelScope.launch(Dispatchers.IO) {
authRepository.saveAccessTokenToDataStore(refreshToken)
}
fun loginUser(email: String, password: String) {
_loginUiState.value = LoginUiState.Loading
viewModelScope.launch {
try {
val result = authRepository.loginUser(email, password)
result.onSuccess { loginResponse ->
saveUsernameToDataStore(loginResponse.username)
saveRefreshTokenToDataStore(loginResponse.refresh)
saveAccessTokenToDataStore(loginResponse.access)
_loginUiState.value = LoginUiState.Success(loginResponse)
}.onFailure { throwable ->
_loginUiState.value = LoginUiState.Error(throwable.message ?: "Unknown error")
}
} catch (e: Exception) {
_loginUiState.value = LoginUiState.Error(e.message ?: "An error occurred")
}
}
}
}
AppModule
Step 6: AppModule
Implement di/AppModule as below
const val USER_PREFERENCES = "user_preferences"
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl(Constants.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
@Provides
@Singleton
fun provideAuthRepository(
apiService: ApiService,
preferencesDataStore: DataStore<Preferences>
): AuthRepository =
AuthRepository(apiService, preferencesDataStore)
@Singleton
@Provides
fun providePreferencesDataStore(@ApplicationContext appContext: Context): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { emptyPreferences() }
),
migrations = listOf(SharedPreferencesMigration(appContext, USER_PREFERENCES)),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES) }
)
}
}
Step 7: LoginScreen
Implement screens/LoginScreen.kt as below
@Destination
@Composable
fun LoginScreen(
navController: NavController,
navigator: DestinationsNavigator,
authViewModel: AuthViewModel = hiltViewModel()
) {
val context = LocalContext.current
val gradientColors = listOf(
PrimaryColor,
SecondaryColor
)
var showExpandedText by remember {
mutableStateOf(false)
}
val passwordVector = painterResource(id = R.drawable.password_eye)
val emailValue = remember { mutableStateOf("") }
val passwordValue = remember { mutableStateOf("") }
val passwordVisibility = remember { mutableStateOf(false) }
val uiState by authViewModel.loginUiState.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current
when (uiState) {
is LoginUiState.Loading -> {
// Show loading indicator
LoadingUI()
}
is LoginUiState.Success -> {
// Handle success
Toast.makeText(
context,
"Login Success",
Toast.LENGTH_SHORT
).show()
navigator.navigate(MainScreenBottomNavigationDestination)
}
is LoginUiState.Error -> {
// Show error message
// ErrorUI((uiState as LoginUiState.Error).exception)
Toast.makeText(
context,
"Login Error. Please try again.",
Toast.LENGTH_SHORT
).show()
}
LoginUiState.Idle -> {
// Show initial UI or nothing
IdleUI()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = createGradientEffect(
colors = gradientColors,
isVertical = true
)
),
contentAlignment = Alignment.Center,
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 0.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
Image(
painter = painterResource(id = R.drawable.onboarding_0),
contentDescription = "Image1",
modifier = Modifier.padding(start = 50.dp, end = 50.dp)
)
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(visible = showExpandedText) {
Text(
text = "Ignite Your Potential",
color = Color.White,
style = MaterialTheme.typography.bodyMedium.copy(color = Color.White),
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.fillMaxWidth()
)
}
Spacer(modifier = Modifier.padding(10.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
OutlinedTextField(
value = emailValue.value,
onValueChange = { emailValue.value = it },
label = { Text(text = "Email", color = Color.White) },
placeholder = { Text(text = "Enter email address", color = Color.White) },
singleLine = true,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.fillMaxWidth(0.8f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color.White,
unfocusedBorderColor = Color.White,
focusedTextColor = Color.White
)
)
OutlinedTextField(
value = passwordValue.value,
onValueChange = { passwordValue.value = it },
trailingIcon = {
IconButton(onClick = {
passwordVisibility.value = !passwordVisibility.value
}) {
Icon(
painter = passwordVector, contentDescription = "Password icon",
tint = if (passwordVisibility.value) PrimaryColor else Color.Gray
)
}
},
label = { Text(text = "Password", color = Color.White) },
placeholder = { Text(text = "Enter the password", color = Color.White) },
singleLine = true,
visualTransformation = if (passwordVisibility.value) VisualTransformation.None
else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(0.8f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color.White,
unfocusedBorderColor = Color.White,
focusedTextColor = Color.White
)
)
Spacer(modifier = Modifier.padding(10.dp))
Button(
onClick = {
// navController.popBackStack()
/* TODO-FIXME-CLEANUP
navController.navigate(Screen.MainScreen.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
}
*/
keyboardController?.hide()
authViewModel.loginUser(emailValue.value, passwordValue.value)
// TODO-FIXME-CLEANUP navigator.navigate(MainScreenBottomNavigationDestination)
}, modifier = Modifier
.fillMaxWidth(0.8f)
.height(50.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = PrimaryColor
),
) {
Text(
text = "Login",
fontSize = 20.sp,
color = Color.White
)
}
Spacer(modifier = Modifier.padding(4.dp))
Box(
modifier = Modifier
.fillMaxWidth(0.8f)
.wrapContentHeight(), contentAlignment = Alignment.CenterEnd
) {
Text(
text = "Forgot my password",
modifier = Modifier.clickable(onClick = {
}), color = YellowGreen, fontSize = 14.sp
)
}
Spacer(modifier = Modifier.padding(20.dp))
Text(
text = "Create an account",
fontWeight = FontWeight.Bold,
color = Color.Gray,
modifier = Modifier.clickable(onClick = {
navController.navigate(Screen.MainScreen.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
}
})
)
Spacer(modifier = Modifier.padding(20.dp))
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
/* TODO-FIXME
navigator.navigate(
)
*/
navController.popBackStack()
},
modifier = Modifier
.align(Alignment.CenterHorizontally)
.background(TransparentColor),
colors = ButtonDefaults.buttonColors(
backgroundColor = PrimaryColor
),
) {
Text(
text = "Back",
color = Color.White
)
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
fun LoadingUI() {
CircularProgressIndicator() // Material Design loading spinner
}
@Composable
fun ErrorUI(errorMessage: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Error: $errorMessage", color = MaterialTheme.colorScheme.error)
Button(onClick = { /* Retry or navigate back */ }) {
Text("Retry")
}
}
}
@Composable
fun IdleUI() {
Text(text = "Please log in", style = MaterialTheme.typography.titleSmall)
}
@Composable
@Preview
fun LoginScreenPreview() {
// TODO-FIXME-CLEANUP LandingScreen(navigator = MockDestinationsNavigator())
LoginScreen(
navController = rememberNavController(),
navigator = MockDestinationsNavigator()
)
}
Step 8: Permissions
Ensure you have declared android.permission.INTERNET permisssion in the AndroidManifest as below
<uses-permission android:name="android.permission.INTERNET" />
We continue implementation of the remaining parts of the app in Part 5 of this series on Android Jetpack Compose Development.