DEV Community

Devika Android
Devika Android

Posted on

Android MVVM: Pagination with Retrofit, Room Database & LiveData

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")

Enter fullscreen mode Exit fullscreen mode

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>
}

Enter fullscreen mode Exit fullscreen mode

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
)


Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}



Enter fullscreen mode Exit fullscreen mode

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)

}

Enter fullscreen mode Exit fullscreen mode

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)
    }

}


Enter fullscreen mode Exit fullscreen mode

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)
        }

    }
}


Enter fullscreen mode Exit fullscreen mode

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()
                }
            }
        })

    }
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)