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

Waseem Abbas
10 min readFeb 16, 2024

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.

Flow diagram of Login feature

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.

presentation layer of login feature

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 result LoginResult.Loading and call the login API which then either results in LoginResult.Success or LoginResult.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 have LoginState.Default.
  • Whenever a new result is emitted the app may change its state. For example, the LoginResult.Loading emits the LoginState.LoadingState and LoginResult.Success emits LoginState.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.

domain layer of login feature

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:

data layer of login feature

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

--

--

Waseem Abbas

Mobile Software Engineer | Android | Flutter | Clean Architecture