├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── 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
│ │ │ │ ├── integers.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── styles.xml
│ │ │ │ ├── attrs.xml
│ │ │ │ └── strings.xml
│ │ │ ├── values-v21
│ │ │ │ └── styles.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── ic_warning.xml
│ │ │ │ ├── ic_star.xml
│ │ │ │ ├── ic_date_range_black_24dp.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── layout
│ │ │ │ ├── item_loading.xml
│ │ │ │ ├── item_popular.xml
│ │ │ │ ├── activity_popular.xml
│ │ │ │ └── activity_detail.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── kotlin
│ │ │ └── com
│ │ │ │ └── efemoney
│ │ │ │ └── maggg
│ │ │ │ ├── Navigator.kt
│ │ │ │ ├── ui
│ │ │ │ ├── detail
│ │ │ │ │ ├── DetailModule.kt
│ │ │ │ │ ├── DetailInjector.kt
│ │ │ │ │ ├── DetailViewModel.kt
│ │ │ │ │ └── DetailActivity.kt
│ │ │ │ ├── popular
│ │ │ │ │ ├── PopularModule.kt
│ │ │ │ │ ├── PopularInjector.kt
│ │ │ │ │ ├── PopularViewModel.kt
│ │ │ │ │ └── PopularActivity.kt
│ │ │ │ ├── base
│ │ │ │ │ └── BaseActivity.kt
│ │ │ │ └── widget
│ │ │ │ │ └── AspectRatioImageView.kt
│ │ │ │ ├── data
│ │ │ │ ├── model
│ │ │ │ │ ├── Genre.kt
│ │ │ │ │ ├── ProductionCompany.kt
│ │ │ │ │ ├── SpokenLanguage.kt
│ │ │ │ │ ├── ProductionCountry.kt
│ │ │ │ │ ├── Paged.kt
│ │ │ │ │ ├── BelongsToCollection.kt
│ │ │ │ │ ├── MovieOverview.kt
│ │ │ │ │ ├── Movie.kt
│ │ │ │ │ └── TmdbImagePath.kt
│ │ │ │ ├── remote
│ │ │ │ │ ├── Config.kt
│ │ │ │ │ └── TmdbApi.kt
│ │ │ │ ├── Repository.kt
│ │ │ │ └── TmdbRepository.kt
│ │ │ │ ├── inject
│ │ │ │ ├── qualifier
│ │ │ │ │ ├── ApiKey.kt
│ │ │ │ │ └── ImageConfigPref.kt
│ │ │ │ ├── ViewModelClassKey.kt
│ │ │ │ ├── module
│ │ │ │ │ ├── RepositoryModule.kt
│ │ │ │ │ ├── ViewModelFactoryModule.kt
│ │ │ │ │ ├── RxSchedulerModule.kt
│ │ │ │ │ ├── GlideConfigModule.kt
│ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ └── ApiModule.kt
│ │ │ │ ├── DaggerViewModelFactory.kt
│ │ │ │ └── component
│ │ │ │ │ └── AppComponent.kt
│ │ │ │ ├── rx
│ │ │ │ ├── RxSchedulers.kt
│ │ │ │ └── ProductionRxSchedulers.kt
│ │ │ │ ├── MagggApp.kt
│ │ │ │ ├── interceptor
│ │ │ │ └── AuthInterceptor.kt
│ │ │ │ ├── ext
│ │ │ │ ├── livedata.kt
│ │ │ │ ├── glide.kt
│ │ │ │ ├── app.kt
│ │ │ │ └── view.kt
│ │ │ │ ├── glide
│ │ │ │ ├── TmdbImageModelLoader.kt
│ │ │ │ ├── MagggAppGlideModule.kt
│ │ │ │ └── TmdbImageUrlConfig.kt
│ │ │ │ └── gson
│ │ │ │ └── TmdbImagePathAdapterFactory.kt
│ │ └── AndroidManifest.xml
│ └── test
│ │ └── kotlin
│ │ └── com
│ │ └── efemoney
│ │ └── maggg
│ │ └── ui
│ │ ├── rx
│ │ └── TestRxSchedulers.kt
│ │ ├── detail
│ │ └── DetailViewModelTest.kt
│ │ └── popular
│ │ └── PopularViewModelTest.kt
├── proguard-rules.pro
└── build.gradle
├── settings.gradle
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── gradle.properties
├── README.md
├── gradlew.bat
├── deps.gradle
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/Navigator.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg
2 |
3 | interface Navigator {
4 | fun showDetails(id: Int)
5 | }
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/efemoney/maggg/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values/integers.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 2
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/workspace.xml
5 | /.idea/libraries
6 | .DS_Store
7 | /build
8 | /captures
9 | .externalNativeBuild
10 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/detail/DetailModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.detail
2 |
3 | import dagger.Module
4 |
5 | @Module
6 | internal abstract class DetailModule {
7 |
8 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/popular/PopularModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.popular
2 |
3 | import dagger.Module
4 |
5 | @Module
6 | internal abstract class PopularModule {
7 |
8 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/Genre.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class Genre(@Json("id") val id: Int,
6 | @Json("name") val name: String
7 | )
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #3F51B5
4 | #303F9F
5 | #FF4081
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/qualifier/ApiKey.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.qualifier
2 |
3 | import javax.inject.Qualifier
4 |
5 | @MustBeDocumented
6 | @Retention(AnnotationRetention.RUNTIME)
7 | @Qualifier
8 | annotation class ApiKey
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/rx/RxSchedulers.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.rx
2 |
3 | import io.reactivex.Scheduler
4 |
5 | interface RxSchedulers {
6 | val computation: Scheduler
7 | val network: Scheduler
8 | val main: Scheduler
9 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-v21/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/qualifier/ImageConfigPref.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.qualifier
2 |
3 | import javax.inject.Qualifier
4 |
5 | @MustBeDocumented
6 | @Retention(AnnotationRetention.RUNTIME)
7 | @Qualifier
8 | annotation class ImageConfigPref
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/ProductionCompany.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class ProductionCompany(@Json("id") val id: Int,
6 | @Json("name") val name: String
7 | )
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/SpokenLanguage.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class SpokenLanguage(@Json("iso_639_1") val iso6391: String,
6 | @Json("name") val name: String
7 | )
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Dec 07 16:39:08 WAT 2017
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip
7 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/ProductionCountry.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class ProductionCountry(@Json("name") val name: String,
6 | @Json("iso_3166_1") val iso31661: String
7 | )
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/Paged.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class Paged(
6 | @Json("results") val results: List- ,
7 | @Json("page") val page: Int,
8 | @Json("total_pages") val totalPages: Int,
9 | @Json("total_results") val totalResults: Int
10 | )
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/ViewModelClassKey.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject
2 |
3 | import android.arch.lifecycle.ViewModel
4 | import dagger.MapKey
5 | import kotlin.reflect.KClass
6 |
7 | @MustBeDocumented
8 | @Target(AnnotationTarget.FUNCTION)
9 | @Retention(AnnotationRetention.RUNTIME)
10 | @MapKey
11 | annotation class ViewModelClassKey(val value: KClass)
12 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/efemoney/maggg/ui/rx/TestRxSchedulers.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.rx
2 |
3 | import com.efemoney.maggg.rx.RxSchedulers
4 | import io.reactivex.schedulers.Schedulers
5 |
6 | class TestRxSchedulers: RxSchedulers {
7 | override val computation = Schedulers.trampoline()
8 | override val network = Schedulers.trampoline()
9 | override val main = Schedulers.trampoline()
10 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/BelongsToCollection.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class BelongsToCollection(@Json("id") val id: Int,
6 | @Json("name") val name: String,
7 | @Json("poster_path") val posterPath: String,
8 | @Json("backdrop_path") val backdropPath: String
9 | )
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/remote/Config.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.remote
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class Config(@Json("images") val imageConfig: ImageConfig)
6 |
7 | data class ImageConfig(
8 | @Json("base_url") val baseUrl: String,
9 | @Json("poster_sizes") val posterSizes: List,
10 | @Json("backdrop_sizes") val backdropSizes: List
11 | )
12 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/Repository.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data
2 |
3 | import com.efemoney.maggg.data.model.Movie
4 | import com.efemoney.maggg.data.model.MovieOverview
5 | import com.efemoney.maggg.data.model.Paged
6 | import io.reactivex.Observable
7 |
8 | interface Repository {
9 |
10 | fun popularMovies(page: Int): Observable>
11 |
12 | fun movieDetails(id: Int): Observable
13 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/module/RepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.module
2 |
3 | import com.efemoney.maggg.data.Repository
4 | import com.efemoney.maggg.data.TmdbRepository
5 | import dagger.Binds
6 | import dagger.Module
7 | import javax.inject.Singleton
8 |
9 | @Module
10 | abstract class RepositoryModule {
11 |
12 | @Binds
13 | @Singleton
14 | abstract fun repository(value: TmdbRepository): Repository
15 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/module/ViewModelFactoryModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.module
2 |
3 | import android.arch.lifecycle.ViewModelProvider
4 | import com.efemoney.maggg.inject.DaggerViewModelFactory
5 | import dagger.Binds
6 | import dagger.Module
7 |
8 | @Module
9 | internal abstract class ViewModelFactoryModule {
10 |
11 | @Binds
12 | internal abstract fun factory(factory: DaggerViewModelFactory): ViewModelProvider.Factory
13 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_warning.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/module/RxSchedulerModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.module
2 |
3 | import com.efemoney.maggg.rx.ProductionRxSchedulers
4 | import com.efemoney.maggg.rx.RxSchedulers
5 | import dagger.Binds
6 | import dagger.Module
7 | import javax.inject.Singleton
8 |
9 | @Module
10 | abstract class RxSchedulerModule {
11 |
12 | @Binds
13 | @Singleton
14 | abstract fun rxSchedulers(value: ProductionRxSchedulers): RxSchedulers
15 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_star.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/rx/ProductionRxSchedulers.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.rx
2 |
3 | import io.reactivex.Scheduler
4 | import io.reactivex.android.schedulers.AndroidSchedulers
5 | import io.reactivex.schedulers.Schedulers
6 | import javax.inject.Inject
7 |
8 | class ProductionRxSchedulers @Inject constructor() : RxSchedulers {
9 | override val computation: Scheduler = Schedulers.computation()
10 | override val network: Scheduler = Schedulers.io()
11 | override val main: Scheduler = AndroidSchedulers.mainThread()
12 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_loading.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_date_range_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/TmdbRepository.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data
2 |
3 | import com.efemoney.maggg.data.model.Movie
4 | import com.efemoney.maggg.data.model.MovieOverview
5 | import com.efemoney.maggg.data.model.Paged
6 | import com.efemoney.maggg.data.remote.TmdbApi
7 | import io.reactivex.Observable
8 | import javax.inject.Inject
9 |
10 | class TmdbRepository
11 | @Inject constructor(private val api: TmdbApi): Repository {
12 |
13 | override fun popularMovies(page: Int): Observable> = api.popularMovies(page).toObservable()
14 |
15 | override fun movieDetails(id: Int): Observable = api.movieDetails(id).toObservable()
16 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/MagggApp.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg
2 |
3 | import com.efemoney.maggg.inject.component.AppComponent
4 | import com.efemoney.maggg.inject.component.DaggerAppComponent
5 | import dagger.android.support.DaggerApplication
6 | import timber.log.Timber
7 | import javax.inject.Inject
8 |
9 | class MagggApp : DaggerApplication() {
10 |
11 | // Utilized by Glide
12 | @Inject lateinit var component: AppComponent
13 |
14 | override fun onCreate() {
15 | super.onCreate()
16 |
17 | if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
18 | }
19 |
20 | override fun applicationInjector() = DaggerAppComponent.builder().create(this)
21 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/detail/DetailInjector.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.detail
2 |
3 | import android.arch.lifecycle.ViewModel
4 | import com.efemoney.maggg.inject.ViewModelClassKey
5 | import dagger.Binds
6 | import dagger.Module
7 | import dagger.android.ContributesAndroidInjector
8 | import dagger.multibindings.IntoMap
9 |
10 | @Module
11 | internal abstract class DetailInjector {
12 |
13 | @ContributesAndroidInjector(modules = [DetailModule::class])
14 | abstract fun injector(): DetailActivity
15 |
16 | @Binds
17 | @IntoMap
18 | @ViewModelClassKey(DetailViewModel::class)
19 | internal abstract fun viewModel(viewModel: DetailViewModel): ViewModel
20 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/popular/PopularInjector.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.popular
2 |
3 | import android.arch.lifecycle.ViewModel
4 | import com.efemoney.maggg.inject.ViewModelClassKey
5 | import dagger.Binds
6 | import dagger.Module
7 | import dagger.android.ContributesAndroidInjector
8 | import dagger.multibindings.IntoMap
9 |
10 | @Module
11 | internal abstract class PopularInjector {
12 |
13 | @ContributesAndroidInjector(modules = [PopularModule::class])
14 | internal abstract fun injector(): PopularActivity
15 |
16 | @Binds
17 | @IntoMap
18 | @ViewModelClassKey(PopularViewModel::class)
19 | internal abstract fun viewModel(viewModel: PopularViewModel): ViewModel
20 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/remote/TmdbApi.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.remote
2 |
3 | import com.efemoney.maggg.data.model.Movie
4 | import com.efemoney.maggg.data.model.MovieOverview
5 | import com.efemoney.maggg.data.model.Paged
6 | import io.reactivex.Single
7 | import retrofit2.http.GET
8 | import retrofit2.http.Path
9 | import retrofit2.http.Query
10 |
11 | interface TmdbApi {
12 |
13 | @GET("movie/popular")
14 | fun popularMovies(@Query("page") page: Int): Single>
15 |
16 | @GET("movie/{id}")
17 | fun movieDetails(@Path("id") id: Int): Single
18 |
19 | @GET("configuration")
20 | fun config(): Single
21 |
22 | companion object { const val URL = "https://api.themoviedb.org/3/" }
23 | }
24 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | org.gradle.jvmargs=-Xmx1536m
13 |
14 | # When configured, Gradle will run in incubating parallel mode.
15 | # This option should only be used with decoupled projects. More details, visit
16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
17 | # org.gradle.parallel=true
18 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/interceptor/AuthInterceptor.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.interceptor;
2 |
3 | import com.efemoney.maggg.inject.qualifier.ApiKey
4 | import okhttp3.Interceptor
5 | import okhttp3.Response
6 | import javax.inject.Inject
7 |
8 | class AuthInterceptor
9 | @Inject constructor(@ApiKey val apiKey: String)
10 | : Interceptor {
11 |
12 | override fun intercept(chain: Interceptor.Chain): Response? {
13 |
14 | val req = chain.request()
15 |
16 | val url = req.url()
17 | .newBuilder()
18 | .setQueryParameter("api_key", apiKey)
19 | .build()
20 |
21 | val newReq = req
22 | .newBuilder()
23 | .url(url)
24 | .build()
25 |
26 | return chain.proceed(newReq)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_popular.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/base/BaseActivity.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.base
2 |
3 | import android.annotation.SuppressLint
4 | import android.view.MenuItem
5 | import com.efemoney.maggg.MagggApp
6 | import dagger.android.support.DaggerAppCompatActivity
7 | import io.reactivex.disposables.CompositeDisposable
8 |
9 | @SuppressLint("Registered")
10 | open class BaseActivity : DaggerAppCompatActivity() {
11 |
12 | protected val disposables = CompositeDisposable()
13 |
14 | internal val app
15 | get() = application as MagggApp
16 |
17 | override fun onOptionsItemSelected(item: MenuItem): Boolean {
18 |
19 | if (item.itemId == android.R.id.home) {
20 | onBackPressed();
21 | return true;
22 | }
23 |
24 | return super.onOptionsItemSelected(item)
25 | }
26 |
27 | override fun onDestroy() {
28 | super.onDestroy()
29 | disposables.clear()
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ext/livedata.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ext
2 |
3 | import android.arch.lifecycle.LifecycleOwner
4 | import android.arch.lifecycle.LiveData
5 | import android.arch.lifecycle.LiveDataReactiveStreams
6 | import android.arch.lifecycle.Observer
7 | import io.reactivex.BackpressureStrategy
8 | import io.reactivex.Observable
9 | import io.reactivex.Single
10 | import org.reactivestreams.Publisher
11 |
12 | inline fun LiveData.observe(
13 | owner: LifecycleOwner,
14 | crossinline observer: (T?) -> Unit
15 | ) = this.observe(owner, Observer { observer(it) })
16 |
17 | fun LiveData.toPublisher(lo: LifecycleOwner) = LiveDataReactiveStreams.toPublisher(lo, this)
18 |
19 | fun Publisher.toLiveData() = LiveDataReactiveStreams.fromPublisher(this)
20 |
21 | fun Observable.toLiveData() = toFlowable(BackpressureStrategy.MISSING).toLiveData()
22 |
23 | fun Single.toLiveData() = toFlowable().toLiveData()
--------------------------------------------------------------------------------
/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
22 |
23 |
24 | ### Glide
25 | -keep public class * extends com.bumptech.glide.module.AppGlideModule
26 | -keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/MovieOverview.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 | import com.efemoney.maggg.ui.popular.PopularActivity
5 |
6 | data class MovieOverview(
7 | @Json("id") val id: Int,
8 | @Json("title") val title: String,
9 | @Json("overview") val overview: String?,
10 | @Json("popularity") val popularity: Double,
11 | @Json("release_date") val releaseDate: String,
12 | @Json("vote_count") val voteCount: Int,
13 | @Json("vote_average") val voteAverage: Double,
14 | @Json("original_title") val originalTitle: String,
15 | @Json("original_language") val originalLanguage: String,
16 | @Json("poster_path") val posterPath: PosterPath?,
17 | @Json("backdrop_path") val backdropPath: BackdropPath?,
18 | @Json("genre_ids") val genreIds: List,
19 | @Json("adult") val adult: Boolean,
20 | @Json("video") val video: Boolean
21 |
22 | ): PopularActivity.PopularItem
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/module/GlideConfigModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.module
2 |
3 | import com.efemoney.maggg.BuildConfig
4 | import dagger.Module
5 | import dagger.Provides
6 | import okhttp3.OkHttpClient
7 | import okhttp3.logging.HttpLoggingInterceptor
8 | import okhttp3.logging.HttpLoggingInterceptor.Level.BASIC
9 | import okhttp3.logging.HttpLoggingInterceptor.Level.NONE
10 | import javax.inject.Named
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | class GlideConfigModule {
15 |
16 | @Provides
17 | @Singleton
18 | @Named("forGlide")
19 | fun loggingInterceptor(): HttpLoggingInterceptor {
20 |
21 | return HttpLoggingInterceptor()
22 | .setLevel(if (BuildConfig.DEBUG) BASIC else NONE)
23 | }
24 |
25 | @Provides
26 | @Singleton
27 | @Named("forGlide")
28 | fun okHttpClient(@Named("forGlide") logger: HttpLoggingInterceptor): OkHttpClient {
29 |
30 | return OkHttpClient.Builder()
31 | .addInterceptor(logger)
32 | .build()
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/module/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.module
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import com.efemoney.maggg.MagggApp
6 | import com.efemoney.maggg.Navigator
7 | import com.efemoney.maggg.inject.qualifier.ImageConfigPref
8 | import com.efemoney.maggg.ui.detail.DetailActivity
9 | import dagger.Module
10 | import dagger.Provides
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | class AppModule {
15 |
16 | @Provides
17 | @Singleton
18 | fun context(app: MagggApp): Context = app
19 |
20 | @Provides
21 | @Singleton
22 | @ImageConfigPref
23 | fun pref(context: Context): SharedPreferences = context.getSharedPreferences("tmdb_config", Context.MODE_PRIVATE)
24 |
25 | @Provides
26 | @Singleton
27 | fun navigator(context: Context): Navigator {
28 |
29 | return object : Navigator {
30 | override fun showDetails(id: Int) {
31 | context.startActivity(DetailActivity.createIntent(context, id))
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/DaggerViewModelFactory.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject
2 |
3 | import android.arch.lifecycle.ViewModel
4 | import android.arch.lifecycle.ViewModelProvider
5 | import com.efemoney.maggg.ext.NoWildcards
6 | import javax.inject.Inject
7 | import javax.inject.Provider
8 | import javax.inject.Singleton
9 |
10 | @Singleton
11 | class DaggerViewModelFactory
12 | @Inject constructor(private val creators: @NoWildcards Map, Provider>)
13 | : ViewModelProvider.Factory {
14 |
15 | override fun create(modelClass: Class): T {
16 |
17 | var creator: Provider? = creators[modelClass]
18 |
19 | if (creator == null) for ((key, value) in creators) if (modelClass.isAssignableFrom(key)) {
20 | creator = value
21 | break
22 | }
23 |
24 | if (creator == null) throw IllegalArgumentException("unknown model class " + modelClass)
25 |
26 | try {
27 | @Suppress("UNCHECKED_CAST")
28 | return creator.get() as T
29 | } catch (e: Exception) {
30 | throw RuntimeException(e)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Maggg
3 |
4 | image_grid_to_detail
5 |
6 | A movie poster
7 | A movie backdrop
8 |
9 | An error occurred
10 |
11 | Heu, mineralis! You have to travel, and follow freedom by your experimenting.
12 | \n\nAs i have viewed you, so you must facilitate one another. Try smashing cracker crumps mousse rubed with adobo sauce.
13 | \n\nAww, fortune! The vision is a sub-light pathway.
14 | \n\nHeu, mineralis! You have to travel, and follow freedom by your experimenting.
15 | \n\nNuclear flux, turbulence, and peace.
16 | \n\nAww, fortune! The vision is a sub-light pathway.
17 | \n\nNuclear flux, turbulence, and peace.
18 | \n\nAs i have viewed you, so you must facilitate one another. Try smashing cracker crumps mousse rubed with adobo sauce."
19 |
20 |
21 | %1$s (%2$s)
22 | “ %1$s “
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/component/AppComponent.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.component
2 |
3 | import com.efemoney.maggg.MagggApp
4 | import com.efemoney.maggg.glide.TmdbImageUrlConfig
5 | import com.efemoney.maggg.inject.module.*
6 | import com.efemoney.maggg.ui.detail.DetailInjector
7 | import com.efemoney.maggg.ui.popular.PopularInjector
8 | import dagger.Component
9 | import dagger.android.AndroidInjector
10 | import dagger.android.support.AndroidSupportInjectionModule
11 | import okhttp3.OkHttpClient
12 | import javax.inject.Named
13 | import javax.inject.Singleton
14 |
15 | @Singleton
16 | @Component(modules = [
17 | AndroidSupportInjectionModule::class,
18 | AppModule::class,
19 | ApiModule::class,
20 | GlideConfigModule::class,
21 | RepositoryModule::class,
22 | RxSchedulerModule::class,
23 | ViewModelFactoryModule::class,
24 |
25 | PopularInjector::class,
26 | DetailInjector::class
27 | ])
28 | interface AppComponent : AndroidInjector {
29 |
30 | @Named("forGlide")
31 | fun glideClient(): OkHttpClient
32 |
33 | fun imageUrlConfig(): TmdbImageUrlConfig
34 |
35 | @Component.Builder
36 | abstract class Builder : AndroidInjector.Builder() {
37 |
38 | abstract override fun build(): AppComponent
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/glide/TmdbImageModelLoader.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.glide
2 |
3 | import com.bumptech.glide.load.Options
4 | import com.bumptech.glide.load.model.GlideUrl
5 | import com.bumptech.glide.load.model.ModelLoader
6 | import com.bumptech.glide.load.model.ModelLoaderFactory
7 | import com.bumptech.glide.load.model.MultiModelLoaderFactory
8 | import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader
9 | import com.efemoney.maggg.data.model.TmdbImagePath
10 | import com.efemoney.maggg.ext.build
11 | import java.io.InputStream
12 |
13 | class TmdbImageModelLoader(
14 | val config: TmdbImageUrlConfig,
15 | delegate: ModelLoader
16 | ) : BaseGlideUrlLoader(delegate) {
17 |
18 | override fun handles(model: TmdbImagePath): Boolean = true
19 |
20 | override fun getUrl(model: TmdbImagePath, w: Int, h: Int, opt: Options) = config.url(model, w)
21 |
22 | class Factory(val config: TmdbImageUrlConfig)
23 | : ModelLoaderFactory {
24 |
25 | override fun build(factory: MultiModelLoaderFactory): TmdbImageModelLoader {
26 |
27 | return TmdbImageModelLoader(config, delegate = factory.build())
28 | }
29 |
30 | override fun teardown() = Unit
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ext/glide.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ext
2 |
3 | import com.bumptech.glide.RequestBuilder
4 | import com.bumptech.glide.load.DataSource
5 | import com.bumptech.glide.load.engine.GlideException
6 | import com.bumptech.glide.load.model.MultiModelLoaderFactory
7 | import com.bumptech.glide.request.RequestListener
8 | import com.bumptech.glide.request.target.Target
9 |
10 | fun RequestBuilder.onResource(
11 | failed: () -> Unit = {},
12 | loaded: () -> Unit
13 | ): RequestBuilder {
14 |
15 | return listener(object : RequestListener {
16 |
17 | override fun onLoadFailed(e: GlideException?,
18 | model: Any?,
19 | target: Target?,
20 | isFirstResource: Boolean): Boolean {
21 | failed()
22 | return false
23 | }
24 |
25 | override fun onResourceReady(resource: T,
26 | model: Any?,
27 | target: Target?,
28 | dataSource: DataSource?,
29 | isFirstResource: Boolean): Boolean {
30 | loaded()
31 | return false
32 | }
33 | })
34 | }
35 |
36 | inline fun MultiModelLoaderFactory.build() = build(Model::class.java, Data::class.java)
37 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/gson/TmdbImagePathAdapterFactory.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.gson
2 |
3 | import com.efemoney.maggg.data.model.BackdropPath
4 | import com.efemoney.maggg.data.model.PosterPath
5 | import com.efemoney.maggg.data.model.TmdbImagePath
6 | import com.google.gson.Gson
7 | import com.google.gson.TypeAdapter
8 | import com.google.gson.TypeAdapterFactory
9 | import com.google.gson.reflect.TypeToken
10 | import com.google.gson.stream.JsonReader
11 | import com.google.gson.stream.JsonWriter
12 |
13 | @Suppress("UNCHECKED_CAST")
14 | object TmdbImagePathAdapterFactory : TypeAdapterFactory {
15 |
16 | override fun create(gson: Gson, type: TypeToken): TypeAdapter? {
17 |
18 | if (!TmdbImagePath::class.java.isAssignableFrom(type.rawType)) return null
19 |
20 | return (TmdbImagePathAdapter(type as TypeToken) as TypeAdapter).nullSafe()
21 | }
22 |
23 | class TmdbImagePathAdapter(val type: TypeToken) : TypeAdapter() {
24 | override fun write(writer: JsonWriter, value: TmdbImagePath) {
25 | writer.value(value.path) // write path to json
26 | }
27 | override fun read(reader: JsonReader): TmdbImagePath {
28 |
29 | val path = reader.nextString()
30 |
31 | return when {
32 | type.rawType == PosterPath::class.java -> PosterPath(path)
33 | type.rawType == BackdropPath::class.java -> BackdropPath(path)
34 | else -> throw IllegalArgumentException()
35 | }
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/Movie.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import com.efemoney.maggg.ext.Json
4 |
5 | data class Movie(
6 | @Json("id") val id: Int,
7 | @Json("imdb_id") val imdbId: String?,
8 | @Json("title") val title: String,
9 | @Json("tagline") val tagline: String?,
10 | @Json("overview") val overview: String?,
11 | @Json("homepage") val homepage: String?,
12 | @Json("popularity") val popularity: Double,
13 | @Json("release_date") val releaseDate: String,
14 |
15 | @Json("budget") val budget: Int,
16 | @Json("revenue") val revenue: Int,
17 |
18 | @Json("status") val status: String,
19 | @Json("runtime") val runtime: Int?,
20 | @Json("vote_count") val voteCount: Int,
21 | @Json("vote_average") val voteAverage: Double,
22 | @Json("belongs_to_collection") val belongsToCollection: BelongsToCollection?,
23 |
24 | @Json("poster_path") val posterPath: PosterPath?,
25 | @Json("backdrop_path") val backdropPath: BackdropPath?,
26 |
27 | @Json("original_title") val originalTitle: String,
28 | @Json("original_language") val originalLanguage: String,
29 | @Json("spoken_languages") val spokenLanguages: List,
30 | @Json("production_companies") val productionCompanies: List,
31 | @Json("production_countries") val productionCountries: List,
32 | @Json("genres") val genres: List,
33 | @Json("adult") val adult: Boolean,
34 | @Json("video") val video: Boolean
35 | )
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/data/model/TmdbImagePath.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.data.model
2 |
3 | import android.os.Parcel
4 | import android.os.Parcelable
5 |
6 | interface TmdbImagePath {
7 | val type: String
8 | val path: String
9 | }
10 |
11 | data class PosterPath(override val path: String) : TmdbImagePath, Parcelable {
12 | override val type = "poster"
13 |
14 | constructor(source: Parcel) : this(source.readString())
15 |
16 | override fun writeToParcel(dest: Parcel, flags: Int) = dest.writeString(path)
17 | override fun describeContents() = 0
18 |
19 | companion object {
20 | @JvmField
21 | val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
22 | override fun createFromParcel(source: Parcel): PosterPath = PosterPath(source)
23 | override fun newArray(size: Int): Array = arrayOfNulls(size)
24 | }
25 | }
26 | }
27 |
28 | data class BackdropPath(override val path: String) : TmdbImagePath, Parcelable {
29 | override val type = "backdrop"
30 |
31 | constructor(source: Parcel) : this(source.readString())
32 |
33 | override fun writeToParcel(dest: Parcel, flags: Int) = dest.writeString(path)
34 | override fun describeContents() = 0
35 |
36 | companion object {
37 | @JvmField
38 | val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
39 | override fun createFromParcel(source: Parcel): BackdropPath = BackdropPath(source)
40 | override fun newArray(size: Int): Array = arrayOfNulls(size)
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Maggg
2 | Tmdb (themoviedb.org) popular movies feed App. Built to showcase the Model-View-Intent
3 | architectural pattern.
4 |
5 | #### Building and running the project
6 | You are free to clone this repo. This project requires:
7 | * Android Studio 3.1 Canary 5
8 | * Android Gradle Plugin `3.1.0-alpha5`
9 | * Kotlin `1.2.0`
10 |
11 | This app (along with the unit tests) are written 100% in Kotlin 🎉
12 |
13 | This project makes use of the free popular movies API at [themoviedb.org](http://developers.themoviedb.org).
14 |
15 | You need to sign up for a free account and obtain an API key.
16 |
17 | Once obtained, copy your API key into a/the `local.properties` file in the root of your project like:
18 | ```
19 | maggg.tmdbApiKey=
20 | ```
21 | and build the project.
22 |
23 | #### Libraries Used
24 | * Design SupportLib
25 | * AppCompat
26 | * Retrofit
27 | * Gson
28 | * Glide
29 | * Architechture Components
30 | * Dagger (Dagger Android)
31 | * RxJava/RxAndroid/RxBinding
32 | * JUnit
33 | * Mockito (Mockito-Kotlin)
34 |
35 | #### Image Loading
36 | The tmdb api offers images in various size buckets and this app takes advantage of [Glide 4.4](http://bumptech.github.io/glide/)s
37 | excellent `ModelLoader` API to load the best sized image for the current device. See the classes in the
38 | [glide](https://github.com/efemoney/maggg/tree/master/app/src/main/kotlin/com/efemoney/maggg/glide) package for more information
39 |
40 | #### MVI Architecture
41 | This application uses the Mode-View-Intent (MVI) architecture. The MVI pattern models the entire system
42 | as a unidirectional flow of *immutable* **intents**, **states** and data in between. RxJava and other
43 | libraries are used to power this pattern.
44 | When running the app in `debug` mode you can view the sequence of user events and app state by filtering logs
45 | with the `StateLogs` log tag.
46 |
47 | #### Possible Improvements
48 | * Include Glide RecyclerView integration library and add preloading for a better UX
49 | * More animations & shared transitions
50 | * More tests!
51 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/glide/MagggAppGlideModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.glide
2 |
3 | import android.content.Context
4 | import android.graphics.drawable.Drawable
5 | import android.util.Log
6 | import com.bumptech.glide.Glide
7 | import com.bumptech.glide.GlideBuilder
8 | import com.bumptech.glide.Registry
9 | import com.bumptech.glide.annotation.Excludes
10 | import com.bumptech.glide.annotation.GlideModule
11 | import com.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule
12 | import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
13 | import com.bumptech.glide.load.engine.DiskCacheStrategy
14 | import com.bumptech.glide.load.model.GlideUrl
15 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade
16 | import com.bumptech.glide.module.AppGlideModule
17 | import com.bumptech.glide.request.RequestOptions
18 | import com.efemoney.maggg.BuildConfig
19 | import com.efemoney.maggg.MagggApp
20 | import com.efemoney.maggg.data.model.TmdbImagePath
21 | import java.io.InputStream
22 |
23 | @GlideModule
24 | @Excludes(OkHttpLibraryGlideModule::class) // Exclude the default Okhttp module
25 | class MagggAppGlideModule : AppGlideModule() {
26 |
27 | override fun isManifestParsingEnabled(): Boolean = false
28 |
29 | override fun applyOptions(context: Context, builder: GlideBuilder) {
30 |
31 | builder.setLogLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.ERROR) // log more in debug
32 | builder.setDefaultRequestOptions(RequestOptions()
33 | .diskCacheStrategy(DiskCacheStrategy.ALL) // cache all
34 | .centerCrop() // center crop, we are loading smaller images so this makes sense
35 | )
36 | builder.setDefaultTransitionOptions(Drawable::class.java, withCrossFade())
37 | }
38 |
39 | override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
40 |
41 | // https://github.com/bumptech/glide/issues/2002
42 | val component = (context.applicationContext as MagggApp).component
43 |
44 | // Register a custom okhttp loader that logs request urls in debug
45 | registry.replace(
46 | GlideUrl::class.java,
47 | InputStream::class.java,
48 | OkHttpUrlLoader.Factory(component.glideClient()))
49 |
50 | // Register a model loader for tmdb image paths
51 | registry.replace(
52 | TmdbImagePath::class.java,
53 | InputStream::class.java,
54 | TmdbImageModelLoader.Factory(component.imageUrlConfig()))
55 | }
56 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/inject/module/ApiModule.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.inject.module
2 |
3 | import com.efemoney.maggg.BuildConfig
4 | import com.efemoney.maggg.data.remote.TmdbApi
5 | import com.efemoney.maggg.gson.TmdbImagePathAdapterFactory
6 | import com.efemoney.maggg.inject.qualifier.ApiKey
7 | import com.efemoney.maggg.interceptor.AuthInterceptor
8 | import com.google.gson.Gson
9 | import com.google.gson.GsonBuilder
10 | import dagger.Module
11 | import dagger.Provides
12 | import okhttp3.OkHttpClient
13 | import okhttp3.logging.HttpLoggingInterceptor
14 | import okhttp3.logging.HttpLoggingInterceptor.Level.BODY
15 | import okhttp3.logging.HttpLoggingInterceptor.Level.NONE
16 | import retrofit2.Retrofit
17 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
18 | import retrofit2.converter.gson.GsonConverterFactory
19 | import java.util.concurrent.TimeUnit
20 | import javax.inject.Singleton
21 |
22 | @Module
23 | class ApiModule {
24 |
25 | @Provides
26 | @Singleton
27 | fun gson(): Gson {
28 |
29 | return GsonBuilder()
30 | .setDateFormat("yyyy-MM-dd")
31 | .registerTypeAdapterFactory(TmdbImagePathAdapterFactory)
32 | .create()
33 | }
34 |
35 | @Provides
36 | @ApiKey
37 | fun apiKey() = BuildConfig.TMDB_API_KEY
38 |
39 | @Provides
40 | @Singleton
41 | fun loggingInterceptor(): HttpLoggingInterceptor {
42 |
43 | return HttpLoggingInterceptor()
44 | .setLevel(if (BuildConfig.DEBUG) BODY else NONE)
45 | }
46 |
47 | @Provides
48 | @Singleton
49 | fun okHttpClient(authInterceptor: AuthInterceptor, logger: HttpLoggingInterceptor): OkHttpClient {
50 |
51 | return OkHttpClient.Builder()
52 | .connectTimeout(60, TimeUnit.SECONDS)
53 | .writeTimeout(60, TimeUnit.SECONDS)
54 | .readTimeout(60, TimeUnit.SECONDS)
55 | .addInterceptor(authInterceptor)
56 | .addInterceptor(logger)
57 | .build()
58 | }
59 |
60 | @Provides
61 | @Singleton
62 | fun retrofit(client: OkHttpClient, gson: Gson): Retrofit {
63 |
64 | return Retrofit.Builder()
65 | .client(client)
66 | .baseUrl(TmdbApi.URL)
67 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
68 | .addConverterFactory(GsonConverterFactory.create(gson))
69 | .build()
70 | }
71 |
72 | @Provides
73 | @Singleton
74 | fun api(retrofit: Retrofit): TmdbApi = retrofit.create(TmdbApi::class.java)
75 | }
76 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
12 | set DEFAULT_JVM_OPTS=
13 |
14 | set DIRNAME=%~dp0
15 | if "%DIRNAME%" == "" set DIRNAME=.
16 | set APP_BASE_NAME=%~n0
17 | set APP_HOME=%DIRNAME%
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windowz variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 | if "%@eval[2+2]" == "4" goto 4NT_args
53 |
54 | :win9xME_args
55 | @rem Slurp the command line arguments.
56 | set CMD_LINE_ARGS=
57 | set _SKIP=2
58 |
59 | :win9xME_args_slurp
60 | if "x%~1" == "x" goto execute
61 |
62 | set CMD_LINE_ARGS=%*
63 | goto execute
64 |
65 | :4NT_args
66 | @rem Get arguments from the 4NT Shell from JP Software
67 | set CMD_LINE_ARGS=%$
68 |
69 | :execute
70 | @rem Setup the command line
71 |
72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if "%ERRORLEVEL%"=="0" goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
85 | exit /b 1
86 |
87 | :mainEnd
88 | if "%OS%"=="Windows_NT" endlocal
89 |
90 | :omega
91 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ext/app.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ext
2 |
3 | import android.arch.lifecycle.ViewModel
4 | import android.arch.lifecycle.ViewModelProvider
5 | import android.arch.lifecycle.ViewModelProviders
6 | import android.arch.persistence.room.ColumnInfo
7 | import android.content.Context
8 | import android.os.Build
9 | import android.support.annotation.ColorRes
10 | import android.support.annotation.DrawableRes
11 | import android.support.annotation.StringRes
12 | import android.support.v4.app.Fragment
13 | import android.support.v4.app.FragmentActivity
14 | import android.support.v4.content.ContextCompat
15 | import android.support.v7.content.res.AppCompatResources
16 | import android.widget.Toast
17 | import com.google.gson.annotations.SerializedName
18 | import io.reactivex.disposables.CompositeDisposable
19 | import io.reactivex.disposables.Disposable
20 |
21 | typealias Room = ColumnInfo
22 | typealias NoWildcards = JvmSuppressWildcards
23 | typealias Json = SerializedName
24 |
25 | inline fun FragmentActivity.viewModelGet(): T
26 | = ViewModelProviders.of(this).get(T::class.java)
27 |
28 | inline fun FragmentActivity.viewModelGetUsing(factory: ViewModelProvider.Factory): T
29 | = ViewModelProviders.of(this, factory).get(T::class.java)
30 |
31 | inline fun Fragment.viewModelGet(): T
32 | = ViewModelProviders.of(this).get(T::class.java)
33 |
34 | inline fun Fragment.viewModelGetUsing(factory: ViewModelProvider.Factory): T
35 | = ViewModelProviders.of(this, factory).get(T::class.java)
36 |
37 | operator fun CompositeDisposable.plusAssign(d: Disposable) { add(d) }
38 |
39 | fun isAtLeastApi(level: Int): Boolean = Build.VERSION.SDK_INT >= level
40 |
41 |
42 | fun Context.dpToPx(dp: Int) = (dpToPxFloat(dp) + 0.5f).toInt()
43 |
44 | fun Context.dpToPxFloat(dp: Int) = dp * resources.displayMetrics.density
45 |
46 | fun Context.spToPx(sp: Int) = (spToPxFloat(sp) + 0.5f).toInt()
47 |
48 | fun Context.spToPxFloat(sp: Int) = sp * resources.displayMetrics.scaledDensity
49 |
50 |
51 | fun Context.string(@StringRes id: Int): String = getString(id)
52 |
53 | fun Context.string(@StringRes id: Int, vararg args: Any): String = getString(id, args)
54 |
55 | fun Fragment.string(@StringRes id: Int): String = getString(id)
56 |
57 | fun Context.color(@ColorRes id: Int) = ContextCompat.getColor(this, id)
58 |
59 | fun Fragment.color(@ColorRes id: Int) = context?.color(id)
60 |
61 | fun Context.drawable(@DrawableRes id: Int) = AppCompatResources.getDrawable(this, id)
62 |
63 | fun Fragment.drawable(@DrawableRes id: Int) = context?.drawable(id)
64 |
65 |
66 | fun Context.toast(msg: CharSequence?, duration: Int) = Toast.makeText(this, msg, duration).show()
67 |
68 | fun toastShort(context: Context, msg: CharSequence?) = toastWith(context, msg, Toast.LENGTH_LONG)
69 |
70 | fun toastLong(context: Context, msg: CharSequence?) = toastWith(context, msg, Toast.LENGTH_SHORT)
71 |
72 | fun toastWith(context: Context, msg: CharSequence?, duration: Int) = context.toast(msg, duration)
73 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 | apply plugin: 'kotlin-android'
3 | apply plugin: 'kotlin-kapt'
4 |
5 | def props = new Properties()
6 | props.load new FileInputStream(rootProject.file('local.properties'))
7 |
8 | android {
9 |
10 | compileSdkVersion 27
11 | buildToolsVersion "27.0.2"
12 |
13 | defaultConfig {
14 | applicationId "com.efemoney.maggg"
15 |
16 | minSdkVersion 17
17 | targetSdkVersion 27
18 |
19 | versionCode 1
20 | versionName "1.0"
21 |
22 | vectorDrawables.useSupportLibrary = true
23 |
24 | buildConfigField 'String', 'TMDB_API_KEY', "\"${props['maggg.tmdbApiKey']}\""
25 |
26 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
27 | }
28 |
29 | dataBinding.enabled = true
30 |
31 | sourceSets {
32 | main.java.srcDirs += 'src/main/kotlin'
33 | test.java.srcDirs += 'src/test/kotlin'
34 | androidTest.java.srcDirs += 'src/androidTest/kotlin'
35 | }
36 |
37 | buildTypes {
38 |
39 | release {
40 | minifyEnabled false
41 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
42 | }
43 | }
44 | }
45 |
46 | dependencies {
47 |
48 | kapt deps.databinding.compiler
49 |
50 | implementation deps.kotlin.stdlib
51 |
52 | implementation deps.support.design
53 | implementation deps.support.palette
54 | implementation deps.support.appcompat
55 | implementation deps.support.preference
56 | implementation deps.support.constraint
57 |
58 | implementation deps.gson
59 |
60 | implementation deps.okhttp.main
61 | implementation deps.okhttp.logging
62 |
63 | implementation deps.retrofit.main
64 | implementation deps.retrofit.rx2adapter
65 | implementation deps.retrofit.gsonconverter
66 |
67 | implementation deps.rx.java
68 | implementation deps.rx.android
69 |
70 | implementation deps.rxrelay
71 |
72 | implementation deps.rxbindingkotlin.main
73 | implementation deps.rxbindingkotlin.appcompat
74 | implementation deps.rxbindingkotlin.recyclerview
75 |
76 | kapt deps.arch.lifecycle.compiler
77 | implementation deps.arch.lifecycle.extensions
78 | implementation deps.arch.lifecycle.reactivestreams
79 |
80 | kapt deps.arch.room.compiler
81 | implementation deps.arch.room.runtime
82 | implementation deps.arch.room.rxjava
83 |
84 | kapt deps.glide.compiler
85 | implementation deps.glide.main
86 | implementation deps.glide.okhttpintegration
87 |
88 | kapt deps.dagger.compiler
89 | kapt deps.dagger.androidprocessor
90 | implementation deps.dagger.main
91 | implementation deps.dagger.android
92 | implementation deps.dagger.androidsupport
93 | compileOnly deps.jsr250
94 |
95 | implementation deps.timber
96 |
97 | testImplementation deps.gson
98 | testImplementation deps.kotlin.stdlib
99 | testImplementation deps.kotlin.junittest
100 | testImplementation 'org.amshove.kluent:kluent:1.14'
101 | testImplementation 'com.nhaarman:mockito-kotlin:1.5.0'
102 | testImplementation 'android.arch.core:core-testing:1.0.0'
103 | }
104 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/widget/AspectRatioImageView.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.widget
2 |
3 | import android.content.Context
4 | import android.support.annotation.IntDef
5 | import android.support.v7.widget.AppCompatImageView
6 | import android.util.AttributeSet
7 | import com.efemoney.maggg.R
8 |
9 | class AspectRatioImageView
10 | @JvmOverloads constructor(context: Context,
11 | attrs: AttributeSet? = null,
12 | defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr) {
13 |
14 | // NOTE: These must be kept in sync with the AspectRatioImageView attributes in attrs.xml.
15 | @IntDef(MEASUREMENT_WIDTH.toLong(), MEASUREMENT_HEIGHT.toLong())
16 | @Retention(AnnotationRetention.SOURCE)
17 | annotation class Dominant
18 |
19 | var aspectRatio: Float = 0f
20 | set(value) {
21 | field = value
22 |
23 | if (aspectRatioEnabled) requestLayout()
24 | }
25 |
26 | var dominantMeasurement: Int = 0
27 | set(value) {
28 | if (value != MEASUREMENT_HEIGHT && value != MEASUREMENT_WIDTH)
29 | throw IllegalArgumentException("Invalid measurement type.")
30 |
31 | field = value
32 |
33 | if (aspectRatioEnabled) requestLayout()
34 | }
35 |
36 | var aspectRatioEnabled: Boolean = false
37 | set(value) {
38 | field = value
39 |
40 | requestLayout()
41 | }
42 |
43 | init {
44 |
45 | val a = context.obtainStyledAttributes(attrs, R.styleable.AspectRatioImageView)
46 |
47 | aspectRatio = a.getFloat(R.styleable.AspectRatioImageView_aspectRatio, DEFAULT_ASPECT_RATIO)
48 | dominantMeasurement = a.getInt(R.styleable.AspectRatioImageView_dominantMeasurement, DEFAULT_DOMINANT_MEASUREMENT)
49 | aspectRatioEnabled = a.getBoolean(R.styleable.AspectRatioImageView_aspectRatioEnabled, DEFAULT_ASPECT_RATIO_ENABLED)
50 |
51 | a.recycle()
52 | }
53 |
54 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
55 | super.onMeasure(widthMeasureSpec, heightMeasureSpec)
56 | if (!aspectRatioEnabled) return
57 |
58 | val newWidth: Int
59 | val newHeight: Int
60 |
61 | when (dominantMeasurement) {
62 |
63 | MEASUREMENT_WIDTH -> {
64 | newWidth = measuredWidth
65 | newHeight = (newWidth / aspectRatio).toInt()
66 | }
67 |
68 | MEASUREMENT_HEIGHT -> {
69 | newHeight = measuredHeight
70 | newWidth = (newHeight * aspectRatio).toInt()
71 | }
72 |
73 | else -> throw IllegalStateException("Unknown measurement with ID " + dominantMeasurement)
74 | }
75 |
76 | setMeasuredDimension(newWidth, newHeight)
77 | }
78 |
79 | companion object {
80 |
81 | const val MEASUREMENT_WIDTH = 0
82 | const val MEASUREMENT_HEIGHT = 1
83 |
84 | private val DEFAULT_ASPECT_RATIO = 1.67f
85 | private val DEFAULT_ASPECT_RATIO_ENABLED = false
86 | private val DEFAULT_DOMINANT_MEASUREMENT = MEASUREMENT_WIDTH.toInt()
87 | }
88 | }
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/efemoney/maggg/ui/detail/DetailViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.detail
2 |
3 | import android.arch.core.executor.testing.InstantTaskExecutorRule
4 | import android.arch.lifecycle.Observer
5 | import com.efemoney.maggg.data.Repository
6 | import com.efemoney.maggg.data.model.BackdropPath
7 | import com.efemoney.maggg.data.model.Movie
8 | import com.efemoney.maggg.data.model.PosterPath
9 | import com.efemoney.maggg.ui.detail.DetailViewModel.DetailViewIntents.LoadMovieIntent
10 | import com.efemoney.maggg.ui.detail.DetailViewModel.DetailViewState
11 | import com.efemoney.maggg.ui.rx.TestRxSchedulers
12 | import com.nhaarman.mockito_kotlin.doReturn
13 | import com.nhaarman.mockito_kotlin.mock
14 | import com.nhaarman.mockito_kotlin.verify
15 | import io.reactivex.Observable
16 | import org.junit.Before
17 | import org.junit.Rule
18 | import org.junit.Test
19 | import org.junit.runner.RunWith
20 | import org.junit.runners.JUnit4
21 | import kotlin.LazyThreadSafetyMode.NONE
22 |
23 | @RunWith(JUnit4::class)
24 | class DetailViewModelTest {
25 |
26 | @get:Rule
27 | val instantExecutor = InstantTaskExecutorRule()
28 |
29 | private val testMovie: Movie by lazy(NONE) {
30 |
31 | Movie(
32 | id = 346364,
33 | adult = false,
34 | backdropPath = BackdropPath("/tcheoA2nPATCm2vvXw2hVQoaEFD.jpg"),
35 | belongsToCollection = null,
36 | budget = 35000000,
37 | genres = listOf(),
38 | homepage = "http://itthemovie.com/",
39 | imdbId = "tt1396484",
40 | originalLanguage = "en",
41 | originalTitle = "It",
42 | overview = "In a small town in Maine, seven children known as The Losers Club come face to face with life problems, bullies and a monster that takes the shape of a clown called Pennywise.",
43 | popularity = 994.18693,
44 | posterPath = PosterPath("/9E2y5Q7WlCVNEhP5GiVTjhEhx1o.jpg"),
45 | productionCompanies = listOf(),
46 | productionCountries = listOf(),
47 | releaseDate = "2017-09-05",
48 | revenue = 555575232,
49 | runtime = 135,
50 | spokenLanguages = listOf(),
51 | status = "Released",
52 | tagline = "Your fears are unleashed",
53 | title = "It",
54 | video = false,
55 | voteAverage = 7.2,
56 | voteCount = 4380
57 | )
58 | }
59 | private val testMovieId = 346364
60 |
61 | private val repo: Repository = mock {
62 | on { movieDetails(testMovieId) } doReturn Observable.just(testMovie)
63 | }
64 |
65 | private lateinit var vm: DetailViewModel
66 |
67 | @Before
68 | fun setUp() {
69 | vm = DetailViewModel(repo, TestRxSchedulers())
70 | }
71 |
72 | @Test
73 | fun loadMovie() {
74 |
75 | val observer = mock>()
76 | vm.state.observeForever(observer)
77 |
78 | vm.intents.accept(LoadMovieIntent(testMovieId))
79 |
80 | verify(observer).onChanged(DetailViewState(loading = true))
81 | verify(observer).onChanged(DetailViewState(loading = false, movie = testMovie))
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ext/view.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ext
2 |
3 | import android.graphics.drawable.Drawable
4 | import android.support.annotation.ColorRes
5 | import android.support.annotation.DrawableRes
6 | import android.support.design.widget.Snackbar
7 | import android.support.v4.view.ViewCompat
8 | import android.support.v7.content.res.AppCompatResources
9 | import android.text.Editable
10 | import android.text.TextWatcher
11 | import android.view.View
12 | import android.widget.TextView
13 |
14 | fun View.doOnLayout(onLayout: (View) -> Boolean) {
15 | addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
16 |
17 | override fun onLayoutChange(view: View, left: Int, top: Int, right: Int, bottom: Int,
18 | oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
19 |
20 | if (onLayout(view)) view.removeOnLayoutChangeListener(this)
21 | }
22 | })
23 | }
24 |
25 | fun View.doWhenLaidOut(a: (View) -> Unit) {
26 |
27 | if (ViewCompat.isLaidOut(this))
28 | a(this)
29 | else doOnLayout {
30 | a(it)
31 | false
32 | }
33 | }
34 |
35 | fun View.show() {
36 | visibility = View.VISIBLE
37 | }
38 |
39 | fun View.hide() {
40 | visibility = View.GONE
41 | }
42 |
43 | fun View.enable() {
44 | isEnabled = true
45 | }
46 |
47 | fun View.disable() {
48 | isEnabled = false
49 | }
50 |
51 | val View.isShowing
52 | get() = visibility == View.VISIBLE
53 |
54 | fun View.snackbar(msg: CharSequence, length: Int) {
55 | Snackbar.make(this, msg, length).show()
56 | }
57 |
58 | fun View.enableIf(predicate: Boolean) = if (predicate) enable() else disable()
59 |
60 | fun View.showIf(predicate: Boolean) = if (predicate) show() else hide()
61 |
62 | fun View.centerX() = (left + right) / 2f
63 |
64 | fun View.centerY() = (top + bottom) / 2f
65 |
66 | fun showAll(vararg views: View) = views.forEach { it.show() }
67 |
68 | fun hideAll(vararg views: View) = views.forEach { it.hide() }
69 |
70 |
71 | fun TextView.setTextColorRes(@ColorRes resId: Int) = setTextColor(context.color(resId))
72 |
73 | fun TextView.onTextChange(action: () -> Unit) = addTextChangedListener(object : TextWatcher {
74 | override fun afterTextChanged(p0: Editable?) = action()
75 | override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) = Unit
76 | override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) = Unit
77 | })
78 |
79 | fun TextView.drawableLeft(@DrawableRes resId: Int)
80 | = drawableLeft(AppCompatResources.getDrawable(context, resId))
81 |
82 | fun TextView.drawableLeft(drawable: Drawable?) = compoundDrawables(left = drawable)
83 | fun TextView.drawableTop(drawable: Drawable?) = compoundDrawables(top = drawable)
84 | fun TextView.drawableRight(drawable: Drawable?) = compoundDrawables(right = drawable)
85 | fun TextView.drawableBottom(drawable: Drawable?) = compoundDrawables(bottom = drawable)
86 |
87 | fun TextView.compoundDrawables(left: Drawable? = null, top: Drawable? = null,
88 | right: Drawable? = null, bottom: Drawable? = null) {
89 |
90 | val d = compoundDrawables
91 |
92 | val l = left ?: d[0]
93 | val t = top ?: d[1]
94 | val r = right ?: d[2]
95 | val b = bottom ?: d[3]
96 |
97 | setCompoundDrawablesWithIntrinsicBounds(l, t, r, b)
98 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_popular.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
9 |
10 |
18 |
19 |
23 |
24 |
25 |
26 |
37 |
38 |
51 |
52 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/detail/DetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.detail
2 |
3 | import android.arch.lifecycle.LiveData
4 | import android.arch.lifecycle.MutableLiveData
5 | import android.arch.lifecycle.ViewModel
6 | import com.efemoney.maggg.data.Repository
7 | import com.efemoney.maggg.data.model.Movie
8 | import com.efemoney.maggg.rx.RxSchedulers
9 | import com.efemoney.maggg.ui.detail.DetailViewModel.DetailViewIntents.LoadMovieIntent
10 | import com.efemoney.maggg.ui.detail.DetailViewModel.DetailViewResults.LoadMovieResult
11 | import com.jakewharton.rxrelay2.PublishRelay
12 | import io.reactivex.Observable
13 | import io.reactivex.disposables.Disposable
14 | import io.reactivex.functions.Consumer
15 | import timber.log.Timber
16 | import javax.inject.Inject
17 |
18 | class DetailViewModel
19 | @Inject constructor(private val repository: Repository,
20 | private val schedulers: RxSchedulers) : ViewModel() {
21 |
22 | private val disposable: Disposable
23 |
24 | private val _state = MutableLiveData()
25 | private val _intents = PublishRelay.create()
26 |
27 | val state : LiveData get() = _state
28 | val intents : Consumer get() = _intents
29 |
30 | init {
31 |
32 | disposable = _intents.publish p@ { shared ->
33 |
34 | val merged = // Observable.merge(
35 | shared.ofType(LoadMovieIntent::class.java)
36 | .doOnNext { Timber.tag("StateLogs").d("<--- Intent: load movie ") }
37 | .flatMap(::loadMovie)
38 | // )
39 |
40 | return@p merged.scan(DetailViewState(), this::reduce)
41 | .doOnNext { Timber.tag("StateLogs").d("---> State : loading=${it.loading}, error=${it.error}, movie=${if (it.movie == null) "null" else "Movie(...)" }, ") }
42 |
43 | }.observeOn(schedulers.main).subscribe(_state::setValue)
44 | }
45 |
46 | override fun onCleared() = disposable.dispose()
47 |
48 | private fun loadMovie(intent: LoadMovieIntent): Observable {
49 |
50 | return repository.movieDetails(intent.id)
51 | .subscribeOn(schedulers.network)
52 | .observeOn(schedulers.main)
53 | .map { LoadMovieResult(movie = it) }
54 | .startWith(LoadMovieResult(loading = true))
55 | .onErrorReturn { LoadMovieResult(error = it) }
56 | }
57 |
58 | private fun reduce(previous: DetailViewState, next: DetailViewResults): DetailViewState {
59 |
60 | return when (next) {
61 | is LoadMovieResult -> {
62 | when {
63 | next.loading -> previous.copy(loading = true)
64 | next.error != null -> previous.copy(loading = false, error = next.error)
65 | else -> previous.copy(loading = false, error = null, movie = next.movie)
66 | }
67 | }
68 | }
69 | }
70 |
71 | sealed class DetailViewIntents {
72 |
73 | data class LoadMovieIntent(val id: Int) : DetailViewIntents()
74 | }
75 |
76 | private sealed class DetailViewResults {
77 |
78 | data class LoadMovieResult(val loading: Boolean = false,
79 | val error: Throwable? = null,
80 | val movie: Movie? = null) : DetailViewResults()
81 | }
82 |
83 | data class DetailViewState(
84 | val loading: Boolean = false,
85 | val error: Throwable? = null,
86 | val movie: Movie? = null
87 | )
88 | }
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/efemoney/maggg/ui/popular/PopularViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.popular
2 |
3 | import android.arch.core.executor.testing.InstantTaskExecutorRule
4 | import android.arch.lifecycle.Observer
5 | import com.efemoney.maggg.Navigator
6 | import com.efemoney.maggg.data.Repository
7 | import com.efemoney.maggg.data.model.BackdropPath
8 | import com.efemoney.maggg.data.model.MovieOverview
9 | import com.efemoney.maggg.data.model.Paged
10 | import com.efemoney.maggg.data.model.PosterPath
11 | import com.efemoney.maggg.ui.popular.PopularViewModel.PopularViewIntents.LoadInitialIntent
12 | import com.efemoney.maggg.ui.popular.PopularViewModel.PopularViewIntents.LoadNextPageIntent
13 | import com.efemoney.maggg.ui.popular.PopularViewModel.PopularViewState
14 | import com.efemoney.maggg.ui.rx.TestRxSchedulers
15 | import com.nhaarman.mockito_kotlin.doReturn
16 | import com.nhaarman.mockito_kotlin.mock
17 | import com.nhaarman.mockito_kotlin.verify
18 | import io.reactivex.Observable
19 | import org.junit.Before
20 | import org.junit.Rule
21 | import org.junit.Test
22 | import org.junit.runner.RunWith
23 | import org.junit.runners.JUnit4
24 |
25 | @RunWith(JUnit4::class)
26 | class PopularViewModelTest {
27 |
28 | @get:Rule
29 | val instantExecutor = InstantTaskExecutorRule()
30 |
31 | private val testMovieList = listOf(
32 | MovieOverview(
33 | id = 346364,
34 | adult = false,
35 | backdropPath = BackdropPath("/tcheoA2nPATCm2vvXw2hVQoaEFD.jpg"),
36 | originalLanguage = "en",
37 | originalTitle = "It",
38 | overview = "In a small town in Maine, seven children known as The Losers Club come face to face with life problems, bullies and a monster that takes the shape of a clown called Pennywise.",
39 | popularity = 994.18693,
40 | posterPath = PosterPath("/9E2y5Q7WlCVNEhP5GiVTjhEhx1o.jpg"),
41 | releaseDate = "2017-09-05",
42 | title = "It",
43 | video = false,
44 | voteAverage = 7.2,
45 | voteCount = 4380,
46 | genreIds = listOf()
47 | )
48 | )
49 |
50 | private val repo: Repository = mock {
51 | on { popularMovies(1) } doReturn Observable.just(Paged(
52 | testMovieList,
53 | page = 1,
54 | totalPages = 2,
55 | totalResults = 2
56 | ))
57 |
58 | on { popularMovies(2) } doReturn Observable.just(Paged(
59 | testMovieList,
60 | page = 2,
61 | totalPages = 2,
62 | totalResults = 2
63 | ))
64 | }
65 |
66 | private val navigator: Navigator = mock()
67 |
68 | private lateinit var vm: PopularViewModel
69 |
70 | @Before
71 | fun setUp() {
72 | vm = PopularViewModel(repo, navigator, TestRxSchedulers())
73 | }
74 |
75 | @Test
76 | fun loadInitialThenLoadNext() {
77 |
78 | val observer = mock>()
79 | vm.state.observeForever(observer)
80 |
81 | vm.intents.accept(LoadInitialIntent)
82 |
83 | verify(observer).onChanged(PopularViewState(loading = true))
84 | verify(observer).onChanged(PopularViewState(loading = false, list = testMovieList))
85 |
86 | vm.intents.accept(LoadNextPageIntent)
87 |
88 | verify(observer).onChanged(PopularViewState(nextPageLoading = true, list = testMovieList))
89 | verify(observer).onChanged(PopularViewState(nextPageLoading = false, list = testMovieList + testMovieList))
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/detail/DetailActivity.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.detail
2 |
3 | import android.arch.lifecycle.Observer
4 | import android.arch.lifecycle.ViewModelProvider
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.databinding.DataBindingUtil
8 | import android.os.Bundle
9 | import com.efemoney.maggg.R
10 | import com.efemoney.maggg.databinding.ActivityDetailBinding
11 | import com.efemoney.maggg.ext.*
12 | import com.efemoney.maggg.glide.GlideApp
13 | import com.efemoney.maggg.ui.base.BaseActivity
14 | import com.efemoney.maggg.ui.detail.DetailViewModel.DetailViewIntents.LoadMovieIntent
15 | import io.reactivex.Observable
16 | import javax.inject.Inject
17 |
18 | class DetailActivity : BaseActivity() {
19 |
20 | lateinit var binding: ActivityDetailBinding
21 |
22 | @Inject lateinit var factory: ViewModelProvider.Factory
23 | private lateinit var viewModel: DetailViewModel
24 |
25 | override fun onCreate(savedInstanceState: Bundle?) {
26 | super.onCreate(savedInstanceState)
27 | binding = DataBindingUtil.setContentView(this, R.layout.activity_detail)
28 |
29 | val id = intent.getIntExtra(EXTRA_MOVIE_ID, -1)
30 |
31 | setSupportActionBar(binding.toolbar)
32 | supportActionBar?.setDisplayHomeAsUpEnabled(true)
33 | supportActionBar?.setDisplayShowTitleEnabled(false)
34 |
35 | viewModel = viewModelGetUsing(factory)
36 |
37 | setupView()
38 | bindView()
39 | bindViewModel(id)
40 | }
41 |
42 | private fun setupView() {
43 |
44 | }
45 |
46 | private fun bindView() {
47 |
48 | viewModel.state.observe(this, Observer {
49 |
50 | val state = it ?: throw NullPointerException("Received null state")
51 |
52 | when {
53 | state.loading -> {
54 | binding.progress.show()
55 | binding.emptyContent.hide()
56 | }
57 |
58 | state.error != null -> {
59 | binding.progress.hide()
60 | binding.emptyContent.show()
61 |
62 | binding.emptyContent.text = state.error.message ?: string(R.string.error)
63 | }
64 |
65 | else -> {
66 | val movie = state.movie ?: return@Observer
67 |
68 | binding.progress.hide()
69 | binding.emptyContent.hide()
70 |
71 | val rm = GlideApp.with(this)
72 | movie.posterPath?.let { rm.load(it).into(binding.poster) }
73 | movie.backdropPath?.let { rm.load(it).into(binding.backdrop) }
74 |
75 | binding.title.text = getString(R.string.format_movie_title, movie.title, movie.releaseDate.take(4))
76 | binding.genres.text = movie.genres.joinToString { it.name.capitalize() }
77 |
78 | binding.rating.text = movie.voteAverage.toString()
79 | binding.rating.drawableLeft(R.drawable.ic_star)
80 |
81 | binding.overview.text = movie.overview
82 |
83 | when {
84 | movie.tagline.isNullOrBlank() -> binding.tagline.hide()
85 | else -> binding.tagline.text = getString(R.string.format_tagline, movie.tagline) // if not null
86 | }
87 | }
88 | }
89 | })
90 | }
91 |
92 | private fun bindViewModel(id: Int) {
93 |
94 | disposables += Observable
95 | .just(LoadMovieIntent(id))
96 | .subscribe(viewModel.intents)
97 | }
98 |
99 | companion object {
100 |
101 | val EXTRA_MOVIE_ID = "id"
102 |
103 | fun createIntent(context: Context, id: Int) = Intent(context, DetailActivity::class.java)
104 | .apply { putExtra(EXTRA_MOVIE_ID, id) }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/deps.gradle:
--------------------------------------------------------------------------------
1 | ext.versions = [
2 | agp: '3.1.0-alpha05',
3 | kotlin: '1.2.0',
4 |
5 | playservices: '11.6.2',
6 | support: '27.0.2',
7 | constraint: '1.1.0-beta3',
8 | archcomponents: '1.0.0',
9 |
10 | timber: '4.6.0',
11 |
12 | dagger: '2.13',
13 | okhttp: '3.9.0',
14 | retrofit: '2.3.0',
15 |
16 | gson: '2.8.2',
17 | glide: '4.4.0',
18 |
19 | rxjava: '2.1.7',
20 | rxandroid: '2.0.1',
21 |
22 | rxrelay: '2.0.0',
23 | rxbinding: '2.0.0',
24 | ]
25 |
26 | ext.deps = [
27 |
28 | kotlinplugin: "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin",
29 | androidplugin: "com.android.tools.build:gradle:$versions.agp",
30 |
31 | databinding: [
32 | compiler: "com.android.databinding:compiler:$versions.agp"
33 | ],
34 |
35 | kotlin: [
36 | stdlib: "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$versions.kotlin",
37 | junittest: "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin"
38 | ],
39 |
40 | support: [
41 | design: "com.android.support:design:$versions.support",
42 | palette: "com.android.support:palette-v7:$versions.support",
43 | cardview: "com.android.support:cardview-v7:$versions.support",
44 | appcompat: "com.android.support:appcompat-v7:$versions.support",
45 | preference: "com.android.support:preference-v14:$versions.support",
46 | constraint: "com.android.support.constraint:constraint-layout:$versions.constraint",
47 | ],
48 |
49 | arch: [
50 | lifecycle: [
51 | compiler: "android.arch.lifecycle:compiler:$versions.archcomponents",
52 | extensions: "android.arch.lifecycle:extensions:$versions.archcomponents",
53 | reactivestreams: "android.arch.lifecycle:reactivestreams:$versions.archcomponents"
54 | ],
55 |
56 | room: [
57 | compiler: "android.arch.persistence.room:compiler:$versions.archcomponents",
58 | runtime: "android.arch.persistence.room:runtime:$versions.archcomponents",
59 | rxjava: "android.arch.persistence.room:rxjava2:$versions.archcomponents",
60 | ]
61 | ],
62 |
63 | gson: "com.google.code.gson:gson:$versions.gson",
64 | timber: "com.jakewharton.timber:timber:$versions.timber",
65 |
66 | okhttp: [
67 | main: "com.squareup.okhttp3:okhttp:$versions.okhttp",
68 | logging: "com.squareup.okhttp3:logging-interceptor:$versions.okhttp",
69 | ],
70 |
71 | retrofit: [
72 | main: "com.squareup.retrofit2:retrofit:$versions.retrofit",
73 | rx2adapter: "com.squareup.retrofit2:adapter-rxjava2:$versions.retrofit",
74 | gsonconverter: "com.squareup.retrofit2:converter-gson:$versions.retrofit",
75 | ],
76 |
77 | glide: [
78 | main: "com.github.bumptech.glide:glide:$versions.glide",
79 | compiler: "com.github.bumptech.glide:compiler:$versions.glide",
80 | okhttpintegration: "com.github.bumptech.glide:okhttp3-integration:$versions.glide",
81 | ],
82 |
83 | dagger: [
84 | main: "com.google.dagger:dagger:$versions.dagger",
85 | compiler: "com.google.dagger:dagger-compiler:$versions.dagger",
86 | android: "com.google.dagger:dagger-android:$versions.dagger",
87 | androidsupport: "com.google.dagger:dagger-android-support:$versions.dagger",
88 | androidprocessor: "com.google.dagger:dagger-android-processor:$versions.dagger"
89 | ],
90 |
91 | jsr250: "javax.annotation:jsr250-api:1.0",
92 |
93 | rx: [
94 | java: "io.reactivex.rxjava2:rxjava:$versions.rxjava",
95 | android: "io.reactivex.rxjava2:rxandroid:$versions.rxandroid",
96 | ],
97 |
98 | rxrelay: "com.jakewharton.rxrelay2:rxrelay:$versions.rxrelay",
99 |
100 | rxbinding: [
101 | main: "com.jakewharton.rxbinding2:rxbinding:$versions.rxbinding",
102 | appcompat: "com.jakewharton.rxbinding2:rxbinding-appcompat-v7:$versions.rxbinding",
103 | recyclerview: "com.jakewharton.rxbinding2:rxbinding-recyclerview-v7:$versions.rxbinding",
104 | ],
105 |
106 | rxbindingkotlin: [
107 | main: "com.jakewharton.rxbinding2:rxbinding-kotlin:$versions.rxbinding",
108 | appcompat: "com.jakewharton.rxbinding2:rxbinding-appcompat-v7-kotlin:$versions.rxbinding",
109 | recyclerview: "com.jakewharton.rxbinding2:rxbinding-recyclerview-v7-kotlin:$versions.rxbinding",
110 | ],
111 | ]
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/glide/TmdbImageUrlConfig.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.glide
2 |
3 | import android.content.SharedPreferences
4 | import android.util.SparseArray
5 | import com.efemoney.maggg.data.model.TmdbImagePath
6 | import com.efemoney.maggg.data.remote.Config
7 | import com.efemoney.maggg.data.remote.ImageConfig
8 | import com.efemoney.maggg.data.remote.TmdbApi
9 | import com.efemoney.maggg.inject.qualifier.ImageConfigPref
10 | import com.efemoney.maggg.rx.RxSchedulers
11 | import timber.log.Timber
12 | import java.util.concurrent.TimeUnit.DAYS
13 | import javax.inject.Inject
14 | import javax.inject.Singleton
15 | import kotlin.math.abs
16 |
17 | @Singleton
18 | class TmdbImageUrlConfig
19 | @Inject constructor(private val api: TmdbApi,
20 | private val schedulers: RxSchedulers,
21 | @ImageConfigPref private val prefs: SharedPreferences) {
22 |
23 | private var config = ImageConfig(
24 | baseUrl = prefs.getString("config.base", DEFAULT_URL),
25 | posterSizes = prefs.getStringSet("config.poster", setOf()).toList(),
26 | backdropSizes = prefs.getStringSet("config.backdrop", setOf()).toList()
27 | )
28 |
29 | // caches
30 | private val p = SparseArray(2)
31 | private val b = SparseArray(2)
32 |
33 | init {
34 | updateConfigIfNecessary()
35 | }
36 |
37 | private fun updateConfigIfNecessary() {
38 |
39 | val now = System.currentTimeMillis()
40 | val threeDays = DAYS.toMillis(3) // update every 3 days
41 | val lastRetrieved = prefs.getLong("config.retrieved", 0)
42 |
43 | if (now - lastRetrieved < threeDays) return
44 |
45 | val ignored = api.config()
46 | .subscribeOn(schedulers.network)
47 | .subscribe(::updateConfig)
48 | }
49 |
50 | private fun updateConfig(c: Config) {
51 |
52 | config = c.imageConfig
53 |
54 | // clear caches
55 | p.clear()
56 | b.clear()
57 |
58 | // Persist
59 | prefs.edit()
60 | .putString("config.base", config.baseUrl)
61 | .putStringSet("config.poster", config.posterSizes.toSet())
62 | .putStringSet("config.backdrop", config.backdropSizes.toSet())
63 | .putLong("config.retrieved", System.currentTimeMillis())
64 | .apply()
65 | }
66 |
67 | fun url(model: TmdbImagePath, width: Int): String {
68 |
69 | return when(model.type) {
70 | "poster" -> posterUrl(model.path, width)
71 | "backdrop" -> backdropUrl(model.path, width)
72 | else -> {
73 | config.baseUrl + DEFAULT_SIZE + model.path
74 | }
75 | }
76 | }
77 |
78 | private fun posterUrl(path: String, width: Int) = kotlin.run {
79 |
80 | val size = p.get(width) ?: kotlin.run {
81 | p.put(width, findBestSize(width, config.posterSizes) ?: DEFAULT_SIZE)
82 | p.get(width)
83 | }
84 |
85 | Timber.d("PosterImageRequest: $width -> $size from ${config.posterSizes}")
86 |
87 | config.baseUrl + size + path
88 | }
89 |
90 | private fun backdropUrl(path: String, width: Int) = kotlin.run {
91 |
92 | val size = p.get(width) ?: kotlin.run {
93 | p.put(width, findBestSize(width, config.posterSizes) ?: DEFAULT_SIZE)
94 | p.get(width)
95 | }
96 |
97 | Timber.d("BackdropImageRequest: $width -> $size from ${config.backdropSizes}")
98 |
99 | config.baseUrl + size + path
100 | }
101 |
102 | private fun findBestSize(width: Int, list: List): String? {
103 |
104 | return when {
105 |
106 | list.isEmpty() -> null
107 |
108 | else -> {
109 |
110 | val index = list.asSequence() // sequences!
111 | .map { it.substringAfter('w', missingDelimiterValue = "") } // remove 'w'
112 | .map { it.toIntOrNull() } // Convert to int or null
113 | .map { it?.minus(width) } // map to 'deviation' from required width
114 | .mapIndexed { i, v -> i to v } // capture index and deviation
115 | .sortedBy { abs(it.second ?: Int.MAX_VALUE) } // sort by abs(deviation) smallest to largest
116 | .take(2) // take first two items
117 | .minBy { it.second?.plus(width) ?: Int.MAX_VALUE } // pick whichever will result in a smaller image download
118 | ?.first // get its index
119 |
120 | list[index ?: list.size - 1] // fallback to last size
121 | }
122 | }
123 | }
124 |
125 | companion object {
126 | @JvmField val DEFAULT_URL = "http://image.tmdb.org/t/p/"
127 | @JvmField val DEFAULT_SIZE = "original"
128 | }
129 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
10 | DEFAULT_JVM_OPTS=""
11 |
12 | APP_NAME="Gradle"
13 | APP_BASE_NAME=`basename "$0"`
14 |
15 | # Use the maximum available, or set MAX_FD != -1 to use that value.
16 | MAX_FD="maximum"
17 |
18 | warn ( ) {
19 | echo "$*"
20 | }
21 |
22 | die ( ) {
23 | echo
24 | echo "$*"
25 | echo
26 | exit 1
27 | }
28 |
29 | # OS specific support (must be 'true' or 'false').
30 | cygwin=false
31 | msys=false
32 | darwin=false
33 | case "`uname`" in
34 | CYGWIN* )
35 | cygwin=true
36 | ;;
37 | Darwin* )
38 | darwin=true
39 | ;;
40 | MINGW* )
41 | msys=true
42 | ;;
43 | esac
44 |
45 | # Attempt to set APP_HOME
46 | # Resolve links: $0 may be a link
47 | PRG="$0"
48 | # Need this for relative symlinks.
49 | while [ -h "$PRG" ] ; do
50 | ls=`ls -ld "$PRG"`
51 | link=`expr "$ls" : '.*-> \(.*\)$'`
52 | if expr "$link" : '/.*' > /dev/null; then
53 | PRG="$link"
54 | else
55 | PRG=`dirname "$PRG"`"/$link"
56 | fi
57 | done
58 | SAVED="`pwd`"
59 | cd "`dirname \"$PRG\"`/" >/dev/null
60 | APP_HOME="`pwd -P`"
61 | cd "$SAVED" >/dev/null
62 |
63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
64 |
65 | # Determine the Java command to use to start the JVM.
66 | if [ -n "$JAVA_HOME" ] ; then
67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
68 | # IBM's JDK on AIX uses strange locations for the executables
69 | JAVACMD="$JAVA_HOME/jre/sh/java"
70 | else
71 | JAVACMD="$JAVA_HOME/bin/java"
72 | fi
73 | if [ ! -x "$JAVACMD" ] ; then
74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
75 |
76 | Please set the JAVA_HOME variable in your environment to match the
77 | location of your Java installation."
78 | fi
79 | else
80 | JAVACMD="java"
81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
82 |
83 | Please set the JAVA_HOME variable in your environment to match the
84 | location of your Java installation."
85 | fi
86 |
87 | # Increase the maximum file descriptors if we can.
88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
89 | MAX_FD_LIMIT=`ulimit -H -n`
90 | if [ $? -eq 0 ] ; then
91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
92 | MAX_FD="$MAX_FD_LIMIT"
93 | fi
94 | ulimit -n $MAX_FD
95 | if [ $? -ne 0 ] ; then
96 | warn "Could not set maximum file descriptor limit: $MAX_FD"
97 | fi
98 | else
99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
100 | fi
101 | fi
102 |
103 | # For Darwin, add options to specify how the application appears in the dock
104 | if $darwin; then
105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
106 | fi
107 |
108 | # For Cygwin, switch paths to Windows format before running java
109 | if $cygwin ; then
110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
112 | JAVACMD=`cygpath --unix "$JAVACMD"`
113 |
114 | # We build the pattern for arguments to be converted via cygpath
115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
116 | SEP=""
117 | for dir in $ROOTDIRSRAW ; do
118 | ROOTDIRS="$ROOTDIRS$SEP$dir"
119 | SEP="|"
120 | done
121 | OURCYGPATTERN="(^($ROOTDIRS))"
122 | # Add a user-defined pattern to the cygpath arguments
123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
125 | fi
126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
127 | i=0
128 | for arg in "$@" ; do
129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
131 |
132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
134 | else
135 | eval `echo args$i`="\"$arg\""
136 | fi
137 | i=$((i+1))
138 | done
139 | case $i in
140 | (0) set -- ;;
141 | (1) set -- "$args0" ;;
142 | (2) set -- "$args0" "$args1" ;;
143 | (3) set -- "$args0" "$args1" "$args2" ;;
144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
150 | esac
151 | fi
152 |
153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
154 | function splitJvmOpts() {
155 | JVM_OPTS=("$@")
156 | }
157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
159 |
160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
161 |
--------------------------------------------------------------------------------
/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/kotlin/com/efemoney/maggg/ui/popular/PopularViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.popular
2 |
3 | import android.arch.lifecycle.LiveData
4 | import android.arch.lifecycle.MutableLiveData
5 | import android.arch.lifecycle.ViewModel
6 | import com.efemoney.maggg.Navigator
7 | import com.efemoney.maggg.data.Repository
8 | import com.efemoney.maggg.data.model.MovieOverview
9 | import com.efemoney.maggg.data.model.Paged
10 | import com.efemoney.maggg.rx.RxSchedulers
11 | import com.efemoney.maggg.ui.popular.PopularViewModel.PopularViewIntents.*
12 | import com.efemoney.maggg.ui.popular.PopularViewModel.PopularViewResults.*
13 | import com.jakewharton.rxrelay2.PublishRelay
14 | import io.reactivex.Observable
15 | import io.reactivex.disposables.Disposable
16 | import io.reactivex.functions.Consumer
17 | import timber.log.Timber
18 | import javax.inject.Inject
19 |
20 | class PopularViewModel
21 | @Inject constructor(private val repository: Repository,
22 | private val navigator: Navigator,
23 | private val schedulers: RxSchedulers) : ViewModel() {
24 |
25 | private val disposable: Disposable
26 |
27 | private val _state = MutableLiveData()
28 | private val _intents = PublishRelay.create()
29 |
30 | val state: LiveData get() = _state
31 | val intents: Consumer get() = _intents
32 |
33 | /* */
34 |
35 | private var page: Int = 1
36 | private var total: Int = 1
37 |
38 | init {
39 | disposable = _intents.publish p@ { shared ->
40 |
41 | val merged = Observable.merge(
42 |
43 | shared.ofType(LoadInitialIntent::class.java)
44 | .doOnNext { Timber.tag("StateLogs").d("<--- Intent: load init") }
45 | .flatMap(::loadInitial),
46 |
47 | shared.ofType(LoadNextPageIntent::class.java)
48 | .doOnNext { Timber.tag("StateLogs").d("<--- Intent: load next page (${page + 1})") }
49 | .flatMap(::loadNextPage),
50 |
51 | shared.ofType(MovieClickedIntent::class.java)
52 | .doOnNext { Timber.tag("StateLogs").d("<--- Intent: click movie(${it.id})") }
53 | .flatMap(::movieClicked)
54 | )
55 |
56 | return@p merged.scan(PopularViewState(), this::reduce)
57 | .distinctUntilChanged()
58 | .doOnNext { Timber.tag("StateLogs").d("---> State : loading=${it.loading}, error=${it.error}, npLoading=${it.nextPageLoading}, npError=${it.nextPageError}, list=[${if (it.list.isNotEmpty()) it.list.size.toString() else ""}]") }
59 |
60 | }.observeOn(schedulers.main).subscribe(_state::setValue)
61 | }
62 |
63 | override fun onCleared() = disposable.dispose()
64 |
65 | private fun loadInitial(ignored: LoadInitialIntent): Observable {
66 |
67 | return repository.popularMovies(1)
68 | .subscribeOn(schedulers.network)
69 | .map { LoadInitialResult(paged = it) }
70 | .startWith(LoadInitialResult(loading = true))
71 | .onErrorReturn { LoadInitialResult(error = it) }
72 | }
73 |
74 | private fun loadNextPage(ignored: LoadNextPageIntent): Observable {
75 |
76 | val nextPageIsLoading = _state.value!!.nextPageLoading
77 | val pageIsLast = page == total
78 |
79 | return when {
80 | nextPageIsLoading || pageIsLast -> // if already loading or exhausted pages
81 | Observable.just(LoadNextPageResult(wontLoad = true))
82 |
83 | else -> repository.popularMovies(page + 1)
84 | .subscribeOn(schedulers.network)
85 | .map { LoadNextPageResult(paged = it) }
86 | .startWith(LoadNextPageResult(loading = true))
87 | .onErrorReturn { LoadNextPageResult(error = it) }
88 | }
89 |
90 | }
91 |
92 | private fun movieClicked(intent: MovieClickedIntent): Observable {
93 | navigator.showDetails(intent.id)
94 | return Observable.just(MovieClickedResult)
95 | }
96 |
97 | private fun reduce(previous: PopularViewState, next: PopularViewResults): PopularViewState {
98 |
99 | return when (next) {
100 |
101 | is LoadInitialResult -> {
102 | when {
103 | next.loading -> previous.copy(loading = true, error = null)
104 | next.error != null -> previous.copy(loading = false, error = next.error)
105 | else -> {
106 | val paged = next.paged!!
107 | page = paged.page
108 | total = paged.totalPages
109 | previous.copy(loading = false, error = null, list = paged.results)
110 | }
111 | }
112 | }
113 |
114 | is LoadNextPageResult -> {
115 | when {
116 | next.wontLoad -> previous // state remains same
117 | next.loading -> previous.copy(nextPageLoading = true, nextPageError = null)
118 | next.error != null -> previous.copy(nextPageLoading = false, nextPageError = next.error)
119 | else -> {
120 | val paged = next.paged!!
121 | page = paged.page
122 | total = paged.totalPages
123 | previous.copy(nextPageLoading = false, nextPageError = null, list = previous.list + next.paged.results)
124 | }
125 | }
126 | }
127 |
128 | is MovieClickedResult -> previous
129 | }
130 | }
131 |
132 | sealed class PopularViewIntents {
133 |
134 | object LoadInitialIntent : PopularViewIntents()
135 |
136 | object LoadNextPageIntent : PopularViewIntents()
137 |
138 | data class MovieClickedIntent(val id: Int) : PopularViewIntents()
139 | }
140 |
141 | private sealed class PopularViewResults {
142 |
143 | data class LoadInitialResult(val loading: Boolean = false,
144 | val error: Throwable? = null,
145 | val paged: Paged? = null) : PopularViewResults()
146 |
147 | data class LoadNextPageResult(val wontLoad: Boolean = false,
148 | val loading: Boolean = false,
149 | val error: Throwable? = null,
150 | val paged: Paged? = null) : PopularViewResults()
151 |
152 | object MovieClickedResult : PopularViewResults()
153 | }
154 |
155 | data class PopularViewState(
156 | val loading: Boolean = false,
157 | val error: Throwable? = null,
158 | val nextPageLoading: Boolean = false,
159 | val nextPageError: Throwable? = null,
160 | val list: List = listOf()
161 | )
162 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/efemoney/maggg/ui/popular/PopularActivity.kt:
--------------------------------------------------------------------------------
1 | package com.efemoney.maggg.ui.popular
2 |
3 | import android.arch.lifecycle.Observer
4 | import android.arch.lifecycle.ViewModelProvider
5 | import android.content.Context
6 | import android.databinding.DataBindingUtil
7 | import android.databinding.ViewDataBinding
8 | import android.os.Bundle
9 | import android.support.design.widget.Snackbar
10 | import android.support.v7.util.DiffUtil
11 | import android.support.v7.widget.GridLayoutManager
12 | import android.support.v7.widget.RecyclerView
13 | import android.view.LayoutInflater
14 | import android.view.ViewGroup
15 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade
16 | import com.efemoney.maggg.R
17 | import com.efemoney.maggg.data.model.MovieOverview
18 | import com.efemoney.maggg.databinding.ActivityPopularBinding
19 | import com.efemoney.maggg.databinding.ItemLoadingBinding
20 | import com.efemoney.maggg.databinding.ItemPopularBinding
21 | import com.efemoney.maggg.ext.*
22 | import com.efemoney.maggg.glide.GlideApp
23 | import com.efemoney.maggg.rx.RxSchedulers
24 | import com.efemoney.maggg.ui.base.BaseActivity
25 | import com.efemoney.maggg.ui.popular.PopularViewModel.PopularViewIntents.*
26 | import com.jakewharton.rxbinding2.support.v7.widget.RecyclerViewScrollEvent
27 | import com.jakewharton.rxbinding2.support.v7.widget.scrollEvents
28 | import com.jakewharton.rxbinding2.view.clicks
29 | import com.jakewharton.rxrelay2.PublishRelay
30 | import io.reactivex.Observable
31 | import java.util.concurrent.TimeUnit.MILLISECONDS
32 | import javax.inject.Inject
33 |
34 | class PopularActivity : BaseActivity() {
35 |
36 | lateinit var binding: ActivityPopularBinding
37 |
38 | @Inject lateinit var schedulers: RxSchedulers
39 | @Inject lateinit var factory: ViewModelProvider.Factory
40 | private lateinit var viewModel: PopularViewModel
41 |
42 | private lateinit var adapter: PopularAdapter
43 |
44 | override fun onCreate(savedInstanceState: Bundle?) {
45 | super.onCreate(savedInstanceState)
46 |
47 | binding = DataBindingUtil.setContentView(this, R.layout.activity_popular)
48 |
49 | setSupportActionBar(binding.toolbar)
50 |
51 | viewModel = viewModelGetUsing(factory)
52 |
53 | adapter = PopularAdapter(this)
54 |
55 | setupView()
56 | bindView()
57 | bindViewModel()
58 | }
59 |
60 | private fun setupView() {
61 |
62 | val gridSize = resources.getInteger(R.integer.grid_span_count)
63 |
64 | val glm = GridLayoutManager(this, gridSize)
65 | glm.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
66 | override fun getSpanSize(pos: Int): Int = if (adapter.item(pos) is LoadingItem) gridSize else 1
67 | }
68 |
69 | binding.grid.adapter = adapter
70 | binding.grid.layoutManager = glm
71 | }
72 |
73 | private fun bindView() {
74 |
75 | viewModel.state.observe(this, Observer {
76 |
77 | val state = it ?: throw NullPointerException("Received null state")
78 |
79 | when {
80 | state.loading -> {
81 | binding.grid.hide()
82 | binding.progress.show()
83 | binding.emptyContent.hide()
84 | }
85 |
86 | state.error != null -> {
87 | binding.grid.hide()
88 | binding.progress.hide()
89 | binding.emptyContent.show()
90 |
91 | binding.emptyContent.text = state.error.message ?: string(R.string.error)
92 | }
93 |
94 | else -> {
95 | binding.grid.show()
96 | binding.progress.hide()
97 | binding.emptyContent.hide()
98 |
99 | val list: MutableList = state.list.toMutableList() // copy
100 |
101 | if (state.nextPageLoading)
102 | list += LoadingItem
103 |
104 | if (state.nextPageError != null)
105 | binding.root.snackbar(state.nextPageError.message ?: string(R.string.error), Snackbar.LENGTH_LONG)
106 |
107 | adapter.itemsRelay.accept(list)
108 | }
109 | }
110 | })
111 | }
112 |
113 | private fun bindViewModel() {
114 |
115 | disposables += Observable
116 | .just(LoadInitialIntent)
117 | .subscribe(viewModel.intents)
118 |
119 | disposables += binding.grid.scrollEvents()
120 | .throttleFirst(100, MILLISECONDS)
121 | .filter(::isScrollingUpwards)
122 | .filter(::adapterNotLoading)
123 | .filter(::loadMoreThresholdReached)
124 | .map { LoadNextPageIntent }
125 | .subscribe(viewModel.intents)
126 | }
127 |
128 | // dy is +ve when scrolling up
129 | private fun isScrollingUpwards(event: RecyclerViewScrollEvent): Boolean = event.dy() > 0
130 |
131 | private fun adapterNotLoading(ignored: Any) = adapter.items.lastOrNull() !is LoadingItem
132 |
133 | private fun loadMoreThresholdReached(ignored: Any): Boolean {
134 |
135 | val glm = (binding.grid.layoutManager as GridLayoutManager)
136 | val loadMoreThreshold = 2 * glm.spanCount
137 | val lastVisibleItem = glm.findLastVisibleItemPosition()
138 | val itemCount = glm.itemCount
139 |
140 | return lastVisibleItem + loadMoreThreshold >= itemCount
141 | }
142 |
143 | inner class PopularAdapter(context: Context) : RecyclerView.Adapter>() {
144 |
145 | private val inflater = LayoutInflater.from(context)
146 |
147 | val itemsRelay = PublishRelay.create>()
148 |
149 | init {
150 |
151 | val initial = listOf() to (null as DiffUtil.DiffResult?)
152 |
153 | disposables += itemsRelay
154 | .observeOn(schedulers.computation)
155 | .scan(initial) { (old, diff), new ->
156 | // return a pair of new list to diff result of old list with new
157 | new to DiffUtil.calculateDiff(PopularItemDiffCallback(old, new))
158 | }
159 | .observeOn(schedulers.main)
160 | .subscribe {
161 | items = it.first
162 | it.second?.dispatchUpdatesTo(this)
163 | }
164 | }
165 |
166 | var items = listOf()
167 |
168 | fun item(position: Int) = items[position]
169 |
170 | override fun getItemId(position: Int): Long {
171 | val item = items[position]
172 |
173 | return when (item) {
174 | is MovieOverview -> item.id.toLong()
175 | is LoadingItem -> -1L
176 | else -> throw IllegalArgumentException("")
177 | }
178 | }
179 |
180 | override fun getItemViewType(position: Int): Int = when (item(position)) {
181 | is MovieOverview -> 0
182 | is LoadingItem -> 1
183 | else -> throw IllegalStateException("Invalid item type ${item(position)::class.java.simpleName}")
184 | }
185 |
186 | override fun getItemCount(): Int = items.size
187 |
188 | override fun onBindViewHolder(holder: ViewHolder<*, PopularItem>, position: Int) {
189 |
190 | holder.bind(item(position))
191 | }
192 |
193 | override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder<*, PopularItem> {
194 |
195 | return when (viewType) {
196 | 0 -> {
197 | ViewHolder.Popular(
198 | this@PopularActivity,
199 | ItemPopularBinding.inflate(inflater, parent, false)
200 | ) as ViewHolder<*, PopularItem>
201 | }
202 | 1 -> {
203 | ViewHolder.Loading(
204 | ItemLoadingBinding.inflate(inflater, parent, false)
205 | ) as ViewHolder<*, PopularItem>
206 | }
207 | else -> throw IllegalStateException("Unknown view type $viewType")
208 | }
209 | }
210 | }
211 |
212 | sealed class ViewHolder(binding: V)
213 | : RecyclerView.ViewHolder(binding.root) {
214 |
215 | abstract fun bind(data: Data)
216 |
217 | class Popular(private val activity: PopularActivity,
218 | private val binding: ItemPopularBinding)
219 | : ViewHolder(binding) {
220 |
221 | private lateinit var data: MovieOverview
222 |
223 | init {
224 | activity.disposables += binding.root.clicks()
225 | .debounce(150, MILLISECONDS)
226 | .map { MovieClickedIntent(data.id) }
227 | .subscribe(activity.viewModel.intents)
228 | }
229 |
230 | override fun bind(data: MovieOverview) {
231 | this.data = data
232 |
233 | if (data.posterPath == null)
234 | GlideApp.with(activity)
235 | .clear(binding.poster)
236 | else
237 | GlideApp.with(activity)
238 | .load(data.posterPath)
239 | .transition(withCrossFade())
240 | .into(binding.poster)
241 |
242 | binding.executePendingBindings()
243 | }
244 | }
245 |
246 | class Loading(binding: ItemLoadingBinding)
247 | : ViewHolder(binding) {
248 |
249 | override fun bind(data: PopularActivity.LoadingItem) = Unit
250 | }
251 | }
252 |
253 | interface PopularItem
254 |
255 | object LoadingItem
256 | : PopularItem
257 |
258 | class PopularItemDiffCallback(val old: List,
259 | val new: List) : DiffUtil.Callback() {
260 |
261 | override fun getOldListSize() = old.size
262 |
263 | override fun getNewListSize() = new.size
264 |
265 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
266 | val oi = old[oldItemPosition]
267 | val ni = new[newItemPosition]
268 | return oi == ni || (oi is MovieOverview && ni is MovieOverview && oi.id == ni.id)
269 | }
270 |
271 | // Hardcoded for simplicity. If items are same, contents are same too
272 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = true
273 | }
274 | }
275 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_detail.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
9 |
10 |
14 |
15 |
20 |
21 |
30 |
31 |
36 |
37 |
38 |
39 |
40 |
41 |
48 |
49 |
52 |
53 |
67 |
68 |
83 |
84 |
98 |
99 |
112 |
113 |
127 |
128 |
141 |
142 |
156 |
157 |
175 |
176 |
182 |
183 |
189 |
190 |
196 |
197 |
203 |
204 |
210 |
211 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
--------------------------------------------------------------------------------