In this blog, we will build a complete Android app using MVVM architecture that supports pagination using Retrofit and stores data locally using Room Database.
We will also learn how to handle API responses, implement infinite scrolling, manage loading states, and update boolean values dynamically in the database.
By the end of this tutorial, you will have a production-ready understanding of how modern Android apps handle pagination and offline data caching.
In this step, we add all the necessary dependencies required for building our pagination system using MVVM architecture.
This includes Room Database for local storage, Retrofit for API calls, Coroutines for background operations, and Lifecycle components for managing UI-related data.
//sdp
implementation("com.intuit.sdp:sdp-android:1.1.1")
val roomVersion = "2.8.3"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
kapt("androidx.room:room-compiler:$roomVersion")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
// Lifecycle ViewModel & Runtime (latest stable Lifecycle)
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
Setting up Retrofit API Client
we configure our Retrofit client using a singleton pattern to ensure a single instance is used throughout the app. The base URL is defined, and Gson is used to automatically convert JSON responses into Kotlin data classes.
We also attach an OkHttp logging interceptor, which helps in debugging by logging complete request and response data in the console.
object ApiClient {
private const val BASE_URL = "https://dummyjson.com/"
private val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val okHttp = OkHttpClient.Builder()
.addInterceptor(logging)
.build()
val apiService: ApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
interface ApiService {
@GET("todos")
suspend fun getTodos(
@Query("limit") limit: Int,
@Query("skip") skip: Int
): Response<TodoModel>
}
Model For Api and Entity for Database
data class TodoModel(
@SerializedName("limit")
@Expose
var limit: Int,
@SerializedName("skip")
@Expose
var skip: Int,
@SerializedName("todos")
@Expose
var todos: List<Todo>,
@SerializedName("total")
@Expose
var total: Int
) {
data class Todo(
@SerializedName("completed")
@Expose
var completed: Boolean,
@SerializedName("id")
@Expose
var id: Int,
@SerializedName("todo")
@Expose
var todo: String,
@SerializedName("userId")
@Expose
var userId: Int
)
}
@Entity(tableName = "todosTable")
data class TodoEntity(
@PrimaryKey val id: Int,
val todo: String,
val completed: Boolean,
val userId: Int,
val page: Int
)
Setting up Room Database
In this step, we configure our Room Database by defining all the required entities, including both User and Todo tables. We also declare abstract DAO methods to interact with the database.
The DatabaseBuilder ensures a singleton instance of the database using a synchronized block. Additionally, fallbackToDestructiveMigration() is used to handle schema changes during development by recreating the database when the version is updated.
@Database(entities = [User::class,TodoEntity::class], version = 2)
abstract class AppDatabase: RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun todoDao(): TodoDAO
}
object DatabaseBuilder {
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"user_db"
).fallbackToDestructiveMigration().build()
INSTANCE = instance
instance
}
}
}
Dao class for inser, read , delte & update
@Dao
interface TodoDAO {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(list: List<TodoEntity>)
@Query("SELECT * FROM todosTable ORDER BY id ASC")
fun getTodos(): LiveData<List<TodoEntity>>
@Query("UPDATE todosTable SET completed = :status WHERE id = :id")
suspend fun updateCompleted(id: Int, status: Boolean)
@Query("DELETE FROM todosTable WHERE id = :id")
suspend fun deleteItem(id: Int)
}
Repository
class TodoRepository(
private val api: ApiService,
private val dao: TodoDAO
) {
fun getTodos(): LiveData<List<TodoEntity>> {
return dao.getTodos()
}
suspend fun loadTodos(page: Int, limit: Int) {
val skip = page * limit
val response = api.getTodos(limit, skip)
val body = response.body()
if (response.isSuccessful && body != null) {
val list = body.todos.map {
TodoEntity(
id = it.id,
todo = it.todo,
completed = it.completed,
userId = it.userId,
page = page
)
}
dao.insertAll(list)
} else {
Log.e("API_ERROR", response.message())
}
}
suspend fun toggleTodo(todo: TodoEntity) {
dao.updateCompleted(todo.id, !todo.completed)
}
}
Modelclass with Factory
class TODOViewModel(
private val repo: TodoRepository
): ViewModel() {
private var page = 0
private val limit = 10
val todos: LiveData<List<TodoEntity>> = repo.getTodos()
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> = _isLoading
fun loadNextPage() {
if (_isLoading.value == true) return
_isLoading.value = true
viewModelScope.launch {
try {
repo.loadTodos(page, limit)
page++
} catch (e: Exception) {
e.printStackTrace()
} finally {
_isLoading.value = false
}
}
}
fun toggle(todo: TodoEntity) {
viewModelScope.launch {
repo.toggleTodo(todo)
}
}
}
class ViewModelFactory(
private val repo: TodoRepository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when (modelClass) {
TODOViewModel::class.java -> TODOViewModel(repo) as T
else -> super.create(modelClass)
}
}
}
Set up with all Data in MAinActivity
class ApiWithRoomDBActivity : AppCompatActivity() {
private lateinit var viewModel: TODOViewModel
private lateinit var dao: TodoDAO
private lateinit var repo: TodoRepository
private lateinit var adapter: TodoAdapter
private lateinit var binding: ActivityApiWithRoomDbactivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = DataBindingUtil.setContentView(this, R.layout.activity_api_with_room_dbactivity)
dao = DatabaseBuilder.getInstance(this).todoDao()
repo = TodoRepository(ApiClient.apiService, dao)
viewModel =
ViewModelProvider.create(this, ViewModelFactory(repo = repo))[TODOViewModel::class.java]
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initView()
}
private fun initView() {
adapter = TodoAdapter(onUpdate = { itemData ->
viewModel.toggle(itemData)
}, onDelete = { itemData ->
lifecycleScope.launch {
dao.deleteItem(itemData.id)
}
})
binding.rvTodo.layoutManager = LinearLayoutManager(this)
binding.rvTodo.adapter = adapter
viewModel.loadNextPage()
viewModel.todos.observe(this) { list ->
adapter.addAll(ArrayList(list))
}
viewModel.isLoading.observe(this) { isLoading ->
if (isLoading) {
binding.pbTodo.visibility = View.VISIBLE
} else {
binding.pbTodo.visibility = View.GONE
}
}
binding.rvTodo.addOnScrollListener(object :
RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(rv, dx, dy)
if (!rv.canScrollVertically(1)) {
viewModel.loadNextPage()
}
}
})
}
}
Top comments (0)