Mastering Clean Architecture: A Comprehensive Guide to Building Movies App with MVVM and Jetpack Compose

Table of Contents

Clean Architecture and MVVM Architecture are two popular architectural patterns for building robust, maintainable, and scalable Android applications. In this article, we will discuss how to implement Clean Architecture and MVVM Architecture in an Android application using Kotlin. We will cover all aspects of both architectures in-depth and explain how they work together to create a robust application.

Clean Architecture

Clean Architecture is a software design pattern that emphasizes separation of concerns and the use of dependency injection. It divides an application into layers, with each layer having a specific responsibility. The layers include:

  1. Presentation Layer
  2. Domain Layer
  3. Data Layer

The Presentation Layer is responsible for the user interface and interacts with the user. The Domain Layer contains business logic and rules. The Data Layer interacts with external sources of data.

The Clean Architecture pattern is designed to promote testability, maintainability, and scalability. It reduces coupling between different parts of an application, making it easier to modify or update them without affecting other parts of the application.

MVVM Architecture

MVVM stands for Model-View-ViewModel. It is a software design pattern that separates an application into three layers: Model, View, and ViewModel. The Model represents the data and business logic. The View represents the user interface. The ViewModel acts as a mediator between the Model and the View. It exposes data from the Model to the View and handles user input from the View.

MVVM Architecture promotes separation of concerns, testability, and maintainability. It is designed to work with data binding and makes it easy to update the user interface when data changes.

Combining Clean and MVVM Architecture

Clean Architecture and MVVM Architecture can be used together to create a robust, maintainable, and scalable Android application. The Presentation Layer in Clean Architecture corresponds to the View and ViewModel in MVVM Architecture. The Domain Layer in Clean Architecture corresponds to the Model in MVVM Architecture. The Data Layer in Clean Architecture corresponds to the Data Layer in MVVM Architecture.

Implement Clean and MVVM Architecture

Let’s build one demo app to implement Clean and MVVM Architecture. we will create a simple app that displays a list of movies and allows the user to view the details of each movie. We will use the Movie Database API as our data source.

Building this MVVM demo app using Clean Architecture, MVVM, Kotlin, Coroutines, Room, Hilt, Retrofit, Moshi, Flow, and Jetpack Compose.

Set up the project

Create a new project in Android Studio and add the necessary dependencies for MVVM Architecture, such as room, hilt, and ViewModel.

build.gradle (Module:app)

Kotlin
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'
    id 'dagger.hilt.android.plugin'
}

android {
    namespace 'com.softaai.mvvmdemo'
    compileSdk 33

    defaultConfig {
        applicationId "com.softaai.mvvmdemo"
        minSdk 24
        targetSdk 33
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary true
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.4.2'
    }
    packagingOptions {
        resources {
            excludes += '/META-INF/{AL2.0,LGPL2.1}'
        }
    }
}

dependencies {

    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'androidx.core:core-ktx:1.10.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.8.0'
    implementation 'androidx.compose.material3:material3:1.1.0-beta02'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'

    // activity
    implementation 'androidx.activity:activity-ktx:1.7.0'
    implementation 'androidx.activity:activity-compose:1.7.0'

    // Lifecycle
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1"
    implementation "androidx.lifecycle:lifecycle-common:2.6.1"
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
    implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"

    //compose
    implementation 'androidx.compose.ui:ui:1.5.0-alpha02'
    implementation 'androidx.compose.material:material:1.5.0-alpha02'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
    debugImplementation "androidx.compose.ui:ui-tooling:1.5.0-alpha02"
    implementation "androidx.compose.ui:ui-tooling-preview:1.5.0-alpha02"
    implementation "androidx.compose.runtime:runtime-livedata:1.5.0-alpha02"


    //compose navigation
    implementation "androidx.navigation:navigation-compose:2.5.3"
    implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"


    // Dagger hilt
    implementation 'com.google.dagger:hilt-android:2.45'
    kapt 'com.google.dagger:hilt-compiler:2.45'


    // Networking
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
    implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.2'
    implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2'


    // Moshi
    implementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
    kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.14.0'


    // Coroutine
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1"


    // Room and room pagination
    implementation "androidx.room:room-runtime:2.5.1"
    kapt "androidx.room:room-compiler:2.5.1"
    implementation "androidx.room:room-ktx:2.5.1"
    implementation "androidx.room:room-paging:2.5.1"


    // coil image loading
    implementation 'io.coil-kt:coil-compose:2.3.0'

    // multidex
    implementation 'androidx.multidex:multidex:2.0.1'


    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.5.0-alpha02'
    debugImplementation 'androidx.compose.ui:ui-tooling:1.5.0-alpha02'
    debugImplementation 'androidx.compose.ui:ui-test-manifest:1.5.0-alpha02'
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.10"
    //if update warning comes then go to settings and install latest plugin and restart

}

Define Initial Packages

Create initial packages for each layer of Clean Architecture: Presentation, Domain, and Data. Inside each package, create sub-packages for specific functionalities of the layer.

├── data
│   ├── repository
│   └── source
│       ├── local
│       │   ├── datastore
│       │   └── roomdb
│       └── remote
├── di
│   ├── movies
│   └── moviedetails
├── domain
│   ├── model
│   ├── repository
│   └── usecase
└── presentation
    ├── ui
    └── viewmodel

In this hierarchy, we have:

  • data package which contains the repository and source packages.
  • The repository package contains classes responsible for fetching data from source and returning it to domain.
  • The source package contains local and remote packages.
  • The local package contains classes responsible for accessing data from local data storage, such as datastore and roomdb.
  • The remote package contains classes responsible for accessing data from remote data storage, such as APIs.
  • di package which contains the movies and moviedetails packages.
  • These packages contain classes responsible for dependency injection related to movies and moviedetails modules.
  • domain package which contains the model, repository, and usecase packages.
  • The model package contains classes representing the data model of the application.
  • The repository package contains interfaces defining the methods that the repository classes in data package must implement.
  • The usecase package contains classes responsible for defining the use cases of the application, by using repository interfaces and returning the result to the presentation layer.
  • presentation package which contains the ui and viewmodel packages.
  • The ui package contains classes responsible for the user interface of the application, such as activities, fragments, and views.
  • The viewmodel package contains classes responsible for implementing the ViewModel layer of the application, which holds data related to the UI and communicates with the usecase layer.

Identify JSON Response

Identify the JSON response from the URL, Before making a network request to the URL, please use your own API Key as mine is an invalid key. Then examine the JSON response using an Online JSON Viewer to identify its structure. Once you have identified the structure of the response, create Kotlin DTOs for the response and place them in the remote package.

DTOs, Entities, and Domain Models:

In our Android application, we will have different types of data models. These models include DTOs, Entities, and Domain Models.

DTOs (Data Transfer Objects) are used to transfer data between different parts of the application. They are typically used to communicate with a remote server or API.

Entities represent the data models in our local database. They are used to persist data in our application.

Domain Models represent the business logic in our application. They contain the logic and rules that govern how data is processed in the application.

By using these different types of models, we can separate our concerns and ensure that each model is responsible for its own functionality. This makes our code more modular and easier to maintain.

Mapper Functions:

In our Android application, we will often need to convert between different types of models. For example, we might need to convert a DTO to an Entity or an Entity to a Domain Model. To do this, we can use Mapper Functions.

Mapper Functions are used to convert data between different models. They take an input model and convert it to an output model. By using Mapper Functions, we can ensure that our code is organized and maintainable, and we can easily convert between different models as needed.

Define DTOs

We can create Kotlin data transfer objects (DTOs) to represent the data and place them into the remote package, as it represents data fetched from a remote data source

Kotlin
package com.softaai.mvvmdemo.data.source.remote.dto


import com.softaai.mvvmdemo.data.source.local.roomdb.entity.PopularMoviesEntity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class PopularMoviesDto(
    @Json(name = "page")
    val page: Int,
    @Json(name = "results")
    val results: List<MovieDto>,
    @Json(name = "total_pages")
    val totalPages: Int,
    @Json(name = "total_results")
    val totalResults: Int
) {
    fun toPopularMoviesEntity(): PopularMoviesEntity {
        return PopularMoviesEntity(
            page = page,
            results = results.map { it.toMovieEntity() }
        )
    }
}
Kotlin
package com.softaai.mvvmdemo.data.source.remote.dto


import com.softaai.mvvmdemo.data.source.local.roomdb.entity.MovieEntity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MovieDto(
    @Json(name = "adult")
    val adult: Boolean,
    @Json(name = "backdrop_path")
    val backdropPath: String,
    @Json(name = "genre_ids")
    val genreIds: List<Int>,
    @Json(name = "id")
    val id: Int,
    @Json(name = "original_language")
    val originalLanguage: String,
    @Json(name = "original_title")
    val originalTitle: String,
    @Json(name = "overview")
    val overview: String,
    @Json(name = "popularity")
    val popularity: Double,
    @Json(name = "poster_path")
    val posterPath: String,
    @Json(name = "release_date")
    val releaseDate: String,
    @Json(name = "title")
    val title: String,
    @Json(name = "video")
    val video: Boolean,
    @Json(name = "vote_average")
    val voteAverage: Double,
    @Json(name = "vote_count")
    val voteCount: Int
) {
    fun toMovieEntity(): MovieEntity {
        return MovieEntity(
            id = id,
            title = title,
            overview = overview,
            posterUrl = posterPath,
            releaseDate = releaseDate
        )
    }
}

Define the data source

To fetch movies from the Movie Database API, we will use Retrofit to define an interface that defines the API endpoints. We will also use Moshi to deserialize the JSON responses into our Movie data class. Here\’s an example of how to define the API interface:

Kotlin
package com.softaai.mvvmdemo.data.source.remote

import com.softaai.mvvmdemo.data.source.remote.dto.PopularMoviesDto
import retrofit2.http.GET


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */

interface MovieApiService {

    @GET("movie/popular")
    suspend fun getPopularMovies(): PopularMoviesDto

    companion object {
        const val BASE_URL: String = "https://api.themoviedb.org/3/"
    }
}

Here, we are using the @GET annotation to define the API endpoint, and the suspend keyword to indicate that this function should be called from a coroutine. We are also using the deserialized PopularMovieDto data class.

Note → We used PopularMoviesDto data class directly instead of wrapping it in a Response or Resource class. This is because it is assumed that the API response will always contain the expected data structure and any errors in the API call will be handled by catching exceptions, another reason is we are not tightly coupling our app to the API response structure and can modify the response format without affecting the rest of the app.

Define Resource Sealed Class

Resource Sealed Classes are used to represent the state of a request or operation that can either succeed or fail. They allow us to handle different states of an operation, such as loading, success, or error, in a more organized way. Typically, a Resource Sealed Class contains three states:

  1. Loading: When the operation is in progress.
  2. Success: When the operation is successful and data is available.
  3. Error: When the operation fails.
Kotlin
package com.softaai.mvvmdemo.data.source.remote


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
    class Loading<T>(data: T? = null) : Resource<T>(data)
    class Success<T>(data: T?) : Resource<T>(data)
    class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
}


By using Resource Sealed Classes, we can easily handle different states of an operation in our ViewModel without writing lots of boilerplate code.

Implement Interceptor for network requests

The purpose of the interceptor is to add an API key query parameter to every outgoing network request.

Kotlin
package com.softaai.mvvmdemo.data.source.remote

import okhttp3.Interceptor
import okhttp3.Response


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
class RequestInterceptor : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        val newUrl = originalRequest.url
            .newBuilder()
            .addQueryParameter(
                "api_key",
                "04a03ff73803441c785b1ae76dbdab9c"    //TODO Use your api key this one invalid
            )
            .build()
        val request = originalRequest.newBuilder()
            .url(newUrl)
            .build()
        return chain.proceed(request)
    }
}

The RequestInterceptor class implements the Interceptor interface provided by the OkHttp library, which allows it to intercept and modify HTTP requests and responses.

In the intercept method, the incoming chain parameter represents the chain of interceptors and the final network call to be executed. The method first retrieves the original request from the chain using chain.request(). It then creates a new URL builder from the original request URL and adds a query parameter with the key \”api_key\” and a specific value to it. Here use your own api_key as existing key is invalid

Next, it creates a new request by calling originalRequest.newBuilder() and setting the new URL with the added query parameter using .url(newUrl). Finally, it calls chain.proceed(request) to execute the modified request and return the response.

Overall, this interceptor helps to ensure that every network request made by the app includes a valid API key, which is required for authentication and authorization purposes.

Implementation of Room

Room is a powerful ORM (Object-Relational Mapping) library that makes it easy to work with a SQLite database in Android. It provides a high-level API for working with database tables, queries, and transactions, as well as support for Flow, LiveData, and RxJava for reactive programming.

Define the Entity

First, we’ll define the MovieEntity class, which represents the movies table in our local database. We annotate the class with @Entity and specify the table name and primary key. We also define the columns using public properties.

Kotlin
package com.softaai.mvvmdemo.data.source.local.roomdb.entity

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.softaai.mvvmdemo.domain.model.Movie


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@Entity(tableName = MovieEntity.TABLE_NAME)
data class MovieEntity(
    @PrimaryKey val id: Int,
    val title: String,
    val overview: String,
    @ColumnInfo(name = "poster_url") val posterUrl: String,
    @ColumnInfo(name = "release_date") val releaseDate: String
) {

    fun toMovie(): Movie {
        return Movie(
            title = title,
            overview = overview,
            posterUrl = posterUrl,
            releaseDate = releaseDate
        )
    }

    companion object {
        const val TABLE_NAME = "movie"
    }
}

We have another entity for API response, which contains a movie entity list

Kotlin
package com.softaai.mvvmdemo.data.source.local.roomdb.entity

import androidx.room.Entity
import androidx.room.PrimaryKey
import com.softaai.mvvmdemo.domain.model.PopularMovies


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@Entity(tableName = PopularMoviesEntity.TABLE_NAME)
class PopularMoviesEntity(
    @PrimaryKey(autoGenerate = true)
    val primaryKeyId: Int? = null,
    val page: Int,
    val results: List<MovieEntity>
) {

    fun toPopularMovies(): PopularMovies {
        return PopularMovies(
            page = page,
            results = results.map { it.toMovie() }
        )
    }

    companion object {
        const val TABLE_NAME = "popular_movies"
    }
}

Define the DAO

Next, we’ll define the MovieDao interface, which provides the methods to interact with the movies table. We annotate the interface with @Dao and define the query methods using annotations such as @Query, @Insert, and @Delete.

Kotlin
package com.softaai.mvvmdemo.data.source.local.roomdb.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.softaai.mvvmdemo.data.source.local.roomdb.entity.MovieEntity


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */

@Dao
interface MovieDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertMovieList(movies: List<MovieEntity>)

    @Query("SELECT * FROM ${MovieEntity.TABLE_NAME}")
    suspend fun getMovieList(): List<MovieEntity>

    @Query("DELETE FROM ${MovieEntity.TABLE_NAME}")
    suspend fun deleteAll()
}

Note ->Here also, we are not using any wrappers like Flow, Response, or Resource. The reason behind this is that we are keeping the repository layer decoupled from the data sources (local or remote) and allowing for easier testing and evolution. In this specific case, it is a simple synchronous database operation, as the data is being retrieved from the local database using Room. Room already provides the functionality to perform asynchronous database operations in the background, so we do not need to use any additional wrappers like Flow or Resource. We can simply call the getMovieList() method from a coroutine and retrieve the list of MovieEntity objects.

Define Type Converter

In Room, a type converter is a way to convert non-primitive types (such as Date or custom objects in our case List<MovieEntity>) to primitive types that can be stored in the SQLite database.

To use a type converter in Room, you need to create a class that implements the TypeConverter interface, which has two methods: toType() and fromType(). The toType() method converts a non-primitive type to a primitive type, while the fromType() method converts the primitive type back to the non-primitive type.

Kotlin
package com.softaai.mvvmdemo.data.source.local.roomdb.converter

import androidx.room.TypeConverter
import com.softaai.mvvmdemo.data.source.local.roomdb.entity.MovieEntity
import com.softaai.mvvmdemo.domain.model.Movie
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
class PopularMoviesEntityConverter {

    @TypeConverter
    fun fromStringToMovieList(value: String): List<Movie>? =
        Moshi.Builder()
            .add(KotlinJsonAdapterFactory())
            .build()
            .adapter<List<Movie>>(Types.newParameterizedType(List::class.java, Movie::class.java))
            .fromJson(value)

    @TypeConverter
    fun fromMovieListTypeToString(movieListType: List<Movie>?): String =
        Moshi.Builder()
            .add(KotlinJsonAdapterFactory())
            .build()
            .adapter<List<Movie>>(Types.newParameterizedType(List::class.java, Movie::class.java))
            .toJson(movieListType)


    @TypeConverter
    fun fromStringToMovieEntityList(value: String): List<MovieEntity>? =
        Moshi.Builder()
            .add(KotlinJsonAdapterFactory())
            .build().adapter<List<MovieEntity>>(
                Types.newParameterizedType(
                    List::class.java,
                    MovieEntity::class.java
                )
            ).fromJson(value)

    @TypeConverter
    fun fromMovieEntityListTypeToString(movieEntityListType: List<MovieEntity>?): String =
        Moshi.Builder()
            .add(KotlinJsonAdapterFactory())
            .build().adapter<List<MovieEntity>>(
                Types.newParameterizedType(
                    List::class.java,
                    MovieEntity::class.java
                )
            ).toJson(movieEntityListType)

}

To use this TypeConverter, you need to annotate the field or property that needs to be converted with the @TypeConverters annotation, specifying the converter class.

Define the Database

Finally, we’ll define the MovieDatabase class, which represents the entire local database. We annotate the class with @Database and specify the list of entities and version number. We also define a singleton instance of the database using the Room.databaseBuilder method.

Kotlin
package com.softaai.mvvmdemo.data.source.local.roomdb

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.softaai.mvvmdemo.data.source.local.roomdb.converter.PopularMoviesEntityConverter
import com.softaai.mvvmdemo.data.source.local.roomdb.dao.MovieDao
import com.softaai.mvvmdemo.data.source.local.roomdb.dao.PopularMoviesDao
import com.softaai.mvvmdemo.data.source.local.roomdb.entity.MovieEntity
import com.softaai.mvvmdemo.data.source.local.roomdb.entity.PopularMoviesEntity


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */

@Database(
    entities = [PopularMoviesEntity::class, MovieEntity::class],
    version = 1,
    exportSchema = false
)
@TypeConverters(PopularMoviesEntityConverter::class)
abstract class MovieDatabase : RoomDatabase() {
    abstract fun getMovieDao(): MovieDao

    abstract fun getPopularMoviesDao(): PopularMoviesDao

    companion object {
        @Volatile
        private var INSTANCE: MovieDatabase? = null

        fun getDatabase(context: Context): MovieDatabase {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    MovieDatabase::class.java,
                    "movie_database"
                ).build()
                INSTANCE = instance
                return instance
            }
        }
    }
}

Define Repository interface in Domain Layer

Usually In the Domain Layer, we define the interfaces for the Repository and Use Case (In our case, skipped for the Use Case). These interfaces define the methods that will be used to interact with the data layer. The Repository interface defines the methods that will be used to retrieve and save data, while the Use Case interface defines the business logic that will be performed on the data.

We will create a MovieRepository interface that defines the methods for fetching movies:

Kotlin
package com.softaai.mvvmdemo.domain.repository

import com.softaai.mvvmdemo.data.source.remote.Resource
import com.softaai.mvvmdemo.domain.model.Movie
import kotlinx.coroutines.flow.Flow


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
interface MovieRepository {
    fun getPopularMovies(): Flow<Resource<List<Movie>>>
}

We are returning a Flow<Resource<List<Movie>>> from the getPopularMovies() function. The Flow will emit the result of the API call asynchronously, and the Resource class will hold either the list of movies or an error.

Implement a Repository interface in the Data Layer

We define interfaces for the Repository and Use Case in the Domain Layer and these interfaces will be implemented in the Data Layer. By separating the interfaces from their implementations, we can easily swap out the data layer implementation if needed. This allows us to easily switch between different data sources, such as a local database or a remote API, without having to modify the business logic layer.

In our example, we will create an implementation of the MovieRepository interface that uses Retrofit and Moshi to fetch the popular movies:

Kotlin
package com.softaai.mvvmdemo.data.repository

import com.softaai.mvvmdemo.data.source.local.roomdb.dao.MovieDao
import com.softaai.mvvmdemo.data.source.local.roomdb.dao.PopularMoviesDao
import com.softaai.mvvmdemo.data.source.remote.MovieApiService
import com.softaai.mvvmdemo.data.source.remote.Resource
import com.softaai.mvvmdemo.domain.model.Movie
import com.softaai.mvvmdemo.domain.repository.MovieRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import retrofit2.HttpException
import java.io.IOException


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
class MovieRepositoryImpl constructor(
    private val movieApiService: MovieApiService,
    private val popularMoviesDao: PopularMoviesDao,
    private val movieDao: MovieDao
) : MovieRepository {

    override fun getPopularMovies(): Flow<Resource<List<Movie>>> = flow {
        emit(Resource.Loading())
        try {
            fetchAndInsertPopularMovies(movieApiService, popularMoviesDao, movieDao)
        } catch (e: HttpException) {
            emit(
                Resource.Error(
                    message = "Oops, something went wrong!"
                )
            )
        } catch (e: IOException) {
            emit(
                Resource.Error(
                    message = "Couldn't reach server, check your internet connection."
                )
            )
        }
        // single source of truth we will emit data from db only and not directly from remote
        emit(Resource.Success(getPopularMoviesFromDb(movieDao)))
    }


    private suspend fun fetchAndInsertPopularMovies(
        movieApiService: MovieApiService,
        popularMoviesDao: PopularMoviesDao,
        movieDao: MovieDao
    ) {

        val remotePopularMovies = movieApiService.getPopularMovies()
        popularMoviesDao.insertPopularMovies(remotePopularMovies.toPopularMoviesEntity())
        movieDao.insertMovieList(remotePopularMovies.results.map { it.toMovieEntity() }) //now insert newly fetched data to db

    }


    private suspend fun getPopularMoviesFromDb(movieDao: MovieDao): List<Movie> {
        val newPopularMovies = movieDao.getMovieList().map { it.toMovie() }
        return newPopularMovies
    }


}

Here, we are using the flow builder from the Kotlin coroutines library to emit the result of the API call asynchronously. We are also using the catch operator to catch any exceptions that might occur during the API call. If there is an error, we emit the error wrapped in the Resource.Error class.

Implement Use Case

I skipped implementing the Use Case in the Data Layer, such as the Repository, and instead implemented it directly in the Domain Layer for this small assignment. However, in a bigger project, it is important to implement it properly in the Data Layer.

Kotlin
package com.softaai.mvvmdemo.domain.usecase

import com.softaai.mvvmdemo.data.source.remote.Resource
import com.softaai.mvvmdemo.domain.model.Movie
import com.softaai.mvvmdemo.domain.repository.MovieRepository
import kotlinx.coroutines.flow.Flow


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
class GetPopularMovies(
    private val movieRepository: MovieRepository
) {
    operator fun invoke(): Flow<Resource<List<Movie>>> {
        return movieRepository.getPopularMovies()
    }
}

The GetPopularMovies class is a use case class in the domain layer that provides a way to retrieve a list of popular movies from the MovieRepository. By using this class, we can easily retrieve the list of popular movies by calling its invoke() method an operator function, which returns a Flow. We can then collect the items emitted by the Flow and handle the different states of the data using the Resource class.

Add Hilt Modules for Dependency Injection

Hilt is a dependency injection framework that makes it easy to manage dependencies in Android apps. It is built on top of Dagger, a popular dependency injection library, and provides a simpler, more streamlined API for configuring and injecting dependencies.

To inject dependencies into our ViewModel and Repository, we’ll use Hilt for Dependency Injection. Since we have already added the Hilt dependency in the gradle file, we can now directly annotate our Application class with @HiltAndroidApp:

Kotlin
package com.softaai.mvvmdemo

import android.app.Application
import dagger.hilt.android.HiltAndroidApp


/**
 * Created by amoljp19 on 4/19/2023.
 * softAai Apps.
 */

@HiltAndroidApp
class MvvmDemoApp : Application() {
}

Define Hilt modules

Create a Kotlin object for each module and annotate it with @Module. In each module, define one or more provider methods that create instances of your dependencies and annotate them with @Provides.

MoviesNetworkModule

Kotlin
package com.softaai.mvvmdemo.di.moviesmodule

import com.softaai.mvvmdemo.data.source.remote.MovieApiService
import com.softaai.mvvmdemo.data.source.remote.RequestInterceptor
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@Module
@InstallIn(SingletonComponent::class)
class MoviesNetworkModule {

    private val interceptor = run {
        val httpLoggingInterceptor = HttpLoggingInterceptor()
        httpLoggingInterceptor.apply {
            httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
        }
    }

    @Provides
    @Singleton
    fun provideOkHttpClient() =
        OkHttpClient.Builder().addInterceptor(RequestInterceptor()).addInterceptor(interceptor)
            .build()


    @Singleton
    @Provides
    fun provideRetrofitService(okHttpClient: OkHttpClient): MovieApiService =
        Retrofit.Builder()
            .baseUrl(MovieApiService.BASE_URL)
            .addConverterFactory(
                MoshiConverterFactory.create(
                    Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
                )
            )
            .client(okHttpClient)
            .build()
            .create(MovieApiService::class.java)

}

This is a Hilt module called MoviesNetworkModule, which is used for providing dependencies related to network communication with the MovieApiService. The module is annotated with @Module and @InstallIn(SingletonComponent::class), which means that it will be installed in the SingletonComponent and has the scope of the entire application.

The module provides the following dependencies:

  • OkHttpClient: This dependency is provided by a method called provideOkHttpClient, which returns an instance of OkHttpClient that is built with RequestInterceptor and HttpLoggingInterceptor.
  • MovieApiService: This dependency is provided by a method called provideRetrofitService, which takes an instance of OkHttpClient as a parameter and returns an instance of MovieApiService. This method builds a Retrofit instance using MoshiConverterFactory for JSON parsing and the provided OkHttpClient, and creates a MovieApiService instance using the Retrofit.create method.

The @Singleton annotation is used on both provideOkHttpClient and provideRetrofitService methods, which means that Hilt will only create one instance of each dependency and provide it whenever it is needed.

By using these @Provides methods, we can provide these dependencies to any component in our app by simply annotating the constructor of that component with @Inject.

MoviesDatabaseModule

Kotlin
package com.softaai.mvvmdemo.di.moviesmodule

import android.app.Application
import com.softaai.mvvmdemo.data.source.local.roomdb.MovieDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@Module
@InstallIn(SingletonComponent::class)
class MoviesDatabaseModule {

    @Singleton
    @Provides
    fun provideDatabase(application: Application) = MovieDatabase.getDatabase(application)

    @Singleton
    @Provides
    fun providePopularMoviesDao(database: MovieDatabase) =
        database.getPopularMoviesDao()

    @Singleton
    @Provides
    fun provideMovieDao(database: MovieDatabase) =
        database.getMovieDao()
}

Here we have defined a Hilt module called MoviesDatabaseModule which is annotated with @Module and @InstallIn(SingletonComponent::class). This means that this module will be installed in the SingletonComponent which has the scope of the entire application. By using these @Provides methods, we can provide these dependencies to any component in our app by simply annotating the constructor of that component with @Inject.

For example, if we want to use PopularMoviesDao in our MovieRepository, we can simply annotate the constructor of MovieRepository with @Inject and pass PopularMoviesDao as a parameter:

Kotlin
class MovieRepository @Inject constructor(
    private val movieApiService: MovieApiService,
    private val popularMoviesDao: PopularMoviesDao,
    private val movieDao: MovieDao
) {
    ...
}

By doing this, Hilt will automatically provide the PopularMoviesDao, MovieDao, and MovieApiService objects to our MovieRepository whenever it is needed.

MoviesRepositoryModule

Kotlin
package com.softaai.mvvmdemo.di.moviesmodule

import com.softaai.mvvmdemo.data.repository.MovieRepositoryImpl
import com.softaai.mvvmdemo.data.source.local.roomdb.dao.MovieDao
import com.softaai.mvvmdemo.data.source.local.roomdb.dao.PopularMoviesDao
import com.softaai.mvvmdemo.data.source.remote.MovieApiService
import com.softaai.mvvmdemo.domain.repository.MovieRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@Module
@InstallIn(SingletonComponent::class)
class MoviesRepositoryModule {

    @Provides
    @Singleton
    fun provideMovieRepositoryImpl(
        movieApiService: MovieApiService,
        popularMoviesDao: PopularMoviesDao,
        movieDao: MovieDao
    ): MovieRepository = MovieRepositoryImpl(movieApiService, popularMoviesDao, movieDao)

}

This is a Dagger Hilt module for providing the MovieRepository implementation to the app. The module is annotated with @Module and @InstallIn(SingletonComponent::class) which means that the MovieRepository will have a singleton scope throughout the app.

The @Provides method is defined to provide the MovieRepositoryImpl instance. This method takes three parameters: movieApiService of type MovieApiService, popularMoviesDao of type PopularMoviesDao, and movieDao of type MovieDao. These dependencies are injected into the constructor of MovieRepositoryImpl to create its instance.

MoviesUseCaseModule

Kotlin
package com.softaai.mvvmdemo.di.moviesmodule

import com.softaai.mvvmdemo.domain.repository.MovieRepository
import com.softaai.mvvmdemo.domain.usecase.GetPopularMovies
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@Module
@InstallIn(SingletonComponent::class)
class MoviesUsecaseModule {

    @Provides
    @Singleton
    fun provideGetPopularMoviesUseCase(repository: MovieRepository): GetPopularMovies =
        GetPopularMovies(repository)
}

The GetPopularMovies use case by injecting the MovieRepository. The module is annotated with @InstallIn(SingletonComponent::class) which means it will be installed in the SingletonComponent of the application.

The provideGetPopularMoviesUseCase method is annotated with @Provides and @Singleton, indicating that it provides a singleton instance of the GetPopularMovies use case.

The repository parameter of the method is injected via constructor injection, as it is declared as a dependency of the GetPopularMovies constructor. The MovieRepository is provided by the MoviesRepositoryModule which is also installed in the SingletonComponent.

Define the ViewModel

Now, we can define the ViewModel that will be used to expose the movie data to the UI. We will create a MoviesViewModel class that extends the ViewModel class from the Android Architecture Components library:

Kotlin
package com.softaai.mvvmdemo.presentation.viewmodel

import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.softaai.mvvmdemo.data.source.remote.Resource
import com.softaai.mvvmdemo.domain.usecase.GetPopularMovies
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@HiltViewModel
class MoviesViewModel @Inject constructor(
    private val getPopularMovies: GetPopularMovies
) : ViewModel() {

    private val _state = mutableStateOf(MovieUiState())
    val state: State<MovieUiState> = _state

    init {
        getMovies()
    }

    fun getMovies() {
        viewModelScope.launch {

            getPopularMovies().onEach { result ->
                when (result) {
                    is Resource.Loading -> {
                        _state.value = state.value.copy(
                            moviesList = result.data ?: emptyList(),
                            isLoading = true
                        )
                    }
                    is Resource.Success -> {
                        _state.value = state.value.copy(
                            moviesList = result.data ?: emptyList(),
                            isLoading = false
                        )
                    }
                    is Resource.Error -> {
                        _state.value = state.value.copy(
                            moviesList = result.data ?: emptyList(),
                            isLoading = false
                        )

                    }
                }
            }.launchIn(this)
        }
    }
}

This is the implementation of the MoviesViewModel, which is responsible for fetching and providing the list of popular movies to the UI layer. It uses the GetPopularMovies use case to fetch the data from the repository and updates the UI state based on the result of the operation.

Kotlin
package com.softaai.mvvmdemo.presentation.viewmodel

import com.softaai.mvvmdemo.domain.model.Movie


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
data class MovieUiState(
    val moviesList: List<Movie> = emptyList(),
    val isLoading: Boolean = false
)

The @HiltViewModel annotation is used to inject dependencies into a ViewModel using Hilt. When a ViewModel is annotated with @HiltViewModel, Hilt generates a factory for the ViewModel and provides dependencies to the ViewModel via this factory. This way, the ViewModel can easily access dependencies, such as use cases or repositories, without the need to manually create and inject them.

The ViewModel uses a mutableStateOf() function to create a state object that can be updated from anywhere in the ViewModel. The state object is exposed as an immutable State object to the UI layer, which can observe it and update the UI accordingly.

The ViewModel also uses the viewModelScope to launch a coroutine that executes the use case, and observes the result of the operation using the onEach operator. Based on the result, the ViewModel updates the UI state accordingly, indicating whether the data is being loaded, whether it has been loaded successfully, or whether an error has occurred.

Define the Compose UI

First we define a MovieItem composable that displays a single movie item in a row. We are using CoilImage from the Coil library to display the movie poster image, and Row and Column composable functions to create the layout.

Kotlin
package com.softaai.mvvmdemo.presentation.ui.compose

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.softaai.mvvmdemo.domain.model.Movie


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */

@Composable
fun MovieItem(
    movie: Movie,
    onItemClick: (Movie) -> Unit
) {
    Row(
        modifier = Modifier
            .clickable { onItemClick(movie) }
            .padding(vertical = 16.dp, horizontal = 16.dp)
            .fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically
    ) {

        CoilImage(imageUrl = movie.posterUrl)

        Spacer(modifier = Modifier.width(16.dp))
        Column {
            Text(
                text = movie.title,
                fontWeight = FontWeight.Bold,
                fontSize = 20.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = movie.releaseDate,
                fontWeight = FontWeight.Normal,
                fontSize = 16.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
        }
    }
}


CoilImage Composable Function

Kotlin
package com.softaai.mvvmdemo.presentation.ui.compose

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import com.softaai.mvvmdemo.R


/**
 * Created by amoljp19 on 4/19/2023.
 * softAai Apps.
 */
@Composable
fun CoilImage(imageUrl: String) {
    Image(
        painter = rememberImagePainter(
            data = "https://image.tmdb.org/t/p/w600_and_h900_bestv2${imageUrl}",
            builder = {
                // Optional: Add image transformations
                placeholder(R.drawable.ic_launcher_foreground)
            }
        ),
        contentDescription = "Coil Image",
        modifier = Modifier
            .size(80.dp)
            .clip(RoundedCornerShape(4.dp))
    )
}

A composable function called CoilImage, displays an image using Coil library in Jetpack Compose. The function takes a String parameter called imageUrl which is the URL of the image to be displayed.

Finally, we will create a MoviesListScreen composable function that displays a list of popular movies using a LazyColumn.

Kotlin
package com.softaai.mvvmdemo.presentation.ui.compose

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.softaai.mvvmdemo.presentation.viewmodel.MoviesViewModel


/**
 * Created by amoljp19 on 4/18/2023.
 * softAai Apps.
 */
@Composable
fun MovieListScreen(moviesViewModel: MoviesViewModel = hiltViewModel()) {

    val state = moviesViewModel.state.value

    LazyColumn(
        Modifier.fillMaxSize(),
        contentPadding = PaddingValues(bottom = 16.dp)
    ) {
        items(state.moviesList.size) { i ->
            MovieItem(movie = state.moviesList[i], onItemClick = {})
        }
    }
}

The Composable function MovieListScreen which takes a MoviesViewModel as a parameter and sets its default value using the hiltViewModel() function provided by the Hilt library that allows you to retrieve a ViewModel instance that is scoped to the current Compose component. This is useful because it allows you to inject dependencies directly into your ViewModel using the Hilt dependency injection system.

By using hiltViewModel() instead of creating a new instance of the MoviesViewModel class manually, you ensure that the instance of the MoviesViewModel used in the MovieListScreen composable is the same instance that is injected by Hilt into the ViewModel.

Inside the function, it gets the current state of the ViewModel using moviesViewModel.state.value and stores it in a variable called state.

It then creates a LazyColumn with Modifier.fillMaxSize() and a content padding of PaddingValues(bottom = 16.dp). Inside the LazyColumn, it creates a list of items using the items function, which iterates over the state.moviesList and creates a MovieItem for each movie.

The MovieItem composable is passed the movie object from the current iteration, and an empty lambda function onItemClick (which could be used to handle clicks on the item).

Putting it all together

Now, we can put all the pieces together in our MainActivity, which is annotated with the @AndroidEntryPoint annotation. This annotation is part of the Hilt library, and it allows Hilt to generate a component for the activity and provide dependencies to its fields and methods.

Kotlin
package com.softaai.mvvmdemo

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.softaai.mvvmdemo.presentation.ui.compose.MovieListScreen
import com.softaai.mvvmdemo.presentation.ui.theme.MVVMDemoTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MVVMDemoTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    MovieListScreen()
                }
            }
        }
    }
}

Inside the onCreate method, the setContent method is used to set the main content of the activity. In this case, the content is the MovieListScreen composable function, which displays a list of movies.

Note — I have provided proper guidance on how to display the list of movies. Now, you can continue building the movie details screen by following similar patterns as discussed earlier. If you face any issues or need any further assistance, feel free to ask me.

Conclusion

In this article, we have demonstrated how to build an Android app using Clean Architecture, MVVM, Kotlin, Room, Hilt, Retrofit, Moshi, Flow, and Jetpack Compose. We have covered all aspects of the app development process, including defining the data model, implementing the repository layer, defining the ViewModel, and defining the UI. By following these best practices, we can create robust and maintainable Android apps that are easy to test and evolve over time.

Note — I have provided proper guidance on how to display the list of movies. I now expect you to complete the remaining work by following the guidelines.

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!