├── 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 │ │ │ ├── drawable-hdpi │ │ │ │ └── img_placeholder.png │ │ │ ├── drawable-ldpi │ │ │ │ └── img_placeholder.png │ │ │ ├── drawable-mdpi │ │ │ │ └── img_placeholder.png │ │ │ ├── drawable-xhdpi │ │ │ │ └── img_placeholder.png │ │ │ ├── drawable-xxhdpi │ │ │ │ └── img_placeholder.png │ │ │ ├── drawable-xxxhdpi │ │ │ │ └── img_placeholder.png │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ │ ├── drawable │ │ │ │ ├── bg_shimmer_rect.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── layout │ │ │ │ ├── activity_movie_detail.xml │ │ │ │ ├── layout_common_error.xml │ │ │ │ ├── activity_dashboard.xml │ │ │ │ ├── activity_movie_detail_content.xml │ │ │ │ ├── item_movie.xml │ │ │ │ ├── layout_item_movie_load.xml │ │ │ │ └── layout_movie_detail_load.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── andro │ │ │ │ └── indie │ │ │ │ └── school │ │ │ │ ├── common │ │ │ │ ├── di │ │ │ │ │ ├── BaseViewModelModuleProvider.kt │ │ │ │ │ ├── BaseModuleProvider.kt │ │ │ │ │ ├── DataSourceModules.kt │ │ │ │ │ ├── DepsModuleProvider.kt │ │ │ │ │ └── NetworkModules.kt │ │ │ │ ├── base │ │ │ │ │ ├── ResponseResult.kt │ │ │ │ │ ├── BaseActivity.kt │ │ │ │ │ ├── DiffCallback.kt │ │ │ │ │ └── BaseDataSource.kt │ │ │ │ ├── extension │ │ │ │ │ └── CommonExt.kt │ │ │ │ └── custom │ │ │ │ │ └── IntentHelper.kt │ │ │ │ ├── ui │ │ │ │ ├── dashboard │ │ │ │ │ ├── DashboardViewModel.kt │ │ │ │ │ ├── DashboardModule.kt │ │ │ │ │ ├── DashboardActivity.kt │ │ │ │ │ └── DashboardMovieAdapter.kt │ │ │ │ └── detail │ │ │ │ │ ├── MovieDetailViewModel.kt │ │ │ │ │ ├── MovieDetailModule.kt │ │ │ │ │ └── MovieDetailActivity.kt │ │ │ │ ├── api │ │ │ │ ├── MovieApiService.kt │ │ │ │ └── AuthInterceptor.kt │ │ │ │ ├── RetrofitCoroutineApp.kt │ │ │ │ └── data │ │ │ │ ├── model │ │ │ │ ├── vo │ │ │ │ │ └── MovieItem.kt │ │ │ │ └── response │ │ │ │ │ ├── MovieResponse.kt │ │ │ │ │ └── MovieDetailResponse.kt │ │ │ │ └── source │ │ │ │ └── remote │ │ │ │ └── MovieRemoteDataSource.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── andro │ │ │ └── indie │ │ │ └── school │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── andro │ │ └── indie │ │ └── school │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── settings.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── readme.md ├── gradle.properties ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | include(":app") 2 | rootProject.name="RetrofitCoroutine" 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/img_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/drawable-hdpi/img_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-ldpi/img_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/drawable-ldpi/img_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/img_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/drawable-mdpi/img_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/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/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/img_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/drawable-xhdpi/img_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/img_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/drawable-xxhdpi/img_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/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/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/img_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/drawable-xxxhdpi/img_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herisulistiyanto/Simple-Retrofit-Coroutine/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /credential.properties 5 | /.idea 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | .cxx 11 | *.apk 12 | *.aab 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_shimmer_rect.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Oct 31 14:24:54 WIB 2019 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-5.4.1-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/di/BaseViewModelModuleProvider.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.di 2 | 3 | /** 4 | * Created by herisulistiyanto on 07/11/19. 5 | * KjokenKoddinger 6 | */ 7 | 8 | interface BaseViewModelModuleProvider { 9 | 10 | fun loadModules() 11 | 12 | } -------------------------------------------------------------------------------- /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/java/com/andro/indie/school/common/di/BaseModuleProvider.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.di 2 | 3 | import org.koin.core.module.Module 4 | 5 | /** 6 | * Created by herisulistiyanto on 07/11/19. 7 | * KjokenKoddinger 8 | */ 9 | 10 | interface BaseModuleProvider { 11 | 12 | val modules: List 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | RetrofitCoroutine 3 | Score : %s 4 | Release date : %s 5 | Overview 6 | Movie Detail 7 | 8 | -------------------------------------------------------------------------------- /app/src/test/java/com/andro/indie/school/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/base/ResponseResult.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.base 2 | 3 | /** 4 | * Created by herisulistiyanto on 01/11/19. 5 | * KjokenKoddinger 6 | */ 7 | 8 | sealed class ResponseResult { 9 | 10 | data class Success(val result: T): ResponseResult() 11 | data class Error(val msg: T): ResponseResult() 12 | object Loading : ResponseResult() 13 | 14 | } 15 | 16 | data class ResponseWrapper(val data: T?, val errorMsg: String?) -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/ui/dashboard/DashboardViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.ui.dashboard 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.andro.indie.school.data.source.remote.MovieRemoteDataSource 6 | 7 | /** 8 | * Created by herisulistiyanto on 01/11/19. 9 | * KjokenKoddinger 10 | */ 11 | 12 | class DashboardViewModel(private val movieRemoteDataSource: MovieRemoteDataSource) : ViewModel() { 13 | 14 | fun discoverAllMovies() = movieRemoteDataSource.discoverAllMovies(viewModelScope) 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/ui/detail/MovieDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.ui.detail 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.andro.indie.school.data.source.remote.MovieRemoteDataSource 6 | 7 | /** 8 | * Created by herisulistiyanto on 06/11/19. 9 | * KjokenKoddinger 10 | */ 11 | 12 | class MovieDetailViewModel(private val movieRemoteDataSource: MovieRemoteDataSource): ViewModel() { 13 | 14 | fun fetchDetailMovie(movieId: Int) = movieRemoteDataSource.getMovieDetails(movieId, viewModelScope) 15 | 16 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Demo Movie App 2 | 3 | Simple demo app using Retrofit & Kotlin Coroutine 4 | 5 | ### In this app using : 6 | * Retrofit + OkHttp 7 | * Coil Image Loader 8 | * Kotlin Coroutine 9 | * Gradle Kotlin DSL 10 | * MVVM Pattern 11 | * Koin 12 | * Shimmer 13 | 14 | 15 | ### How to Compile? 16 | * You need to get `api key` from [themoviedb.org](https://themoviedb.org/), and go to [developer page](https://developers.themoviedb.org/) for complete docs 17 | * Once you get the api key, then create file named `credential.properties` on rootDir project 18 | * Put your api key in `credential.properties` like : `api_key=xxxxxxxxxxxxxxxx` 19 | * And done :) -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/di/DataSourceModules.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.di 2 | 3 | import com.andro.indie.school.data.source.remote.MovieRemoteDataSource 4 | import org.koin.core.module.Module 5 | import org.koin.dsl.module 6 | 7 | /** 8 | * Created by herisulistiyanto on 07/11/19. 9 | * KjokenKoddinger 10 | */ 11 | 12 | object DataSourceModules: BaseModuleProvider { 13 | 14 | override val modules: List 15 | get() = listOf( 16 | remoteDataSourceModule 17 | ) 18 | 19 | private val remoteDataSourceModule = module { 20 | single { MovieRemoteDataSource(get()) } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/api/MovieApiService.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.api 2 | 3 | import com.andro.indie.school.data.model.response.MovieDetailResponse 4 | import com.andro.indie.school.data.model.response.MovieResponse 5 | import retrofit2.Response 6 | import retrofit2.http.GET 7 | import retrofit2.http.Path 8 | 9 | /** 10 | * Created by herisulistiyanto on 2019-10-31. 11 | * KjokenKoddinger 12 | */ 13 | 14 | interface MovieApiService { 15 | 16 | @GET("/3/discover/movie") 17 | suspend fun discoverAllMovies(): Response 18 | 19 | @GET("/3/movie/{movieId}") 20 | suspend fun getMovieDetail(@Path("movieId") movieId: Int): Response 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/ui/dashboard/DashboardModule.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.ui.dashboard 2 | 3 | import com.andro.indie.school.common.di.BaseViewModelModuleProvider 4 | import org.koin.androidx.viewmodel.dsl.viewModel 5 | import org.koin.core.context.loadKoinModules 6 | import org.koin.dsl.module 7 | 8 | /** 9 | * Created by herisulistiyanto on 07/11/19. 10 | * KjokenKoddinger 11 | */ 12 | 13 | object DashboardModule : BaseViewModelModuleProvider { 14 | 15 | override fun loadModules() = lazyLoadModule 16 | 17 | private val lazyLoadModule by lazy { loadKoinModules(viewModelModule) } 18 | 19 | private val viewModelModule = module { 20 | viewModel { DashboardViewModel(get()) } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/ui/detail/MovieDetailModule.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.ui.detail 2 | 3 | import com.andro.indie.school.common.di.BaseViewModelModuleProvider 4 | import org.koin.androidx.viewmodel.dsl.viewModel 5 | import org.koin.core.context.loadKoinModules 6 | import org.koin.dsl.module 7 | 8 | /** 9 | * Created by herisulistiyanto on 07/11/19. 10 | * KjokenKoddinger 11 | */ 12 | 13 | object MovieDetailModule: BaseViewModelModuleProvider { 14 | 15 | override fun loadModules() = lazyLoadModule 16 | 17 | private val lazyLoadModule by lazy { loadKoinModules(viewModelModule) } 18 | 19 | private val viewModelModule = module { 20 | viewModel { MovieDetailViewModel(get()) } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/api/AuthInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.api 2 | 3 | import com.andro.indie.school.BuildConfig 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | 7 | /** 8 | * Created by herisulistiyanto on 2019-10-31. 9 | * KjokenKoddinger 10 | */ 11 | 12 | class AuthInterceptor: Interceptor { 13 | 14 | override fun intercept(chain: Interceptor.Chain): Response { 15 | val original = chain.request() 16 | val interceptUrl = original.url.newBuilder() 17 | .addQueryParameter("api_key", BuildConfig.API_KEY) 18 | .build() 19 | val newReq = original.newBuilder().url(interceptUrl).build() 20 | return chain.proceed(newReq) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_movie_detail.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/di/DepsModuleProvider.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.di 2 | 3 | import android.content.ContentResolver 4 | import org.koin.android.ext.koin.androidContext 5 | import org.koin.core.module.Module 6 | import org.koin.dsl.module 7 | 8 | /** 9 | * Created by herisulistiyanto on 07/11/19. 10 | * KjokenKoddinger 11 | */ 12 | 13 | object DepsModuleProvider { 14 | 15 | private val appModule = module { 16 | single { androidContext().contentResolver } 17 | } 18 | 19 | val modules: List 20 | get() { 21 | return ArrayList().apply { 22 | add(appModule) 23 | addAll(NetworkModules.modules) 24 | addAll(DataSourceModules.modules) 25 | } 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/andro/indie/school/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.andro.indie.school", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/RetrofitCoroutineApp.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school 2 | 3 | import android.app.Application 4 | import com.andro.indie.school.common.di.DepsModuleProvider 5 | import org.koin.android.ext.koin.androidContext 6 | import org.koin.android.ext.koin.androidLogger 7 | import org.koin.core.context.startKoin 8 | import timber.log.Timber 9 | 10 | /** 11 | * Created by herisulistiyanto on 01/11/19. 12 | * KjokenKoddinger 13 | */ 14 | 15 | class RetrofitCoroutineApp: Application() { 16 | 17 | override fun onCreate() { 18 | super.onCreate() 19 | startKoin { 20 | androidLogger() 21 | androidContext(this@RetrofitCoroutineApp) 22 | modules(DepsModuleProvider.modules) 23 | } 24 | 25 | if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/layout_common_error.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/base/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.base 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.lifecycle.LiveData 6 | import androidx.lifecycle.observe 7 | 8 | /** 9 | * Created by herisulistiyanto on 01/11/19. 10 | * KjokenKoddinger 11 | */ 12 | 13 | abstract class BaseActivity : AppCompatActivity() { 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | loadModules() 18 | onViewReady(savedInstanceState) 19 | } 20 | 21 | abstract fun onViewReady(savedInstanceState: Bundle?) 22 | 23 | abstract fun loadModules() 24 | 25 | protected fun LiveData.onResult(action: (T) -> Unit) { 26 | observe(this@BaseActivity) { data -> 27 | data?.let(action) 28 | } 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 15 | 16 | 20 | 21 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/data/model/vo/MovieItem.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.data.model.vo 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | 6 | /** 7 | * Created by herisulistiyanto on 2019-11-05. 8 | * KjokenKoddinger 9 | */ 10 | 11 | data class MovieItem( 12 | @SerializedName("backdrop_path") 13 | val backdropPath: String? = "", 14 | @SerializedName("genre_ids") 15 | val genreIds: List? = listOf(), 16 | @SerializedName("id") 17 | val id: Int? = 0, 18 | @SerializedName("overview") 19 | val overview: String? = "", 20 | @SerializedName("popularity") 21 | val popularity: Double? = 0.0, 22 | @SerializedName("poster_path") 23 | val posterPath: String? = "", 24 | @SerializedName("release_date") 25 | val releaseDate: String? = "", 26 | @SerializedName("title") 27 | val title: String? = "", 28 | @SerializedName("vote_average") 29 | val voteAverage: Double? = 0.0 30 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/base/DiffCallback.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.base 2 | 3 | import androidx.recyclerview.widget.DiffUtil 4 | 5 | /** 6 | * Created by herisulistiyanto on 2019-11-05. 7 | * KjokenKoddinger 8 | */ 9 | 10 | class DiffCallback : DiffUtil.Callback() { 11 | 12 | private var oldList: List = emptyList() 13 | private var newList: List = emptyList() 14 | 15 | fun setList(oldList: List, newList: List) { 16 | this.oldList = oldList 17 | this.newList = newList 18 | } 19 | 20 | override fun getOldListSize(): Int = oldList.size 21 | 22 | override fun getNewListSize(): Int = newList.size 23 | 24 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 25 | return oldList[oldItemPosition] == newList[newItemPosition] 26 | } 27 | 28 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 29 | return oldList[oldItemPosition] == newList[newItemPosition] 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2G 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_dashboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/data/source/remote/MovieRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.data.source.remote 2 | 3 | import androidx.lifecycle.LiveData 4 | import com.andro.indie.school.api.MovieApiService 5 | import com.andro.indie.school.common.base.BaseDataSource 6 | import com.andro.indie.school.common.base.ResponseResult 7 | import com.andro.indie.school.common.base.ResponseWrapper 8 | import com.andro.indie.school.common.base.resultLiveData 9 | import com.andro.indie.school.data.model.response.MovieDetailResponse 10 | import com.andro.indie.school.data.model.response.MovieResponse 11 | import kotlinx.coroutines.CoroutineScope 12 | 13 | /** 14 | * Created by herisulistiyanto on 01/11/19. 15 | * KjokenKoddinger 16 | */ 17 | 18 | class MovieRemoteDataSource(private val movieApiService: MovieApiService) : BaseDataSource() { 19 | 20 | fun discoverAllMovies(scope: CoroutineScope): LiveData>> = resultLiveData(scope) { 21 | getResult { 22 | movieApiService.discoverAllMovies() 23 | } 24 | } 25 | 26 | fun getMovieDetails(movieId: Int, scope: CoroutineScope): LiveData>> = resultLiveData(scope) { 27 | getResult { 28 | movieApiService.getMovieDetail(movieId) 29 | } 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/base/BaseDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.base 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.liveData 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | import retrofit2.Response 9 | import timber.log.Timber 10 | 11 | /** 12 | * Created by herisulistiyanto on 01/11/19. 13 | * KjokenKoddinger 14 | */ 15 | 16 | abstract class BaseDataSource { 17 | 18 | protected suspend fun getResult(call: suspend () -> Response): ResponseResult> { 19 | try { 20 | val response = call() 21 | if (response.isSuccessful) { 22 | val body = response.body() 23 | if (null != body) return ResponseResult.Success(ResponseWrapper(body, null)) 24 | } 25 | return error("${response.code()} ${response.message()}") 26 | } catch (e: Exception) { 27 | return error(e.message ?: e.toString()) 28 | } 29 | } 30 | 31 | private fun error(msg: String): ResponseResult> { 32 | Timber.e(msg) 33 | return ResponseResult.Error(ResponseWrapper(null, msg)) 34 | } 35 | 36 | } 37 | 38 | fun resultLiveData(scope: CoroutineScope, call: suspend () -> ResponseResult): LiveData> { 39 | return liveData(scope.coroutineContext) { 40 | emit(ResponseResult.Loading) 41 | 42 | withContext(Dispatchers.IO) { 43 | emit(call.invoke()) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/extension/CommonExt.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.extension 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.widget.ImageView 6 | import coil.api.load 7 | import com.andro.indie.school.R 8 | import com.andro.indie.school.common.custom.IntentHelper 9 | import java.text.SimpleDateFormat 10 | import java.util.Locale 11 | 12 | /** 13 | * Created by herisulistiyanto on 2019-11-05. 14 | * KjokenKoddinger 15 | */ 16 | 17 | fun String?.convertDate(inputFormat: String = "yyyy-MM-dd", 18 | outputFormat: String = "dd MMMM yyyy", 19 | localeId: Locale = Locale.getDefault()): String { 20 | if (!this.isNullOrEmpty()) { 21 | val dateFormat = SimpleDateFormat(inputFormat, localeId) 22 | val requiredFormat = SimpleDateFormat(outputFormat, localeId) 23 | try { 24 | val date = dateFormat.parse(this) 25 | date?.let { 26 | return requiredFormat.format(it) 27 | } 28 | } catch (e: Exception) { 29 | e.printStackTrace() 30 | } 31 | } 32 | return "" 33 | } 34 | 35 | fun ImageView.loadImageWithUrl(partUrl: String?, isLarge: Boolean = false) { 36 | val baseUrl = when { 37 | isLarge -> "https://image.tmdb.org/t/p/w500/" 38 | else -> "https://image.tmdb.org/t/p/w300/" 39 | } 40 | val fullUrl = baseUrl + partUrl.orEmpty() 41 | this.load(fullUrl) { 42 | crossfade(false) 43 | placeholder(R.drawable.img_placeholder) 44 | error(R.drawable.img_placeholder) 45 | } 46 | } 47 | 48 | inline fun Context.launchActivity(vararg params: Pair) { 49 | IntentHelper.internalStartActivity(this, T::class.java, params) 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/di/NetworkModules.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.di 2 | 3 | import com.andro.indie.school.BuildConfig 4 | import com.andro.indie.school.api.AuthInterceptor 5 | import com.andro.indie.school.api.MovieApiService 6 | import okhttp3.OkHttpClient 7 | import okhttp3.logging.HttpLoggingInterceptor 8 | import org.koin.core.module.Module 9 | import org.koin.dsl.module 10 | import retrofit2.Retrofit 11 | import retrofit2.converter.gson.GsonConverterFactory 12 | import java.util.concurrent.TimeUnit 13 | 14 | /** 15 | * Created by herisulistiyanto on 07/11/19. 16 | * KjokenKoddinger 17 | */ 18 | 19 | object NetworkModules: BaseModuleProvider { 20 | 21 | override val modules: List 22 | get() = listOf( 23 | retrofitModule, 24 | webServiceModule 25 | ) 26 | 27 | private val webServiceModule = module { 28 | single { get().create(MovieApiService::class.java) } 29 | } 30 | 31 | private val retrofitModule = module { 32 | single { provideAuthInterceptor() } 33 | single { provideOkHttpClient(get()) } 34 | single { provideRetrofit(get(), BuildConfig.BASE_URL) } 35 | } 36 | 37 | private fun provideAuthInterceptor(): AuthInterceptor = AuthInterceptor() 38 | 39 | private fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { 40 | val logging = HttpLoggingInterceptor().apply { 41 | level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else 42 | HttpLoggingInterceptor.Level.NONE 43 | } 44 | 45 | return OkHttpClient.Builder() 46 | .addInterceptor(authInterceptor) 47 | .addInterceptor(logging) 48 | .connectTimeout(30L, TimeUnit.SECONDS) 49 | .writeTimeout(30L, TimeUnit.SECONDS) 50 | .readTimeout(30L, TimeUnit.SECONDS) 51 | .build() 52 | } 53 | 54 | private fun provideRetrofit(okHttpClient: OkHttpClient, baseUrl: String): Retrofit { 55 | return Retrofit.Builder() 56 | .client(okHttpClient) 57 | .baseUrl(baseUrl) 58 | .addConverterFactory(GsonConverterFactory.create()) 59 | .build() 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/data/model/response/MovieResponse.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.data.model.response 2 | 3 | import com.andro.indie.school.data.model.vo.MovieItem 4 | import com.google.gson.annotations.SerializedName 5 | 6 | 7 | /** 8 | * Created by herisulistiyanto on 01/11/19. 9 | * KjokenKoddinger 10 | */ 11 | 12 | data class MovieResponse( 13 | @SerializedName("page") 14 | val page: Int? = 0, 15 | @SerializedName("results") 16 | val results: List? = listOf(), 17 | @SerializedName("total_pages") 18 | val totalPages: Int? = 0, 19 | @SerializedName("total_results") 20 | val totalResults: Int? = 0 21 | ) { 22 | data class Result( 23 | @SerializedName("adult") 24 | val adult: Boolean? = false, 25 | @SerializedName("backdrop_path") 26 | val backdropPath: String? = "", 27 | @SerializedName("genre_ids") 28 | val genreIds: List? = listOf(), 29 | @SerializedName("id") 30 | val id: Int? = 0, 31 | @SerializedName("original_language") 32 | val originalLanguage: String? = "", 33 | @SerializedName("original_title") 34 | val originalTitle: String? = "", 35 | @SerializedName("overview") 36 | val overview: String? = "", 37 | @SerializedName("popularity") 38 | val popularity: Double? = 0.0, 39 | @SerializedName("poster_path") 40 | val posterPath: String? = "", 41 | @SerializedName("release_date") 42 | val releaseDate: String? = "", 43 | @SerializedName("title") 44 | val title: String? = "", 45 | @SerializedName("video") 46 | val video: Boolean? = false, 47 | @SerializedName("vote_average") 48 | val voteAverage: Double? = 0.0, 49 | @SerializedName("vote_count") 50 | val voteCount: Int? = 0 51 | ) 52 | 53 | fun mapToMovieItemList(): List { 54 | return results.orEmpty().map { 55 | MovieItem( 56 | it.backdropPath, 57 | it.genreIds, 58 | it.id, 59 | it.overview, 60 | it.popularity, 61 | it.posterPath, 62 | it.releaseDate, 63 | it.title, 64 | it.voteAverage) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /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 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 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 Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/ui/dashboard/DashboardActivity.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.ui.dashboard 2 | 3 | import android.os.Bundle 4 | import androidx.recyclerview.widget.LinearLayoutManager 5 | import com.andro.indie.school.R 6 | import com.andro.indie.school.common.base.BaseActivity 7 | import com.andro.indie.school.common.base.ResponseResult 8 | import com.andro.indie.school.common.extension.launchActivity 9 | import com.andro.indie.school.ui.detail.MovieDetailActivity 10 | import kotlinx.android.synthetic.main.activity_dashboard.* 11 | import kotlinx.android.synthetic.main.layout_common_error.* 12 | import org.koin.androidx.viewmodel.ext.android.viewModel 13 | 14 | class DashboardActivity : BaseActivity() { 15 | 16 | private val dashboardViewModel by viewModel() 17 | private val movieAdapter by lazy { 18 | DashboardMovieAdapter { 19 | launchActivity( 20 | MovieDetailActivity.ExtraKey.EXTRA_MOVIE_ID to it.first, 21 | MovieDetailActivity.ExtraKey.EXTRA_MOVIE_TITLE to it.second 22 | ) 23 | } 24 | } 25 | 26 | object DashboardStateView { 27 | const val STATE_LOADING = 0 28 | const val STATE_CONTENT = STATE_LOADING + 1 29 | const val STATE_ERROR = STATE_CONTENT + 1 30 | } 31 | 32 | override fun onViewReady(savedInstanceState: Bundle?) { 33 | setContentView(R.layout.activity_dashboard) 34 | initDisplay() 35 | 36 | dashboardViewModel.discoverAllMovies().onResult { 37 | vfDashboardContent.displayedChild = when(it) { 38 | is ResponseResult.Success -> { 39 | val data = it.result.data?.mapToMovieItemList() 40 | movieAdapter.setListMovieData(data) 41 | DashboardStateView.STATE_CONTENT 42 | } 43 | is ResponseResult.Loading -> { 44 | DashboardStateView.STATE_LOADING 45 | } 46 | is ResponseResult.Error -> { 47 | tvCommonError.text = it.msg.errorMsg.orEmpty() 48 | DashboardStateView.STATE_ERROR 49 | } 50 | } 51 | } 52 | } 53 | 54 | override fun loadModules() { 55 | DashboardModule.loadModules() 56 | } 57 | 58 | private fun initDisplay() { 59 | with(rvDashboardMovie) { 60 | layoutManager = LinearLayoutManager(this@DashboardActivity) 61 | adapter = movieAdapter 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/ui/dashboard/DashboardMovieAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.ui.dashboard 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.andro.indie.school.R 9 | import com.andro.indie.school.common.base.DiffCallback 10 | import com.andro.indie.school.common.extension.convertDate 11 | import com.andro.indie.school.common.extension.loadImageWithUrl 12 | import com.andro.indie.school.data.model.vo.MovieItem 13 | import kotlinx.android.synthetic.main.item_movie.view.* 14 | 15 | /** 16 | * Created by herisulistiyanto on 2019-11-05. 17 | * KjokenKoddinger 18 | */ 19 | 20 | class DashboardMovieAdapter( 21 | private val diffCallback: DiffCallback = DiffCallback(), 22 | private val listener: (Pair) -> Unit 23 | ) : RecyclerView.Adapter() { 24 | 25 | private val movieList = mutableListOf() 26 | 27 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieItemViewHolder { 28 | val rootView = LayoutInflater.from(parent.context).inflate(R.layout.item_movie, parent, false) 29 | return MovieItemViewHolder(rootView) 30 | } 31 | 32 | override fun onBindViewHolder(holder: MovieItemViewHolder, position: Int) { 33 | holder.onBind(movieList[holder.adapterPosition]) 34 | } 35 | 36 | override fun getItemCount(): Int { 37 | return movieList.size 38 | } 39 | 40 | fun setListMovieData(data: List?) { 41 | calculateDiff(data.orEmpty()) 42 | } 43 | 44 | private fun calculateDiff(newData: List) { 45 | diffCallback.setList(movieList, newData) 46 | val result = DiffUtil.calculateDiff(diffCallback) 47 | with(movieList) { 48 | clear() 49 | addAll(newData) 50 | } 51 | result.dispatchUpdatesTo(this) 52 | } 53 | 54 | inner class MovieItemViewHolder(private val curItemView: View) : RecyclerView.ViewHolder(curItemView) { 55 | 56 | fun onBind(data: MovieItem) { 57 | with(curItemView) { 58 | tvMovieTitle.text = data.title 59 | tvMovieVoteAverage.text = curItemView.context.getString(R.string.text_score, (data.voteAverage 60 | ?: 0).toString()) 61 | tvMovieReleaseDate.text = curItemView.context.getString(R.string.text_release, data.releaseDate.convertDate()) 62 | ivMovieImage.loadImageWithUrl(data.backdropPath) 63 | } 64 | 65 | curItemView.setOnClickListener { 66 | if (adapterPosition != RecyclerView.NO_POSITION) { 67 | data.id?.let { 68 | listener.invoke(it to data.title) 69 | } 70 | } 71 | } 72 | } 73 | 74 | } 75 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_movie_detail_content.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 19 | 20 | 26 | 27 | 33 | 34 | 42 | 43 | 54 | 55 | 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/common/custom/IntentHelper.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.common.custom 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.os.Parcelable 8 | import java.io.Serializable 9 | 10 | /** 11 | * Created by herisulistiyanto on 06/11/19. 12 | * KjokenKoddinger 13 | */ 14 | 15 | object IntentHelper { 16 | 17 | @JvmStatic 18 | fun createIntent(ctx: Context, clazz: Class, params: Array>): Intent { 19 | val intent = Intent(ctx, clazz) 20 | if (params.isNotEmpty()) fillIntentArguments(intent, params) 21 | return intent 22 | } 23 | 24 | @JvmStatic 25 | fun fillIntentArguments(intent: Intent, params: Array>) { 26 | params.forEach { 27 | when (val value = it.second) { 28 | null -> intent.putExtra(it.first, null as Serializable?) 29 | is Int -> intent.putExtra(it.first, value) 30 | is Long -> intent.putExtra(it.first, value) 31 | is CharSequence -> intent.putExtra(it.first, value) 32 | is String -> intent.putExtra(it.first, value) 33 | is Float -> intent.putExtra(it.first, value) 34 | is Double -> intent.putExtra(it.first, value) 35 | is Char -> intent.putExtra(it.first, value) 36 | is Short -> intent.putExtra(it.first, value) 37 | is Boolean -> intent.putExtra(it.first, value) 38 | is Serializable -> intent.putExtra(it.first, value) 39 | is Bundle -> intent.putExtra(it.first, value) 40 | is Parcelable -> intent.putExtra(it.first, value) 41 | is Array<*> -> when { 42 | value.isArrayOf() -> intent.putExtra(it.first, value) 43 | value.isArrayOf() -> intent.putExtra(it.first, value) 44 | value.isArrayOf() -> intent.putExtra(it.first, value) 45 | else -> throw Exception("Intent extra ${it.first} has wrong type ${value.javaClass.name}") 46 | } 47 | is IntArray -> intent.putExtra(it.first, value) 48 | is LongArray -> intent.putExtra(it.first, value) 49 | is FloatArray -> intent.putExtra(it.first, value) 50 | is DoubleArray -> intent.putExtra(it.first, value) 51 | is CharArray -> intent.putExtra(it.first, value) 52 | is ShortArray -> intent.putExtra(it.first, value) 53 | is BooleanArray -> intent.putExtra(it.first, value) 54 | else -> throw Exception("Intent extra ${it.first} has wrong type ${value.javaClass.name}") 55 | } 56 | return@forEach 57 | } 58 | } 59 | 60 | @JvmStatic 61 | fun internalStartActivity( 62 | ctx: Context, 63 | activity: Class, 64 | params: Array> 65 | ) { 66 | ctx.startActivity(createIntent(ctx, activity, params)) 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/data/model/response/MovieDetailResponse.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.data.model.response 2 | import com.google.gson.annotations.SerializedName 3 | 4 | 5 | /** 6 | * Created by herisulistiyanto on 06/11/19. 7 | * KjokenKoddinger 8 | */ 9 | 10 | data class MovieDetailResponse( 11 | @SerializedName("adult") 12 | val adult: Boolean? = false, 13 | @SerializedName("backdrop_path") 14 | val backdropPath: String? = "", 15 | @SerializedName("belongs_to_collection") 16 | val belongsToCollection: Any? = Any(), 17 | @SerializedName("budget") 18 | val budget: Int? = 0, 19 | @SerializedName("genres") 20 | val genres: List? = listOf(), 21 | @SerializedName("homepage") 22 | val homepage: String? = "", 23 | @SerializedName("id") 24 | val id: Int? = 0, 25 | @SerializedName("imdb_id") 26 | val imdbId: String? = "", 27 | @SerializedName("original_language") 28 | val originalLanguage: String? = "", 29 | @SerializedName("original_title") 30 | val originalTitle: String? = "", 31 | @SerializedName("overview") 32 | val overview: String? = "", 33 | @SerializedName("popularity") 34 | val popularity: Double? = 0.0, 35 | @SerializedName("poster_path") 36 | val posterPath: String? = "", 37 | @SerializedName("production_companies") 38 | val productionCompanies: List? = listOf(), 39 | @SerializedName("production_countries") 40 | val productionCountries: List? = listOf(), 41 | @SerializedName("release_date") 42 | val releaseDate: String? = "", 43 | @SerializedName("revenue") 44 | val revenue: Int? = 0, 45 | @SerializedName("runtime") 46 | val runtime: Int? = 0, 47 | @SerializedName("spoken_languages") 48 | val spokenLanguages: List? = listOf(), 49 | @SerializedName("status") 50 | val status: String? = "", 51 | @SerializedName("tagline") 52 | val tagline: String? = "", 53 | @SerializedName("title") 54 | val title: String? = "", 55 | @SerializedName("video") 56 | val video: Boolean? = false, 57 | @SerializedName("vote_average") 58 | val voteAverage: Double? = 0.0, 59 | @SerializedName("vote_count") 60 | val voteCount: Int? = 0 61 | ) { 62 | data class Genre( 63 | @SerializedName("id") 64 | val id: Int? = 0, 65 | @SerializedName("name") 66 | val name: String? = "" 67 | ) 68 | 69 | data class ProductionCompany( 70 | @SerializedName("id") 71 | val id: Int? = 0, 72 | @SerializedName("logo_path") 73 | val logoPath: String? = "", 74 | @SerializedName("name") 75 | val name: String? = "", 76 | @SerializedName("origin_country") 77 | val originCountry: String? = "" 78 | ) 79 | 80 | data class ProductionCountry( 81 | @SerializedName("iso_3166_1") 82 | val iso31661: String? = "", 83 | @SerializedName("name") 84 | val name: String? = "" 85 | ) 86 | 87 | data class SpokenLanguage( 88 | @SerializedName("iso_639_1") 89 | val iso6391: String? = "", 90 | @SerializedName("name") 91 | val name: String? = "" 92 | ) 93 | } -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions 2 | import java.util.Properties 3 | 4 | plugins { 5 | id("com.android.application") 6 | kotlin("android") 7 | kotlin("android.extensions") 8 | kotlin("kapt") 9 | } 10 | 11 | fun readProperties(propertiesFile: File) = Properties().apply { 12 | propertiesFile.inputStream().use { fis -> 13 | load(fis) 14 | } 15 | } 16 | 17 | fun loadProperties(fileName: String, act: (String, String) -> Unit) { 18 | val propFile = File(fileName) 19 | if (propFile.canRead()) { 20 | val data = readProperties(propFile) 21 | data.forEach { key, value -> 22 | act.invoke(key.toString().toUpperCase(), value.toString()) 23 | } 24 | } 25 | 26 | } 27 | 28 | android { 29 | compileSdkVersion(29) 30 | buildToolsVersion("29.0.2") 31 | defaultConfig { 32 | applicationId = "com.andro.indie.school" 33 | minSdkVersion(19) 34 | targetSdkVersion(29) 35 | versionCode = 1 36 | versionName = "1.0" 37 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 38 | 39 | loadProperties("credential.properties") { key, value -> 40 | buildConfigField("String", key, "\"$value\"") 41 | } 42 | 43 | buildConfigField("String", "BASE_URL", "\"https://api.themoviedb.org\"") 44 | } 45 | 46 | buildTypes { 47 | getByName("debug") { 48 | isMinifyEnabled = false 49 | isDebuggable = true 50 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 51 | } 52 | getByName("release") { 53 | isMinifyEnabled = false 54 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 55 | } 56 | } 57 | 58 | compileOptions { 59 | sourceCompatibility = JavaVersion.VERSION_1_8 60 | targetCompatibility = JavaVersion.VERSION_1_8 61 | } 62 | 63 | kotlinOptions { 64 | val options = this as KotlinJvmOptions 65 | options.jvmTarget = "1.8" 66 | } 67 | 68 | } 69 | 70 | fun DependencyHandlerScope.execDeps(vararg deps: List>) { 71 | deps.forEach { collection -> 72 | collection.forEach { 73 | when (it.second) { 74 | is LibType.Library -> implementation(it.first) 75 | is LibType.Compiler -> kapt(it.first) 76 | is LibType.TestLib -> testImplementation(it.first) 77 | is LibType.AndroidTestLib -> androidTestImplementation(it.first) 78 | } 79 | } 80 | } 81 | } 82 | 83 | dependencies { 84 | 85 | implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) 86 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.50") 87 | 88 | execDeps( 89 | Dependencies.androidLibs, 90 | Dependencies.lifecycleLibs, 91 | Dependencies.concurrentLibs, 92 | Dependencies.injectionLibs, 93 | Dependencies.networkingLib, 94 | Dependencies.persistenceLibs, 95 | Dependencies.imageLoaderLibs, 96 | Dependencies.workerLibs, 97 | Dependencies.debuggingLibs, 98 | Dependencies.commonThirdPartyLibs 99 | ) 100 | 101 | testImplementation("junit:junit:4.12") 102 | androidTestImplementation("androidx.test.ext:junit:1.1.1") 103 | androidTestImplementation("androidx.test.espresso:espresso-core:3.2.0") 104 | } 105 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_movie.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 14 | 15 | 26 | 27 | 35 | 36 | 44 | 45 | 55 | 56 | 67 | 68 | 75 | 76 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/andro/indie/school/ui/detail/MovieDetailActivity.kt: -------------------------------------------------------------------------------- 1 | package com.andro.indie.school.ui.detail 2 | 3 | import android.os.Bundle 4 | import android.view.MenuItem 5 | import androidx.lifecycle.lifecycleScope 6 | import com.andro.indie.school.R 7 | import com.andro.indie.school.common.base.BaseActivity 8 | import com.andro.indie.school.common.base.ResponseResult 9 | import com.andro.indie.school.common.extension.loadImageWithUrl 10 | import com.andro.indie.school.data.model.response.MovieDetailResponse 11 | import com.google.android.material.chip.Chip 12 | import kotlinx.android.synthetic.main.activity_movie_detail.* 13 | import kotlinx.android.synthetic.main.activity_movie_detail_content.* 14 | import kotlinx.android.synthetic.main.layout_common_error.* 15 | import kotlinx.coroutines.launch 16 | import org.koin.androidx.viewmodel.ext.android.viewModel 17 | 18 | /** 19 | * Created by herisulistiyanto on 06/11/19. 20 | * KjokenKoddinger 21 | */ 22 | 23 | class MovieDetailActivity : BaseActivity() { 24 | 25 | private val movieDetailViewModel by viewModel() 26 | 27 | private val movieId by lazy(LazyThreadSafetyMode.NONE) { 28 | intent?.getIntExtra(ExtraKey.EXTRA_MOVIE_ID, 0) ?: 0 29 | } 30 | 31 | private val movieTitle by lazy(LazyThreadSafetyMode.NONE) { 32 | intent?.getStringExtra(ExtraKey.EXTRA_MOVIE_TITLE) 33 | } 34 | 35 | object ExtraKey { 36 | const val EXTRA_MOVIE_ID = "MovieDetailActivity.EXTRA_MOVIE_ID" 37 | const val EXTRA_MOVIE_TITLE = "MovieDetailActivity.EXTRA_MOVIE_TITLE" 38 | } 39 | 40 | object MovieDetailStateView { 41 | const val STATE_LOADING = 0 42 | const val STATE_CONTENT = STATE_LOADING + 1 43 | const val STATE_ERROR = STATE_CONTENT + 1 44 | } 45 | 46 | override fun onViewReady(savedInstanceState: Bundle?) { 47 | setContentView(R.layout.activity_movie_detail) 48 | setupToolbar(movieTitle) 49 | 50 | movieDetailViewModel.fetchDetailMovie(movieId).onResult { 51 | vfMovieDetail.displayedChild = when (it) { 52 | is ResponseResult.Success -> { 53 | it.result.data?.let { response -> 54 | populateDetailDisplay(response) 55 | } 56 | MovieDetailStateView.STATE_CONTENT 57 | } 58 | is ResponseResult.Error -> { 59 | tvCommonError.text = it.msg.errorMsg.orEmpty() 60 | MovieDetailStateView.STATE_ERROR 61 | } 62 | is ResponseResult.Loading -> MovieDetailStateView.STATE_LOADING 63 | } 64 | } 65 | } 66 | 67 | override fun loadModules() { 68 | MovieDetailModule.loadModules() 69 | } 70 | 71 | private fun setupToolbar(movieTitle: String?) { 72 | supportActionBar?.apply { 73 | setDisplayHomeAsUpEnabled(true) 74 | setDisplayShowTitleEnabled(true) 75 | title = movieTitle ?: getString(R.string.text_toolbar_title_movie_detail) 76 | } 77 | } 78 | 79 | private fun populateDetailDisplay(response: MovieDetailResponse) { 80 | ivMovieDetail.loadImageWithUrl(response.backdropPath) 81 | tvDetailOverview.text = response.overview.orEmpty() 82 | 83 | val genres = response.genres.orEmpty() 84 | lifecycleScope.launch { 85 | populateGenre(genres) 86 | } 87 | } 88 | 89 | private fun populateGenre(listGenre: List) { 90 | listGenre.forEach { 91 | val chip = Chip(this@MovieDetailActivity).apply { 92 | text = it.name.orEmpty() 93 | isClickable = false 94 | } 95 | chipGroupGenre.addView(chip) 96 | } 97 | } 98 | 99 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 100 | return when (item.itemId) { 101 | android.R.id.home -> { 102 | onBackPressed() 103 | true 104 | } 105 | else -> super.onOptionsItemSelected(item) 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/layout_item_movie_load.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 15 | 16 | 25 | 26 | 34 | 35 | 44 | 45 | 53 | 54 | 55 | 56 | 66 | 67 | 75 | 76 | 86 | 87 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /app/src/main/res/layout/layout_movie_detail_load.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 20 | 21 | 27 | 28 | 34 | 35 | 43 | 44 | 52 | 53 | 61 | 62 | 71 | 72 | 81 | 82 | 91 | 92 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------