├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── gradle.xml ├── jarRepositories.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── spiraldev │ │ └── mvvmpaging │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── spiraldev │ │ │ └── mvvmpaging │ │ │ ├── MVVMPagingApp.kt │ │ │ ├── adapters │ │ │ ├── MoviesPagedListAdapter.kt │ │ │ └── viewholders │ │ │ │ ├── MovieItemViewHolder.kt │ │ │ │ └── NetworkStateViewHolder.kt │ │ │ ├── data │ │ │ ├── local │ │ │ │ ├── DbConsts.kt │ │ │ │ ├── MovieEntity.kt │ │ │ │ ├── MoviesDao.kt │ │ │ │ └── MoviesDatabase.kt │ │ │ └── remote │ │ │ │ ├── ApiConsts.kt │ │ │ │ ├── ApiService.kt │ │ │ │ ├── NetworkState.kt │ │ │ │ ├── datasources │ │ │ │ └── PagedListMovieBoundaryCallback.kt │ │ │ │ ├── utils │ │ │ │ └── PagingRequestHelper.java │ │ │ │ └── vo │ │ │ │ ├── MovieModel.kt │ │ │ │ └── ResponseModel.kt │ │ │ ├── di │ │ │ └── modules │ │ │ │ └── ApplicationModule.kt │ │ │ └── ui │ │ │ ├── MainActivity.kt │ │ │ └── MainActivityViewModel.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── poster_placeholder.png │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── movie_list_item.xml │ │ └── network_state_item.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── spiraldev │ └── mvvmpaging │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── offline_app_diagram.png ├── online_app_diagram.png ├── screenshots ├── sc1.jpg └── sc2.jpg └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | 16 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | xmlns:android 17 | 18 | ^$ 19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 | xmlns:.* 28 | 29 | ^$ 30 | 31 | 32 | BY_NAME 33 | 34 |
35 |
36 | 37 | 38 | 39 | .*:id 40 | 41 | http://schemas.android.com/apk/res/android 42 | 43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 | .*:name 51 | 52 | http://schemas.android.com/apk/res/android 53 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | name 62 | 63 | ^$ 64 | 65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | style 73 | 74 | ^$ 75 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | .* 84 | 85 | ^$ 86 | 87 | 88 | BY_NAME 89 | 90 |
91 |
92 | 93 | 94 | 95 | .* 96 | 97 | http://schemas.android.com/apk/res/android 98 | 99 | 100 | ANDROID_ATTRIBUTE_ORDER 101 | 102 |
103 |
104 | 105 | 106 | 107 | .* 108 | 109 | .* 110 | 111 | 112 | BY_NAME 113 | 114 |
115 |
116 |
117 |
118 | 119 | 121 |
122 |
-------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MVVMPaging 2 | > Demonstartions of using Paging Library with MVVM architecture. 3 | 4 | ## Screenshots 5 | 6 |

7 | 8 | 9 |

10 | 11 | ## Variations 12 | 13 | This project hosts each sample app in separate repository branches. 14 | 15 | ### Stable samples 16 | 17 | | Sample | Description | 18 | | ------------- | ------------- | 19 | | [paging](https://github.com/DataSmoother/MVVMPaging/tree/paging) | The base for the rest of the branches.
Paging Library + Architecture Components + Retrofit + RxJava | 20 | | [paging-dagger](https://github.com/SpiralDevelopment/MVVMPaging/tree/paging-dagger) | Paging Library + Architecture Components + Retrofit + RxJava + Dagger Android | 21 | | [paging-offline](https://github.com/SpiralDevelopment/MVVMPaging/tree/paging-offline) | Paging Library + Architecture Components + Retrofit + RxJava + Dagger Android + Offline-first| 22 | | [paging-dagger-hilt](https://github.com/SpiralDevelopment/MVVMPaging/tree/paging-dagger-hilt) | Paging Library + Architecture Components + Retrofit + RxJava + Dagger Hilt + Offline-first| 23 | 24 | ## App Architectures 25 | 26 | There are 2 types of architecture used in the samples. 27 | 28 | ### Online App Architecture 29 | 30 | Online app architecture fetches data from API and directly binds it to the UI. Used in [paging](https://github.com/DataSmoother/MVVMPaging/tree/paging) and [paging-dagger](https://github.com/SpiralDevelopment/MVVMPaging/tree/paging-dagger) samples 31 | 32 |

33 | 34 |

35 | 36 | 37 | ### Offline-First App Architecture 38 | 39 | Offline-first app architecture fetches data from API and inserts that data into database, then those data are binded to the UI. Used in [paging-offline](https://github.com/DataSmoother/MVVMPaging/tree/paging-offline) and [paging-dagger-hilt](https://github.com/SpiralDevelopment/MVVMPaging/tree/paging-dagger-hilt) samples 40 | 41 | ![offline-app](https://github.com/SpiralDevelopment/MVVMPaging/blob/paging-dagger-hilt/offline_app_diagram.png) 42 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'dagger.hilt.android.plugin' 6 | 7 | android { 8 | compileSdkVersion versions.compileSdk 9 | buildToolsVersion "29.0.3" 10 | 11 | defaultConfig { 12 | applicationId "com.spiraldev.mvvmpaging" 13 | minSdkVersion versions.minSdk 14 | targetSdkVersion versions.targetSdk 15 | versionCode 1 16 | versionName "1.0" 17 | 18 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 25 | } 26 | } 27 | 28 | compileOptions { 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | 33 | kotlinOptions { 34 | jvmTarget = JavaVersion.VERSION_1_8.toString() 35 | } 36 | } 37 | 38 | dependencies { 39 | implementation fileTree(dir: "libs", include: ["*.jar"]) 40 | implementation "org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin}" 41 | implementation 'androidx.core:core-ktx:1.3.0' 42 | implementation "androidx.activity:activity-ktx:1.1.0" 43 | 44 | // UI 45 | implementation "com.google.android.material:material:${versions.material}" 46 | implementation "androidx.appcompat:appcompat:${versions.androidxAppcompat}" 47 | implementation "androidx.recyclerview:recyclerview:${versions.androidx}" 48 | 49 | // ViewModel and LiveData 50 | implementation "androidx.lifecycle:lifecycle-extensions:${versions.lifecycle}" 51 | 52 | // Room 53 | implementation "androidx.room:room-runtime:${versions.persistence}" 54 | kapt "androidx.room:room-compiler:${versions.persistence}" 55 | 56 | // Paging 57 | implementation "androidx.paging:paging-runtime:${versions.paging}" 58 | implementation "androidx.paging:paging-rxjava2:${versions.paging}" 59 | 60 | // Glide 61 | implementation "com.github.bumptech.glide:glide:${versions.glide}" 62 | kapt "com.github.bumptech.glide:compiler:${versions.glide}" 63 | 64 | // Retrofit 65 | implementation "com.squareup.retrofit2:retrofit:${versions.retrofit}" 66 | implementation "com.squareup.retrofit2:converter-gson:${versions.retrofit}" 67 | implementation "com.squareup.retrofit2:adapter-rxjava2:${versions.retrofit}" 68 | implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}" 69 | 70 | 71 | // RxJava 72 | implementation "io.reactivex.rxjava2:rxandroid:${versions.rxAndroid}" 73 | implementation "io.reactivex.rxjava2:rxjava:${versions.rxJava}" 74 | 75 | //Dagger Hilt 76 | implementation "com.google.dagger:hilt-android:${versions.daggerHilt}" 77 | kapt "com.google.dagger:hilt-android-compiler:${versions.daggerHilt}" 78 | implementation "androidx.hilt:hilt-lifecycle-viewmodel:${versions.androidXHilt}" 79 | kapt "androidx.hilt:hilt-compiler:${versions.androidXHilt}" 80 | 81 | // Tests 82 | testImplementation 'junit:junit:4.12' 83 | androidTestImplementation 'androidx.test.ext:junit:1.1.1' 84 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 85 | 86 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/spiraldev/mvvmpaging/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.spiraldev.mvvmpaging", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/MVVMPagingApp.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | 7 | @HiltAndroidApp 8 | class MVVMPagingApp : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/adapters/MoviesPagedListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.adapters 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.paging.PagedListAdapter 9 | import androidx.recyclerview.widget.DiffUtil 10 | import androidx.recyclerview.widget.RecyclerView 11 | import com.bumptech.glide.Glide 12 | import com.spiraldev.mvvmpaging.R 13 | import com.spiraldev.mvvmpaging.adapters.viewholders.MovieItemViewHolder 14 | import com.spiraldev.mvvmpaging.adapters.viewholders.NetworkStateViewHolder 15 | import com.spiraldev.mvvmpaging.data.local.MovieEntity 16 | import com.spiraldev.mvvmpaging.data.remote.NetworkState 17 | 18 | 19 | class MoviesPagedListAdapter(private val retryCallback: () -> Unit) : 20 | PagedListAdapter(MovieDiffCallback()) { 21 | 22 | private var networkState: NetworkState? = null 23 | 24 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 25 | return when (viewType) { 26 | R.layout.movie_list_item -> MovieItemViewHolder.create(parent) 27 | R.layout.network_state_item -> NetworkStateViewHolder.create(parent, retryCallback) 28 | else -> throw IllegalArgumentException("Unknown view type") 29 | } 30 | } 31 | 32 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 33 | when (getItemViewType(position)) { 34 | R.layout.movie_list_item -> (holder as MovieItemViewHolder).bind(getItem(position)) 35 | R.layout.network_state_item -> (holder as NetworkStateViewHolder).bind(networkState) 36 | } 37 | } 38 | 39 | override fun getItemViewType(position: Int): Int { 40 | return if (hasExtraRow() && position == itemCount - 1) { 41 | R.layout.network_state_item 42 | } else { 43 | R.layout.movie_list_item 44 | } 45 | } 46 | 47 | private fun hasExtraRow(): Boolean { 48 | return networkState != null && networkState != NetworkState.LOADED 49 | } 50 | 51 | 52 | class MovieDiffCallback : DiffUtil.ItemCallback() { 53 | override fun areItemsTheSame(oldItem: MovieEntity, newItem: MovieEntity): Boolean { 54 | return oldItem.id == newItem.id 55 | } 56 | 57 | override fun areContentsTheSame(oldItem: MovieEntity, newItem: MovieEntity): Boolean { 58 | return oldItem.title == newItem.title 59 | && oldItem.posterUrl == newItem.posterUrl 60 | && oldItem.voteAverage == newItem.voteAverage 61 | } 62 | } 63 | 64 | fun setNetworkState(newNetworkState: NetworkState?) { 65 | if (currentList != null) { 66 | if (currentList!!.size != 0) { 67 | val previousState = this.networkState 68 | val hadExtraRow = hasExtraRow() 69 | this.networkState = newNetworkState 70 | val hasExtraRow = hasExtraRow() 71 | if (hadExtraRow != hasExtraRow) { 72 | if (hadExtraRow) { 73 | notifyItemRemoved(super.getItemCount()) 74 | } else { 75 | notifyItemInserted(super.getItemCount()) 76 | } 77 | } else if (hasExtraRow && previousState !== newNetworkState) { 78 | notifyItemChanged(itemCount - 1) 79 | } 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/adapters/viewholders/MovieItemViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.adapters.viewholders 2 | 3 | import android.util.Log 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.bumptech.glide.Glide 9 | import com.spiraldev.mvvmpaging.R 10 | import com.spiraldev.mvvmpaging.data.local.MovieEntity 11 | import com.spiraldev.mvvmpaging.data.remote.Api 12 | import com.spiraldev.mvvmpaging.data.remote.vo.MovieModel 13 | import kotlinx.android.synthetic.main.movie_list_item.view.* 14 | 15 | class MovieItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { 16 | fun bind(movie: MovieEntity?) { 17 | itemView.cv_movie_title.text = movie?.title 18 | itemView.cv_movie_release_date.text = movie?.releaseDate 19 | 20 | Glide.with(itemView.context) 21 | .load(movie?.posterUrl) 22 | .placeholder(R.drawable.poster_placeholder) 23 | .into(itemView.cv_iv_movie_poster) 24 | } 25 | 26 | companion object { 27 | fun create(parent: ViewGroup): MovieItemViewHolder { 28 | val layoutInflater = LayoutInflater.from(parent.context) 29 | val view = layoutInflater.inflate(R.layout.movie_list_item, parent, false) 30 | return MovieItemViewHolder(view) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/adapters/viewholders/NetworkStateViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.adapters.viewholders 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.spiraldev.mvvmpaging.R 8 | import com.spiraldev.mvvmpaging.data.remote.NetworkState 9 | import com.spiraldev.mvvmpaging.data.remote.Status 10 | import kotlinx.android.synthetic.main.network_state_item.view.* 11 | 12 | class NetworkStateViewHolder(val view: View, private val retryCallback: () -> Unit) : 13 | RecyclerView.ViewHolder(view) { 14 | 15 | init { 16 | itemView.retry_button.setOnClickListener { retryCallback() } 17 | } 18 | 19 | fun bind(networkState: NetworkState?) { 20 | 21 | if (networkState?.message != null) { 22 | itemView.error_txt.visibility = View.VISIBLE 23 | itemView.error_txt.text = networkState.message 24 | } else { 25 | itemView.error_txt.visibility = View.GONE 26 | } 27 | 28 | itemView.retry_button.visibility = 29 | if (networkState?.status == Status.FAILED) View.VISIBLE else View.GONE 30 | itemView.progress_bar.visibility = 31 | if (networkState?.status == Status.RUNNING) View.VISIBLE else View.GONE 32 | } 33 | 34 | companion object { 35 | fun create(parent: ViewGroup, retryCallback: () -> Unit): NetworkStateViewHolder { 36 | val layoutInflater = LayoutInflater.from(parent.context) 37 | val view = layoutInflater.inflate(R.layout.network_state_item, parent, false) 38 | return NetworkStateViewHolder(view, retryCallback) 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/local/DbConsts.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.local 2 | 3 | object DB { 4 | const val DATABASE_NAME = "Movies.DB" 5 | const val DATABASE_VERSION = 1 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/local/MovieEntity.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.local 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "movie") 7 | data class MovieEntity( 8 | @PrimaryKey(autoGenerate = true) val id: Long, 9 | val title: String, 10 | val popularity: Double, 11 | val voteAverage: Double, 12 | val posterUrl: String, 13 | val releaseDate: String 14 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/local/MoviesDao.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.local 2 | 3 | import androidx.paging.DataSource 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | 9 | @Dao 10 | interface MoviesDao { 11 | 12 | @Insert(onConflict = OnConflictStrategy.REPLACE) 13 | fun insert(movieList: List) 14 | 15 | @Query("SELECT * FROM movie ORDER BY id") 16 | fun allMovies(): DataSource.Factory 17 | 18 | @Query("SELECT COUNT(*) FROM Movie") 19 | fun getCount(): Int 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/local/MoviesDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.local 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import com.spiraldev.mvvmpaging.data.local.DB.DATABASE_NAME 8 | import com.spiraldev.mvvmpaging.data.local.DB.DATABASE_VERSION 9 | 10 | 11 | @Database(entities = [MovieEntity::class], version = DATABASE_VERSION, exportSchema = false) 12 | abstract class MoviesDatabase : RoomDatabase() { 13 | 14 | abstract fun moviesDao(): MoviesDao 15 | 16 | companion object { 17 | fun buildDatabase(context: Context): MoviesDatabase { 18 | return Room.databaseBuilder(context, MoviesDatabase::class.java, DATABASE_NAME) 19 | .build() 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/remote/ApiConsts.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.remote 2 | 3 | const val POST_PER_PAGE = 20 4 | 5 | object Api { 6 | const val THE_MOVIE_URL = "https://api.themoviedb.org/" 7 | const val IMAGES_URL = "https://image.tmdb.org/t/p/w185_and_h278_bestv2" 8 | } 9 | 10 | object Query { 11 | const val API_KEY = "api_key" 12 | const val PAGE = "page" 13 | 14 | const val API_KEY_VALUE = "73600704c1c70585df13771486247174" 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/remote/ApiService.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.remote 2 | 3 | import com.spiraldev.mvvmpaging.data.remote.Query.API_KEY 4 | import com.spiraldev.mvvmpaging.data.remote.Query.API_KEY_VALUE 5 | import com.spiraldev.mvvmpaging.data.remote.Query.PAGE 6 | import com.spiraldev.mvvmpaging.data.remote.vo.ResponseModel 7 | import io.reactivex.Flowable 8 | import io.reactivex.Single 9 | import retrofit2.Call 10 | import retrofit2.http.GET 11 | import retrofit2.http.Query 12 | 13 | interface ApiService { 14 | 15 | @GET("3/movie/popular") 16 | fun fetchPopularMovies(@Query(PAGE) page: Int, 17 | @Query(API_KEY) apiKey: String = API_KEY_VALUE): Single 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/remote/NetworkState.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.remote 2 | 3 | enum class Status { 4 | RUNNING, 5 | SUCCESS, 6 | FAILED 7 | } 8 | 9 | @Suppress("DataClassPrivateConstructor") 10 | data class NetworkState private constructor( 11 | val status: Status, 12 | val message: String? = null) { 13 | companion object { 14 | val LOADED = NetworkState(Status.SUCCESS) 15 | val LOADING = NetworkState(Status.RUNNING) 16 | fun error(msg: String?) = NetworkState(Status.FAILED, msg) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/remote/datasources/PagedListMovieBoundaryCallback.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.remote.datasources 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.paging.PagedList 5 | import com.spiraldev.mvvmpaging.data.local.MovieEntity 6 | import com.spiraldev.mvvmpaging.data.local.MoviesDatabase 7 | import com.spiraldev.mvvmpaging.data.remote.ApiService 8 | import com.spiraldev.mvvmpaging.data.remote.NetworkState 9 | import com.spiraldev.mvvmpaging.data.remote.POST_PER_PAGE 10 | import com.spiraldev.mvvmpaging.data.remote.utils.PagingRequestHelper 11 | import com.spiraldev.mvvmpaging.data.remote.vo.toMovieEntity 12 | import io.reactivex.disposables.CompositeDisposable 13 | import io.reactivex.schedulers.Schedulers 14 | import java.util.concurrent.Executors 15 | 16 | class PagedListMovieBoundaryCallback( 17 | private val apiService: ApiService, 18 | private val moviesDb: MoviesDatabase, 19 | val compositeDisposable: CompositeDisposable 20 | ) : PagedList.BoundaryCallback() { 21 | 22 | val networkState = MutableLiveData() 23 | 24 | private var pageNum = 1 25 | private val executor = Executors.newSingleThreadExecutor() 26 | private val helper = PagingRequestHelper(executor) 27 | private lateinit var rType: PagingRequestHelper.RequestType 28 | 29 | fun retry() { 30 | fetchAndStoreMovies(rType) 31 | } 32 | 33 | override fun onZeroItemsLoaded() { 34 | fetchAndStoreMovies(PagingRequestHelper.RequestType.INITIAL) 35 | } 36 | 37 | override fun onItemAtEndLoaded(itemAtEnd: MovieEntity) { 38 | 39 | executor.execute { 40 | val page = moviesDb.moviesDao().getCount() 41 | pageNum = page / POST_PER_PAGE 42 | pageNum++ 43 | } 44 | 45 | fetchAndStoreMovies(PagingRequestHelper.RequestType.AFTER) 46 | } 47 | 48 | private fun fetchAndStoreMovies(rType: PagingRequestHelper.RequestType) { 49 | this.rType = rType 50 | 51 | helper.runIfNotRunning(rType) { callback -> 52 | networkState.postValue(NetworkState.LOADING) 53 | 54 | compositeDisposable.add(apiService.fetchPopularMovies(pageNum) 55 | .map { response -> response.movieList.map { it.toMovieEntity() } } 56 | .doOnSuccess { 57 | moviesDb.moviesDao().insert(it) 58 | } 59 | .subscribeOn(Schedulers.io()) 60 | .subscribe( 61 | { 62 | networkState.postValue(NetworkState.LOADED) 63 | callback.recordSuccess() 64 | }, 65 | { 66 | networkState.postValue(NetworkState.error(it.message)) 67 | callback.recordFailure(it) 68 | } 69 | )) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/remote/utils/PagingRequestHelper.java: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.remote.utils; 2 | 3 | /* 4 | * Copyright (c) 2018 Razeware LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, 17 | * distribute, sublicense, create a derivative work, and/or sell copies of the 18 | * Software in any work that is designed, intended, or marketed for pedagogical or 19 | * instructional purposes related to programming, coding, application development, 20 | * or information technology. Permission for such use, copying, modification, 21 | * merger, publication, distribution, sublicensing, creation of derivative works, 22 | * or sale is expressly withheld. 23 | * 24 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 25 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 26 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 27 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 28 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 29 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 30 | * THE SOFTWARE. 31 | */ 32 | 33 | 34 | import androidx.annotation.AnyThread; 35 | import androidx.annotation.GuardedBy; 36 | import androidx.annotation.NonNull; 37 | import androidx.annotation.Nullable; 38 | import androidx.annotation.VisibleForTesting; 39 | 40 | import java.util.Arrays; 41 | import java.util.concurrent.CopyOnWriteArrayList; 42 | import java.util.concurrent.Executor; 43 | import java.util.concurrent.atomic.AtomicBoolean; 44 | 45 | public class PagingRequestHelper { 46 | @NonNull 47 | final CopyOnWriteArrayList mListeners = new CopyOnWriteArrayList<>(); 48 | private final Object mLock = new Object(); 49 | private final Executor mRetryService; 50 | @GuardedBy("mLock") 51 | private final RequestQueue[] mRequestQueues = new RequestQueue[] 52 | {new RequestQueue(RequestType.INITIAL), 53 | new RequestQueue(RequestType.BEFORE), 54 | new RequestQueue(RequestType.AFTER)}; 55 | 56 | /** 57 | * Creates a new PagingRequestHelper with the given {@link Executor} which is used to run 58 | * retry actions. 59 | * 60 | * @param retryService The {@link Executor} that can run the retry actions. 61 | */ 62 | public PagingRequestHelper(@NonNull Executor retryService) { 63 | mRetryService = retryService; 64 | } 65 | 66 | /** 67 | * Adds a new listener that will be notified when any request changes {@link Status state}. 68 | * 69 | * @param listener The listener that will be notified each time a request's status changes. 70 | * @return True if it is added, false otherwise (e.g. it already exists in the list). 71 | */ 72 | @AnyThread 73 | public boolean addListener(@NonNull Listener listener) { 74 | return mListeners.add(listener); 75 | } 76 | 77 | /** 78 | * Removes the given listener from the listeners list. 79 | * 80 | * @param listener The listener that will be removed. 81 | * @return True if the listener is removed, false otherwise (e.g. it never existed) 82 | */ 83 | public boolean removeListener(@NonNull Listener listener) { 84 | return mListeners.remove(listener); 85 | } 86 | 87 | /** 88 | * Runs the given {@link Request} if no other requests in the given request type is already 89 | * running. 90 | *

91 | * If run, the request will be run in the current thread. 92 | * 93 | * @param type The type of the request. 94 | * @param request The request to run. 95 | * @return True if the request is run, false otherwise. 96 | */ 97 | @SuppressWarnings("WeakerAccess") 98 | @AnyThread 99 | public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) { 100 | boolean hasListeners = !mListeners.isEmpty(); 101 | StatusReport report = null; 102 | synchronized (mLock) { 103 | RequestQueue queue = mRequestQueues[type.ordinal()]; 104 | if (queue.mRunning != null) { 105 | return false; 106 | } 107 | queue.mRunning = request; 108 | queue.mStatus = Status.RUNNING; 109 | queue.mFailed = null; 110 | queue.mLastError = null; 111 | if (hasListeners) { 112 | report = prepareStatusReportLocked(); 113 | } 114 | } 115 | if (report != null) { 116 | dispatchReport(report); 117 | } 118 | final RequestWrapper wrapper = new RequestWrapper(request, this, type); 119 | wrapper.run(); 120 | return true; 121 | } 122 | 123 | @GuardedBy("mLock") 124 | private StatusReport prepareStatusReportLocked() { 125 | Throwable[] errors = new Throwable[]{ 126 | mRequestQueues[0].mLastError, 127 | mRequestQueues[1].mLastError, 128 | mRequestQueues[2].mLastError 129 | }; 130 | return new StatusReport( 131 | getStatusForLocked(RequestType.INITIAL), 132 | getStatusForLocked(RequestType.BEFORE), 133 | getStatusForLocked(RequestType.AFTER), 134 | errors 135 | ); 136 | } 137 | 138 | @GuardedBy("mLock") 139 | private Status getStatusForLocked(RequestType type) { 140 | return mRequestQueues[type.ordinal()].mStatus; 141 | } 142 | 143 | @AnyThread 144 | @VisibleForTesting 145 | void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) { 146 | StatusReport report = null; 147 | final boolean success = throwable == null; 148 | boolean hasListeners = !mListeners.isEmpty(); 149 | synchronized (mLock) { 150 | RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()]; 151 | queue.mRunning = null; 152 | queue.mLastError = throwable; 153 | if (success) { 154 | queue.mFailed = null; 155 | queue.mStatus = Status.SUCCESS; 156 | } else { 157 | queue.mFailed = wrapper; 158 | queue.mStatus = Status.FAILED; 159 | } 160 | if (hasListeners) { 161 | report = prepareStatusReportLocked(); 162 | } 163 | } 164 | if (report != null) { 165 | dispatchReport(report); 166 | } 167 | } 168 | 169 | private void dispatchReport(StatusReport report) { 170 | for (Listener listener : mListeners) { 171 | listener.onStatusChange(report); 172 | } 173 | } 174 | 175 | /** 176 | * Retries all failed requests. 177 | * 178 | * @return True if any request is retried, false otherwise. 179 | */ 180 | public boolean retryAllFailed() { 181 | final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length]; 182 | boolean retried = false; 183 | synchronized (mLock) { 184 | for (int i = 0; i < RequestType.values().length; i++) { 185 | toBeRetried[i] = mRequestQueues[i].mFailed; 186 | mRequestQueues[i].mFailed = null; 187 | } 188 | } 189 | for (RequestWrapper failed : toBeRetried) { 190 | if (failed != null) { 191 | failed.retry(mRetryService); 192 | retried = true; 193 | } 194 | } 195 | return retried; 196 | } 197 | 198 | /** 199 | * Represents the status of a Request for each {@link RequestType}. 200 | */ 201 | public enum Status { 202 | /** 203 | * There is current a running request. 204 | */ 205 | RUNNING, 206 | /** 207 | * The last request has succeeded or no such requests have ever been run. 208 | */ 209 | SUCCESS, 210 | /** 211 | * The last request has failed. 212 | */ 213 | FAILED 214 | } 215 | 216 | /** 217 | * Available request types. 218 | */ 219 | public enum RequestType { 220 | /** 221 | * Corresponds to an initial request made to a {@link DataSource} or the empty state for 222 | * a {@link android.arch.paging.PagedList.BoundaryCallback BoundaryCallback}. 223 | */ 224 | INITIAL, 225 | /** 226 | * Corresponds to the {@code loadBefore} calls in {@link DataSource} or 227 | * {@code onItemAtFrontLoaded} in 228 | * {@link android.arch.paging.PagedList.BoundaryCallback BoundaryCallback}. 229 | */ 230 | BEFORE, 231 | /** 232 | * Corresponds to the {@code loadAfter} calls in {@link DataSource} or 233 | * {@code onItemAtEndLoaded} in 234 | * {@link android.arch.paging.PagedList.BoundaryCallback BoundaryCallback}. 235 | */ 236 | AFTER 237 | } 238 | 239 | /** 240 | * Runner class that runs a request tracked by the {@link PagingRequestHelper}. 241 | *

242 | * When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)} 243 | * or {@link Callback#recordSuccess()} once and only once. This call 244 | * can be made any time. Until that method call is made, {@link PagingRequestHelper} will 245 | * consider the request is running. 246 | */ 247 | @FunctionalInterface 248 | public interface Request { 249 | /** 250 | * Should run the request and call the given {@link Callback} with the result of the 251 | * request. 252 | * 253 | * @param callback The callback that should be invoked with the result. 254 | */ 255 | void run(Callback callback); 256 | 257 | /** 258 | * Callback class provided to the {@link #run(Callback)} method to report the result. 259 | */ 260 | class Callback { 261 | private final AtomicBoolean mCalled = new AtomicBoolean(); 262 | private final RequestWrapper mWrapper; 263 | private final PagingRequestHelper mHelper; 264 | 265 | Callback(RequestWrapper wrapper, PagingRequestHelper helper) { 266 | mWrapper = wrapper; 267 | mHelper = helper; 268 | } 269 | 270 | /** 271 | * Call this method when the request succeeds and new data is fetched. 272 | */ 273 | @SuppressWarnings("unused") 274 | public final void recordSuccess() { 275 | if (mCalled.compareAndSet(false, true)) { 276 | mHelper.recordResult(mWrapper, null); 277 | } else { 278 | throw new IllegalStateException( 279 | "already called recordSuccess or recordFailure"); 280 | } 281 | } 282 | 283 | /** 284 | * Call this method with the failure message and the request can be retried via 285 | * {@link #retryAllFailed()}. 286 | * 287 | * @param throwable The error that occured while carrying out the request. 288 | */ 289 | @SuppressWarnings("unused") 290 | public final void recordFailure(@NonNull Throwable throwable) { 291 | //noinspection ConstantConditions 292 | if (throwable == null) { 293 | throw new IllegalArgumentException("You must provide a throwable describing" 294 | + " the error to record the failure"); 295 | } 296 | if (mCalled.compareAndSet(false, true)) { 297 | mHelper.recordResult(mWrapper, throwable); 298 | } else { 299 | throw new IllegalStateException( 300 | "already called recordSuccess or recordFailure"); 301 | } 302 | } 303 | } 304 | } 305 | 306 | /** 307 | * Listener interface to get notified by request status changes. 308 | */ 309 | public interface Listener { 310 | /** 311 | * Called when the status for any of the requests has changed. 312 | * 313 | * @param report The current status report that has all the information about the requests. 314 | */ 315 | void onStatusChange(@NonNull StatusReport report); 316 | } 317 | 318 | static class RequestWrapper implements Runnable { 319 | @NonNull 320 | final Request mRequest; 321 | @NonNull 322 | final PagingRequestHelper mHelper; 323 | @NonNull 324 | final RequestType mType; 325 | 326 | RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper, 327 | @NonNull RequestType type) { 328 | mRequest = request; 329 | mHelper = helper; 330 | mType = type; 331 | } 332 | 333 | @Override 334 | public void run() { 335 | mRequest.run(new Request.Callback(this, mHelper)); 336 | } 337 | 338 | void retry(Executor service) { 339 | service.execute(new Runnable() { 340 | @Override 341 | public void run() { 342 | mHelper.runIfNotRunning(mType, mRequest); 343 | } 344 | }); 345 | } 346 | } 347 | 348 | /** 349 | * Data class that holds the information about the current status of the ongoing requests 350 | * using this helper. 351 | */ 352 | public static final class StatusReport { 353 | /** 354 | * Status of the latest request that were submitted with {@link RequestType#INITIAL}. 355 | */ 356 | @NonNull 357 | public final Status initial; 358 | /** 359 | * Status of the latest request that were submitted with {@link RequestType#BEFORE}. 360 | */ 361 | @NonNull 362 | public final Status before; 363 | /** 364 | * Status of the latest request that were submitted with {@link RequestType#AFTER}. 365 | */ 366 | @NonNull 367 | public final Status after; 368 | @NonNull 369 | private final Throwable[] mErrors; 370 | 371 | StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after, 372 | @NonNull Throwable[] errors) { 373 | this.initial = initial; 374 | this.before = before; 375 | this.after = after; 376 | this.mErrors = errors; 377 | } 378 | 379 | /** 380 | * Convenience method to check if there are any running requests. 381 | * 382 | * @return True if there are any running requests, false otherwise. 383 | */ 384 | public boolean hasRunning() { 385 | return initial == Status.RUNNING 386 | || before == Status.RUNNING 387 | || after == Status.RUNNING; 388 | } 389 | 390 | /** 391 | * Convenience method to check if there are any requests that resulted in an error. 392 | * 393 | * @return True if there are any requests that finished with error, false otherwise. 394 | */ 395 | public boolean hasError() { 396 | return initial == Status.FAILED 397 | || before == Status.FAILED 398 | || after == Status.FAILED; 399 | } 400 | 401 | /** 402 | * Returns the error for the given request type. 403 | * 404 | * @param type The request type for which the error should be returned. 405 | * @return The {@link Throwable} returned by the failing request with the given type or 406 | * {@code null} if the request for the given type did not fail. 407 | */ 408 | @Nullable 409 | public Throwable getErrorFor(@NonNull RequestType type) { 410 | return mErrors[type.ordinal()]; 411 | } 412 | 413 | @Override 414 | public String toString() { 415 | return "StatusReport{" 416 | + "initial=" + initial 417 | + ", before=" + before 418 | + ", after=" + after 419 | + ", mErrors=" + Arrays.toString(mErrors) 420 | + '}'; 421 | } 422 | 423 | @Override 424 | public boolean equals(Object o) { 425 | if (this == o) return true; 426 | if (o == null || getClass() != o.getClass()) return false; 427 | StatusReport that = (StatusReport) o; 428 | if (initial != that.initial) return false; 429 | if (before != that.before) return false; 430 | if (after != that.after) return false; 431 | // Probably incorrect - comparing Object[] arrays with Arrays.equals 432 | return Arrays.equals(mErrors, that.mErrors); 433 | } 434 | 435 | @Override 436 | public int hashCode() { 437 | int result = initial.hashCode(); 438 | result = 31 * result + before.hashCode(); 439 | result = 31 * result + after.hashCode(); 440 | result = 31 * result + Arrays.hashCode(mErrors); 441 | return result; 442 | } 443 | } 444 | 445 | class RequestQueue { 446 | @NonNull 447 | final RequestType mRequestType; 448 | @Nullable 449 | RequestWrapper mFailed; 450 | @Nullable 451 | Request mRunning; 452 | @Nullable 453 | Throwable mLastError; 454 | @NonNull 455 | Status mStatus = Status.SUCCESS; 456 | 457 | RequestQueue(@NonNull RequestType requestType) { 458 | mRequestType = requestType; 459 | } 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/remote/vo/MovieModel.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.remote.vo 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.spiraldev.mvvmpaging.data.local.MovieEntity 5 | import com.spiraldev.mvvmpaging.data.remote.Api.IMAGES_URL 6 | 7 | data class MovieModel( 8 | @SerializedName("id") val id: Int, 9 | @SerializedName("title") val title: String, 10 | @SerializedName("popularity") val popularity: Double, 11 | @SerializedName("vote_average") val voteAverage: Double, 12 | @SerializedName("poster_path") val posterPath: String, 13 | @SerializedName("release_date") val releaseDate: String 14 | ) 15 | 16 | fun MovieModel.toMovieEntity() = 17 | MovieEntity(0, title, popularity, voteAverage, getPosterURL(posterPath), releaseDate) 18 | 19 | private fun getPosterURL(posterPath: String) = IMAGES_URL + posterPath 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/data/remote/vo/ResponseModel.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.data.remote.vo 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | 6 | data class ResponseModel( 7 | val page: Int, 8 | @SerializedName("results") 9 | val movieList: List, 10 | @SerializedName("total_pages") 11 | val totalPages: Int, 12 | @SerializedName("total_results") 13 | val totalResults: Int 14 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/di/modules/ApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.seemenstask.di.modules 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import com.spiraldev.mvvmpaging.data.local.MoviesDao 6 | import com.spiraldev.mvvmpaging.data.local.MoviesDatabase 7 | import com.spiraldev.mvvmpaging.data.remote.Api 8 | import com.spiraldev.mvvmpaging.data.remote.ApiService 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.components.ApplicationComponent 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import okhttp3.Interceptor 15 | import okhttp3.OkHttpClient 16 | import okhttp3.logging.HttpLoggingInterceptor 17 | import retrofit2.Retrofit 18 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 19 | import retrofit2.converter.gson.GsonConverterFactory 20 | import java.util.concurrent.TimeUnit 21 | import javax.inject.Singleton 22 | 23 | 24 | @Module 25 | @InstallIn(ApplicationComponent::class) 26 | class ApplicationModule { 27 | 28 | @Provides 29 | @Singleton 30 | fun provideOkHttpClient(): OkHttpClient { 31 | val requestInterceptor = Interceptor { chain -> 32 | 33 | val url = chain.request() 34 | .url() 35 | .newBuilder() 36 | .build() 37 | 38 | val request = chain.request() 39 | .newBuilder() 40 | .url(url) 41 | .build() 42 | 43 | return@Interceptor chain.proceed(request) 44 | } 45 | 46 | return OkHttpClient.Builder() 47 | .addInterceptor(requestInterceptor) 48 | .connectTimeout(60, TimeUnit.SECONDS) 49 | .build() 50 | } 51 | 52 | @Provides 53 | @Singleton 54 | fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { 55 | 56 | return Retrofit.Builder() 57 | .addConverterFactory(GsonConverterFactory.create()) 58 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 59 | .baseUrl(Api.THE_MOVIE_URL) 60 | .client(okHttpClient) 61 | .build() 62 | } 63 | 64 | @Provides 65 | @Singleton 66 | fun provideApiClient(retrofit: Retrofit): ApiService { 67 | return retrofit.create(ApiService::class.java) 68 | } 69 | 70 | @Singleton 71 | @Provides 72 | fun providesMoviesDatabase(@ApplicationContext context: Context): MoviesDatabase = 73 | MoviesDatabase.buildDatabase(context) 74 | } -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.ui 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.activity.viewModels 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.lifecycle.Observer 8 | import androidx.recyclerview.widget.GridLayoutManager 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.spiraldev.mvvmpaging.R 11 | import com.spiraldev.mvvmpaging.adapters.MoviesPagedListAdapter 12 | import com.spiraldev.mvvmpaging.data.remote.NetworkState 13 | import dagger.hilt.android.AndroidEntryPoint 14 | import kotlinx.android.synthetic.main.activity_main.* 15 | 16 | @AndroidEntryPoint 17 | class MainActivity : AppCompatActivity() { 18 | 19 | val viewModel: MainActivityViewModel by viewModels() 20 | 21 | lateinit var moviesAdapter: MoviesPagedListAdapter 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | super.onCreate(savedInstanceState) 25 | setContentView(R.layout.activity_main) 26 | initRecycler() 27 | initObserver() 28 | } 29 | 30 | private fun initRecycler() { 31 | moviesAdapter = MoviesPagedListAdapter { 32 | viewModel.retry() 33 | } 34 | 35 | val gridLayoutManager = GridLayoutManager(this, 4, RecyclerView.VERTICAL, false) 36 | 37 | gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { 38 | override fun getSpanSize(position: Int): Int { 39 | val viewType = moviesAdapter.getItemViewType(position) 40 | if (viewType == R.layout.movie_list_item) return 1 41 | else return 4 42 | } 43 | } 44 | 45 | movies_recycler.adapter = moviesAdapter 46 | movies_recycler.layoutManager = gridLayoutManager 47 | movies_recycler.setHasFixedSize(true) 48 | } 49 | 50 | private fun initObserver() { 51 | viewModel.moviePagedList.observe(this, Observer { 52 | moviesAdapter.submitList(it) 53 | }) 54 | 55 | viewModel.getNetworkState() 56 | .observe(this, Observer { 57 | progress_bar_main.visibility = 58 | if (viewModel.listIsEmpty() && it == NetworkState.LOADING) View.VISIBLE else View.GONE 59 | 60 | txt_error_main.visibility = 61 | if (viewModel.listIsEmpty() && it.message != null) View.VISIBLE else View.GONE 62 | 63 | moviesAdapter.setNetworkState(it) 64 | }) 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/java/com/spiraldev/mvvmpaging/ui/MainActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.spiraldev.mvvmpaging.ui 2 | 3 | import androidx.hilt.lifecycle.ViewModelInject 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.paging.LivePagedListBuilder 7 | import androidx.paging.PagedList 8 | import com.spiraldev.mvvmpaging.data.local.MovieEntity 9 | import com.spiraldev.mvvmpaging.data.local.MoviesDatabase 10 | import com.spiraldev.mvvmpaging.data.remote.ApiService 11 | import com.spiraldev.mvvmpaging.data.remote.NetworkState 12 | import com.spiraldev.mvvmpaging.data.remote.POST_PER_PAGE 13 | import com.spiraldev.mvvmpaging.data.remote.datasources.PagedListMovieBoundaryCallback 14 | import io.reactivex.disposables.CompositeDisposable 15 | import javax.inject.Inject 16 | 17 | class MainActivityViewModel @ViewModelInject constructor( 18 | private val apiService: ApiService, 19 | private val moviesDb: MoviesDatabase 20 | ) : ViewModel() { 21 | 22 | private val compositeDisposable = CompositeDisposable() 23 | var moviePagedList: LiveData> 24 | 25 | private val boundaryCallback: PagedListMovieBoundaryCallback = 26 | PagedListMovieBoundaryCallback( 27 | apiService, 28 | moviesDb, 29 | compositeDisposable 30 | ) 31 | 32 | init { 33 | val config = PagedList.Config.Builder() 34 | .setPageSize(POST_PER_PAGE) 35 | .setEnablePlaceholders(false) 36 | .build() 37 | 38 | moviePagedList = 39 | LivePagedListBuilder(moviesDb.moviesDao().allMovies(), config) 40 | .setBoundaryCallback(boundaryCallback) 41 | .build() 42 | } 43 | 44 | fun listIsEmpty(): Boolean { 45 | return moviePagedList.value?.isEmpty() ?: true 46 | } 47 | 48 | fun retry() { 49 | boundaryCallback.retry() 50 | } 51 | 52 | fun getNetworkState(): LiveData { 53 | return boundaryCallback.networkState 54 | } 55 | 56 | override fun onCleared() { 57 | super.onCleared() 58 | compositeDisposable.dispose() 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/poster_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpiralDevelopment/MVVMPaging/e1c9aa9586c7635ae1086a53f22e382336f3a6f0/app/src/main/res/drawable/poster_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 22 | 23 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/res/layout/movie_list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 19 | 20 | 27 | 28 | 29 | 30 | 40 | 41 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/src/main/res/layout/network_state_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 21 | 22 |