From Concept to Code: Crafting an Android Audio Book App with Jetpack Compose and MVI (P-3)
Part 3: Implement a Login feature with Clean Architecture & MVI
In this segment, I’ll guide you through the process of developing a secure login screen, demonstrating the implementation of Clean Architecture and the Model-View-Intent (MVI) pattern. I’m using the Firebase auth to authenticate users in our Audio Book app.
Understanding the Clean Architecture
In Part 2 of this series, we learned how the MVI design pattern works. Now let’s understand the Clean Architecture by implementing it in the login screen of our app.
Clean Architecture promotes the separation of concerns by organizing code into distinct layers: Presentation, Domain, and Data. This architectural approach ensures modularity, testability, and maintainability throughout the development process. Let’s dive into each layer one by one.
Presentation Layer
The Presentation Layer encompasses the user interface components of the application. It includes UI elements, such as activities, fragments, Jetpack Compose composables, and Viewmodels, responsible for presenting information to the user and handling user interactions. Since We’re using the MVI design pattern we also have MVI Contract classes (ViewState
, Action
, Result
, Event
, and StateReducer
) in the presentation layer.
Let’s write the presentation layer of our Login feature.
Login MVI Contract: (
LoginContract.kt
)
First thing first, we define the MVI Contract to define the different states, actions, and results of the Login screen.
sealed class LoginAction : MviAction {
data class SignInClick(val email: String, val password: String) : LoginAction()
}
sealed class LoginResult : MviResult {
object Loading : LoginResult()
object Success : LoginResult()
data class Failure(val msg: String) : LoginResult()
}
sealed class LoginEvent : MviEvent, LoginResult()
sealed class LoginState : MviViewState {
object DefaultState : LoginState()
object LoadingState : LoginState()
object SuccessState : LoginState()
data class ErrorState(val msg: String) : LoginState()
}
class LoginReducer @Inject constructor() : MviStateReducer<LoginState, LoginResult> {
override fun LoginState.reduce(result: LoginResult): LoginState {
return when (val previousState = this) {
is LoginState.DefaultState -> previousState + result
is LoginState.LoadingState -> previousState + result
is LoginState.SuccessState -> previousState + result
is LoginState.ErrorState -> previousState + result
}
}
private operator fun LoginState.DefaultState.plus(result: LoginResult): LoginState {
return when (result) {
LoginResult.Loading -> LoginState.LoadingState
else -> throw IllegalStateException("unsupported")
}
}
private operator fun LoginState.LoadingState.plus(result: LoginResult): LoginState {
return when (result) {
LoginResult.Success -> LoginState.SuccessState
is LoginResult.Failure -> LoginState.ErrorState(msg = result.msg)
else -> throw IllegalStateException("unsupported")
}
}
private operator fun LoginState.SuccessState.plus(result: LoginResult): LoginState {
return when (result) {
LoginResult.Success -> LoginState.SuccessState
else -> throw IllegalStateException("unsupported")
}
}
private operator fun LoginState.ErrorState.plus(result: LoginResult): LoginState {
return when (result) {
is LoginResult.Failure -> LoginState.ErrorState(msg = result.msg)
is LoginResult.Loading -> LoginState.LoadingState
else -> throw IllegalStateException("unsupported result $result")
}
}
}
Let’s briefly review the above classes to gain insight into how MVI operates:
- For now, our app has only one action for users on the login screen
SignInClick
. It is fired when the user taps on the Login Button. - As soon as the
LoginAction.SignInClick
is triggered, we emit a new resultLoginResult.Loading
and call the login API which then either results inLoginResult.Success
orLoginResult.Failure
. - To represent different states of our login UI, we have the
LoginState
class. There is always a default state for each screen when the user first enters it. For example, we haveLoginState.Default
. - Whenever a new result is emitted the app may change its state. For example, the
LoginResult.Loading
emits theLoginState.LoadingState
andLoginResult.Success
emitsLoginState.Success
. - The
LoginReducer
class is responsible for generating a new state from the existing state and a new result. - Finally, the state is collected in
LoginScreen
composable through ViewModel and the app updates the UI accordingly.
Why we’re writing a bunch of classes to simply update the UI depending on the results received from the login API call? Well, to follow the “Single Responsibility” principle (‘S’ of SOLID) and make our LoginViewModel
more cleaner.
LoginViewModel: (
LoginViewModel.kt
)
The second component in the presentation layer is a ViewModel. It is responsible to hold the state of the UI so sometimes is called a StateHolder.
@HiltViewModel
class LoginViewModel @Inject constructor(
private val signInWithEmailPassword: SignInWithEmailPassword,
reducer: LoginReducer
) : BaseStateViewModel<LoginAction, LoginResult, LoginEvent, LoginState, LoginReducer>(
initialState = LoginState.DefaultState,
reducer = reducer
){
override fun LoginAction.process(): Flow<LoginResult> {
return when(this) {
is LoginAction.SignInClick -> {
flow {
signInWithEmailPassword(
params = SignInWithEmailPassword.Params(
email = email,
password = password
)
).onSuccess {
emit(LoginResult.Success)
}.onFailure {
emit(LoginResult.Failure(msg = it.message ?: "Something went wrong"))
}
}.onStart {
emit(LoginResult.Loading)
}.catch {
emit(LoginResult.Failure(msg = it.message ?: "Something went wrong"))
}
}
}
}
}
Note, that LoginViewModel
extends our BaseStateViewModel
class which has a state flow machine to handle and process incoming actions from the UI. We’re injecting the dependencies through Hilt. For example, it accepts the SignInWithEmailPassword
use case and an LoginReducer
object. Check the AuthModule.kt
to see how to make these dependencies ready for our LoginViewModel
.
Inside the process()
method, we define how each action emits a result or an event. For example herein LoginAction.process()
when LoginAction.SignInClick is received we first generate LoginResult.Loading and then invoking the SignInWithEmailPassword()
use case. Then it generates either LoadingResult.Success
results in a successful response from the server or LoadingResult.Failure
with exception message on a failed network request. While LoginReducer
the object takes care of emitting new states to UI.
LoginScreen Composable: (
LoginScreen.kt
)
@Composable
fun LoginScreen(
viewModel: LoginViewModel,
gotoMain: () -> Unit
) {
val state by viewModel.collectState()
when(state) {
is LoginState.SuccessState -> gotoMain()
is LoginState.ErrorState -> {
Toast.makeText(LocalContext.current, (state as LoginState.ErrorState).msg, Toast.LENGTH_SHORT).show()
}
else -> {}
}
Scaffold {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize()
.padding(dimensionResource(id = R.dimen.onboarding_padding))
) {
CircledLogo()
Spacer(modifier = Modifier.height(100.dp))
Text(
text = stringResource(R.string.login_screen_title),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold
),
)
LoginForm(
state = state,
onLoginClick = { email, password ->
viewModel.action(LoginAction.SignInClick(email, password))
},
onForgetPasswordClick = { /*TODO*/ }
)
}
}
}
@Composable
private fun LoginForm(
state: LoginState,
onLoginClick: (email: String, password: String) -> Unit,
onForgetPasswordClick: () -> Unit
) {
val email = rememberSaveable { mutableStateOf("") }
val password = rememberSaveable { mutableStateOf("") }
Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.section_title_margin_bottom)))
EditText(
text = email.value,
hint = stringResource(R.string.hint_email),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
onValueChange = { newValue: String -> email.value = newValue },
)
Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.field_margin_bottom)))
EditText(
text = password.value,
hint = stringResource(R.string.password),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
onValueChange = { newValue: String -> password.value = newValue },
)
val text = when(state) {
is LoginState.SuccessState -> stringResource(id = R.string.login_success)
is LoginState.ErrorState -> stringResource(id = R.string.retry_login)
else -> stringResource(id = R.string.login)
}
FilledNetworkButton(
text = text,
loading = state == LoginState.LoadingState,
modifier = Modifier
.fillMaxWidth()
.padding(top = dimensionResource(id = R.dimen.vertical_screen_margin))
) {
onLoginClick(email.value, password.value)
}
Box(modifier = Modifier
.fillMaxWidth()
.padding(top = dimensionResource(id = R.dimen.section_title_margin_top))) {
TextButton(
onClick = onForgetPasswordClick,
modifier = Modifier.align(alignment = Alignment.CenterEnd)
) {
Text(
text = stringResource(R.string.forgot_password)
)
}
}
}
In this example, the LoginScreen
composable represents the UI layer responsible for displaying the login form and handling user interactions, such as submitting login credentials. The above composable results in the following UI:
//TODO: add the login screenshot here
Please note the LoginViewModel
instance is injected into LoginScreen
composable through Hilt. Read more about Hilt and Jetpack Compose.
Domain Layer
The Domain Layer contains the business logic and use cases of the application. It defines the core functionality and rules that govern the behavior of the application, independent of any specific implementation details or external dependencies. So it shouldn’t use any Android framework classes and instead should be purely written in Kotlin.
Let’s jump into the domain layer of our Login feature:
Repository Abstraction: (
AuthRepository.kt
)
The first component of the domain layer is a pure abstract repository class. In this example, the AuthRepository
interface defines the contract for interacting with user-related data, such as sign and sign out.
interface AuthRepository {
suspend fun signIn(email: String, password: String): Result<User>
suspend fun signOut(): Result<Boolean>
// signup() etc.
}
Recommendation: It is a best practice to define a separate repository class for each main entity of your app and name it something like “YourEntityRepository”. For example, we can rename the above repository to “UserRepository”. It also acts as a bridge between the domain layer and the data layer to share data.
Business/Domain Model: (
User.kt
)
User
is a business model and is also called a domain model. It carries the data from the domain layer to the presentation layer.
data class User(
val id: String,
val email: String,
val name: String?
)
Caution: A domain model shouldn’t be used in the data layer for JSON mapping or data from a local database. No annotations for serialization/deserialization or room table mapping. It should be a purely Kotlin/Java model.
Use Cases: (
SignInWithEmailAndPassword.kt
)
A use case in clean architecture represents the smallest feature of a module. In this example, the SignInWithEmailAndPassword
use case encapsulates the logic for authenticating users. It abstracts away the details of how authentication is performed, such as validating credentials and interacting with the UserRepository.
interface SignInWithEmailPassword : UseCase<User, SignInWithEmailPassword.Params> {
data class Params(val email: String, val password: String)
}
class SignInWithEmailPasswordImpl(
private val dispatcher: CoroutineDispatcher,
private val authRepository: AuthRepository
) : SignInWithEmailPassword {
override suspend fun invoke(params: SignInWithEmailPassword.Params): Result<User> {
return withContext(dispatcher) {
authRepository.signIn(params.email, params.password)
}
}
}
Note: I have defined a base class UseCase
so each use case in our AudioBook app inherits it.
Challenge: Why we have defined the SignInWithEmailPassword
interface? We can also create it as a class so why do we need it like this? I’d appreciate your answer in the comment section. (Hint SOLID)
Data Layer
The Data Layer handles data retrieval and manipulation, including interactions with databases, network services, and other external data sources. It abstracts away the complexities of data access, providing a clean interface for the Domain Layer to interact with. Let’s explore the components of our Login feature data layer:
Repository Implementation: (
AuthRepositoryImpl.kt
)
Recall the AuthRepository
interface we have created in the domain directory. Now herein the data layer, we’ll write its implementation class AuthRepositoryImpl
.
class AuthRepositoryImpl @Inject constructor(
private val firebaseAuth: FirebaseAuth
) : AuthRepository {
override suspend fun signIn(email: String, password: String): Result<User> {
val authResult = firebaseAuth.signInWithEmailAndPassword(email, password).await()
authResult.user?.let {firebaseUser ->
return Result.success(
User(
id = firebaseUser.uid,
email = firebaseUser.email ?: "invalid email",
name = firebaseUser.displayName
)
)
} ?: run {
return Result.failure(Exception("User not found"))
}
}
override suspend fun signOut(): Result<Boolean> {
firebaseAuth.signOut()
return Result.success(true)
}
}
Data Sources: (
FirebaseAuth
)
The AuthRepositoryImpl
has an instance of FirebaseAuth
a dependency. We call it a “data source”. A repository may have one or more data sources for data manipulation. An instance of a Network Service (HTTP client), a Firebase Service, or a DAO of room database, etc, are examples of data sources.
Data Model, an Entity, or DTO: (
AuthResult, FirebaseUser
)
Data models are classes used to directly map the data from data sources.
I normally use the term “Entity” for model classes used for table mapping in an ORM framework. For example, a class annotated @Entity
to map a table in the ROOM database.
@Entity(tableName = "users")
data class UserEntity (
@PrimaryKey val id: Int,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?
)
I use the term “DTO” (data transfer object) for modal classes that carry the data over network requests. For example in AuthRepositoryImpl
AuthResult
and FirebaseUser
are receiving the data from Firebase. So I’ll call them DTOs. Other examples could be model classes annotated @Serializable
to serialize or deserialize the JSON response of a REST API.
@Serializable
data class Book(val id: Int, val language: String, val title: String)
Data Mapping:
Please note that inside the AuthRepositoryImpl
, the following line of code:
// firebaseAuth is a data source and authResult is a DTO
val authResult = firebaseAuth.signInWithEmailAndPassword(email, password).await()
returns the AuthResult
object having an instance of FirebaseUser
. The singIn
method of AuthRepository
expects a User.kt
object (our domain modal). So herein repository we’re mapping the data modal into the domain modal. This mapping is one of the advantages of using a repository pattern in clean architecture.
// mapping the authResult into Result<User>
authResult.user?.let {firebaseUser ->
return Result.success(
User(
id = firebaseUser.uid,
email = firebaseUser.email ?: "invalid email",
name = firebaseUser.displayName
)
)
} ?: run {
return Result.failure(Exception("User not found"))
}
Recommendation: It is good to write a separate Mapper class or some extension methods in a separate .kt file, to map the data of our entities or DTOs into domain models for reusability purposes. For example, cleaning our AuthRepositoryImpl
we can think of writing an extension method on FirebaseUser
class.
fun FirebaseUser.toDomainUser(): User {
User(
id = this.uid,
email = this.email ?: "invalid email",
name = this.displayName
)
}
Conclusion
By adopting Clean Architecture principles and organizing our code into Presentation, Domain, and Data layers, we ensure a clear separation of concerns and promote code reusability, testability, and maintainability. This architectural approach lays a solid foundation for building scalable and robust Android applications, such as our Android Audio Book App.
Stay tuned for more insights and practical examples as we continue to explore the development of our application! 🚀📱 #AndroidDevelopment #CleanArchitecture #DomainDrivenDesign #DataLayer #PresentationLayer
Feel free to hit me up on Instagram or Facebook.
Know more about me on waseemabbs.com, GitHub, and Linkedin