├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── drawable
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_splash.png
│ │ │ │ ├── splash_background.xml
│ │ │ │ ├── favorite_gradient.xml
│ │ │ │ ├── favorite.xml
│ │ │ │ └── favorite_border.xml
│ │ │ ├── values
│ │ │ │ ├── dimen.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── strings.xml
│ │ │ │ └── styles.xml
│ │ │ ├── layout
│ │ │ │ ├── activity_splash.xml
│ │ │ │ ├── toolbar.xml
│ │ │ │ ├── loading_list_item.xml
│ │ │ │ ├── show_list_item.xml
│ │ │ │ ├── activity_home.xml
│ │ │ │ ├── activity_favorite_shows.xml
│ │ │ │ ├── network_failure_list_item.xml
│ │ │ │ ├── activity_all_shows.xml
│ │ │ │ └── all_show_list_item.xml
│ │ │ └── menu
│ │ │ │ └── home_menu.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── android
│ │ │ │ └── tvflix
│ │ │ │ ├── analytics
│ │ │ │ ├── Analytics.kt
│ │ │ │ ├── Event.kt
│ │ │ │ └── FirebaseAnalyticsHandler.kt
│ │ │ │ ├── splash
│ │ │ │ ├── SplashViewState.kt
│ │ │ │ ├── SplashActivity.kt
│ │ │ │ └── SplashViewModel.kt
│ │ │ │ ├── di
│ │ │ │ ├── DaggerTypes.kt
│ │ │ │ ├── AppBindingModule.kt
│ │ │ │ ├── CoroutinesQualifiers.kt
│ │ │ │ ├── AppModule.kt
│ │ │ │ └── CoroutinesDispatcherModule.kt
│ │ │ │ ├── config
│ │ │ │ ├── FeatureFlagAnnotations.kt
│ │ │ │ ├── ConfigDefaults.kt
│ │ │ │ ├── FeatureFlagModule.kt
│ │ │ │ └── AppConfig.kt
│ │ │ │ ├── network
│ │ │ │ ├── RetrofitExt.kt
│ │ │ │ ├── home
│ │ │ │ │ ├── Episode.kt
│ │ │ │ │ └── Show.kt
│ │ │ │ ├── TvFlixApi.kt
│ │ │ │ ├── TvFlixApiModule.kt
│ │ │ │ ├── ApiInterceptor.kt
│ │ │ │ ├── InterceptorModule.kt
│ │ │ │ └── NetworkModule.kt
│ │ │ │ ├── db
│ │ │ │ ├── favouriteshow
│ │ │ │ │ ├── FavoriteShow.kt
│ │ │ │ │ └── ShowDao.kt
│ │ │ │ ├── TvFlixDatabase.kt
│ │ │ │ └── TvFlixDbModule.kt
│ │ │ │ ├── home
│ │ │ │ ├── HomeViewState.kt
│ │ │ │ ├── HomeViewData.kt
│ │ │ │ ├── ShowDiffUtilCallback.kt
│ │ │ │ ├── ShowsAdapter.kt
│ │ │ │ ├── HomeActivity.kt
│ │ │ │ └── HomeViewModel.kt
│ │ │ │ ├── model
│ │ │ │ └── respositores
│ │ │ │ │ └── SchedulesRepository.kt
│ │ │ │ ├── utils
│ │ │ │ ├── Extensions.kt
│ │ │ │ └── GridItemDecoration.kt
│ │ │ │ ├── shows
│ │ │ │ ├── ShowDiffUtilItemCallback.kt
│ │ │ │ ├── ShowsLoadStateAdapter.kt
│ │ │ │ ├── ShowsViewModel.kt
│ │ │ │ ├── ShowsRepository.kt
│ │ │ │ ├── ShowsStateViewHolder.kt
│ │ │ │ ├── ShowsPagingSource.kt
│ │ │ │ ├── ShowsPagedAdapter.kt
│ │ │ │ └── AllShowsActivity.kt
│ │ │ │ ├── favorite
│ │ │ │ ├── FavoriteShowState.kt
│ │ │ │ ├── FavoriteShowsAdapter.kt
│ │ │ │ ├── FavoriteShowsRepository.kt
│ │ │ │ ├── FavoriteShowsViewModel.kt
│ │ │ │ └── FavoriteShowsActivity.kt
│ │ │ │ ├── TvFlixApplication.kt
│ │ │ │ └── domain
│ │ │ │ ├── AddToFavoritesUseCase.kt
│ │ │ │ ├── RemoveFromFavoritesUseCase.kt
│ │ │ │ ├── GetFavoriteShowsUseCase.kt
│ │ │ │ ├── GetSchedulesUseCase.kt
│ │ │ │ └── SuspendUseCase.kt
│ │ └── AndroidManifest.xml
│ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── android
│ │ │ └── tvflix
│ │ │ ├── utils
│ │ │ ├── MatcherUtils.kt
│ │ │ └── ViewActionUtils.kt
│ │ │ ├── shows
│ │ │ ├── AllShowsRobot.kt
│ │ │ └── AllShowsTest.kt
│ │ │ ├── idlingresource
│ │ │ └── LoadingIdlingResource.kt
│ │ │ └── home
│ │ │ ├── HomeTest.kt
│ │ │ └── HomeRobot.kt
│ └── test
│ │ └── java
│ │ └── com
│ │ └── android
│ │ └── tvflix
│ │ ├── utils
│ │ ├── MainCoroutineRule.kt
│ │ └── TestUtil.kt
│ │ ├── favorites
│ │ └── FavoritesRepositoryTest.kt
│ │ └── home
│ │ └── HomeViewModelTest.kt
├── google-services.json
├── proguard-rules.pro
└── build.gradle.kts
├── _config.yml
├── settings.gradle.kts
├── release-notes.txt
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .github
├── workflows
│ └── android.yml
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── tools
├── pmd.gradle
├── checkstyle.gradle
├── rules-findbugs.xml
├── rules-pmd.xml
└── rules-checkstyle.xml
├── CONTRIBUTING.md
├── .gitignore
├── LICENSE
├── gradle.properties
├── gradlew.bat
├── CODE_OF_CONDUCT.md
├── README.md
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-cayman
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | include(":app")
2 | rootProject.name = "TvFlix"
3 |
--------------------------------------------------------------------------------
/release-notes.txt:
--------------------------------------------------------------------------------
1 | v2.2.2
2 | Refactoring to move towards clean code architecture
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reactivedroid/TvFlix/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reactivedroid/TvFlix/HEAD/app/src/main/res/drawable/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reactivedroid/TvFlix/HEAD/app/src/main/res/drawable/ic_splash.png
--------------------------------------------------------------------------------
/app/src/main/res/values/dimen.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 4dp
4 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/analytics/Analytics.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.analytics
2 |
3 | interface Analytics {
4 | fun sendEvent(event: Event)
5 | fun setUserId(userId: String)
6 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/splash/SplashViewState.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.splash
2 |
3 | sealed class SplashViewState {
4 | object Idle : SplashViewState()
5 | object NavigateToHome : SplashViewState()
6 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/di/DaggerTypes.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.di
2 |
3 | /**
4 | * Sugar over multibindings that helps with Kotlin wildcards.
5 | */
6 | typealias DaggerSet = @JvmSuppressWildcards Set
7 | typealias DaggerMap = @JvmSuppressWildcards Map
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Jan 08 19:26:01 IST 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/config/FeatureFlagAnnotations.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.config
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Qualifier
6 | @Target(
7 | AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER,
8 | AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.FIELD
9 | )
10 | annotation class FavoritesFeatureFlag
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/config/ConfigDefaults.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.config
2 |
3 | object ConfigDefaults {
4 | fun getDefaultValues(): Map {
5 | return hashMapOf(ConfigKeys.FEATURE_FAVORITES_ENABLE to false)
6 | }
7 |
8 | object ConfigKeys {
9 | const val FEATURE_FAVORITES_ENABLE = "feature_favorites_enable"
10 | }
11 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/splash_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | -
7 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/RetrofitExt.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network
2 |
3 | import okhttp3.Call
4 | import okhttp3.Request
5 | import retrofit2.Retrofit
6 |
7 | @PublishedApi
8 | internal inline fun Retrofit.Builder.callFactory(
9 | crossinline body: (Request) -> Call
10 | ) = callFactory(object : Call.Factory {
11 | override fun newCall(request: Request): Call = body(request)
12 | })
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_splash.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 | on:
3 | push:
4 | branches:
5 | - master
6 | pull_request:
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v1
12 | - name: set up JDK 1.11
13 | uses: actions/setup-java@v1
14 | with:
15 | java-version: 1.11
16 | - name: Build with Gradle
17 | run: ./gradlew assembleDebug
18 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/toolbar.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/favorite_gradient.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/analytics/Event.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.analytics
2 |
3 | data class Event(val eventName: String, val eventParams: Map)
4 |
5 | object EventParams {
6 | const val CONTENT_ID = "content_id"
7 | const val CONTENT_TITLE = "content_title"
8 | const val IS_FAVORITE_MARKED = "is_favorite_marked"
9 | const val CLICK_CONTEXT = "CLICK_CONTEXT"
10 | }
11 |
12 | object EventNames {
13 | const val CLICK = "click"
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/db/favouriteshow/FavoriteShow.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.db.favouriteshow
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity(tableName = "favourite_shows")
7 | data class FavoriteShow(
8 | @PrimaryKey
9 | val id: Long,
10 | val name: String,
11 | val premiered: String?,
12 | val imageUrl: String?,
13 | var summary: String?,
14 | var rating: String?,
15 | var runtime: Int?
16 | )
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/home/HomeViewState.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import com.android.tvflix.network.home.Show
4 |
5 | sealed class HomeViewState {
6 | data class NetworkError(val message: String?) : HomeViewState()
7 | object Loading : HomeViewState()
8 | data class Success(val homeViewData: HomeViewData) : HomeViewState()
9 | data class AddedToFavorites(val show: Show) : HomeViewState()
10 | data class RemovedFromFavorites(val show: Show) : HomeViewState()
11 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/model/respositores/SchedulesRepository.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.model.respositores
2 |
3 | import com.android.tvflix.network.TvFlixApi
4 | import com.android.tvflix.network.home.Episode
5 | import javax.inject.Inject
6 |
7 | class SchedulesRepository
8 | @Inject
9 | constructor(private val tvFlixApi: TvFlixApi) {
10 | suspend fun getSchedule(country: String, currentDate: String): List {
11 | return tvFlixApi.getCurrentSchedule(country, currentDate)
12 | }
13 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/utils/Extensions.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.utils
2 |
3 | import com.android.tvflix.db.favouriteshow.FavoriteShow
4 | import com.android.tvflix.network.home.Show
5 |
6 | fun Show.toFavoriteShow(): FavoriteShow {
7 | return FavoriteShow(
8 | id = id,
9 | name = name,
10 | premiered = premiered,
11 | imageUrl = image!!["original"],
12 | summary = summary,
13 | rating = rating!!["average"],
14 | runtime = runtime!!
15 | )
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/config/FeatureFlagModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.config
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 |
8 | @InstallIn(SingletonComponent::class)
9 | @Module
10 | object FeatureFlagModule {
11 | @FavoritesFeatureFlag
12 | @Provides
13 | fun provideFavoritesFeatureFlag(appConfig: AppConfig): Boolean {
14 | return appConfig.getBoolean(ConfigDefaults.ConfigKeys.FEATURE_FAVORITES_ENABLE)
15 | }
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/home/Episode.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network.home
2 |
3 | import android.os.Parcelable
4 | import com.squareup.moshi.JsonClass
5 | import kotlinx.parcelize.Parcelize
6 |
7 | @Parcelize
8 | @JsonClass(generateAdapter = true)
9 | data class Episode(
10 | val show: Show,
11 | val id: Long,
12 | val url: String?,
13 | val name: String?,
14 | val season: Int?,
15 | val number: Int?,
16 | val airdate: String?,
17 | val airtime: String?,
18 | val runtime: Int?
19 | ) : Parcelable
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/di/AppBindingModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.di
2 |
3 | import com.android.tvflix.analytics.Analytics
4 | import com.android.tvflix.analytics.FirebaseAnalyticsHandler
5 | import dagger.Binds
6 | import dagger.Module
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 |
10 | @InstallIn(SingletonComponent::class)
11 | @Module
12 | abstract class AppBindingModule {
13 | @Binds
14 | abstract fun bindFirebaseAnalytics(firebaseAnalyticsHandler: FirebaseAnalyticsHandler): Analytics
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/ShowDiffUtilItemCallback.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import androidx.recyclerview.widget.DiffUtil
4 | import com.android.tvflix.network.home.Show
5 |
6 | class ShowDiffUtilItemCallback : DiffUtil.ItemCallback() {
7 | override fun areItemsTheSame(oldItem: Show, newItem: Show): Boolean {
8 | return oldItem.id == newItem.id
9 | }
10 |
11 | override fun areContentsTheSame(oldItem: Show, newItem: Show): Boolean {
12 | return oldItem.name == newItem.name
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/db/TvFlixDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.db
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import com.android.tvflix.db.favouriteshow.FavoriteShow
6 | import com.android.tvflix.db.favouriteshow.ShowDao
7 |
8 | @Database(entities = [FavoriteShow::class], version = 3, exportSchema = false)
9 | abstract class TvFlixDatabase : RoomDatabase() {
10 |
11 | abstract fun showDao(): ShowDao
12 |
13 | companion object {
14 | const val DATABASE_NAME = "tvflix.db"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/di/CoroutinesQualifiers.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.di
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Retention(AnnotationRetention.BINARY)
6 | @Qualifier
7 | internal annotation class DefaultDispatcher
8 |
9 | @Retention(AnnotationRetention.BINARY)
10 | @Qualifier
11 | internal annotation class IoDispatcher
12 |
13 | @Retention(AnnotationRetention.BINARY)
14 | @Qualifier
15 | internal annotation class MainDispatcher
16 |
17 | @Retention(AnnotationRetention.BINARY)
18 | @Qualifier
19 | internal annotation class MainImmediateDispatcher
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/TvFlixApi.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network
2 |
3 | import com.android.tvflix.network.home.Episode
4 | import com.android.tvflix.network.home.Show
5 | import retrofit2.http.GET
6 | import retrofit2.http.Query
7 |
8 | interface TvFlixApi {
9 | @GET("/schedule")
10 | suspend fun getCurrentSchedule(
11 | @Query("country") country: String,
12 | @Query("date") date: String
13 | ): List
14 |
15 | @GET("/shows")
16 | suspend fun getShows(@Query("page") pageNumber: Int): List
17 | }
18 |
--------------------------------------------------------------------------------
/tools/pmd.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'pmd'
2 |
3 | task pmd(type: Pmd) {
4 | description 'Identifying potential problems mainly dead code, duplicated code, cyclomatic complexity and overcomplicated expressions'
5 | group 'verification'
6 | ruleSetFiles = files("$project.rootDir/tools/rules-pmd.xml")
7 | source = fileTree('src/main/java')
8 | include '**/*.java'
9 | exclude '**/gen/**'
10 |
11 | reports {
12 | xml.enabled = false
13 | html.enabled = true
14 | html.destination file("$project.buildDir/outputs/pmd/pmd.html")
15 | }
16 | }
--------------------------------------------------------------------------------
/tools/checkstyle.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'checkstyle'
2 |
3 | task checkstyle(type: Checkstyle) {
4 | description 'Check code standard'
5 | group 'verification'
6 | configFile file("${project.rootDir}/tools/rules-checkstyle.xml")
7 | source fileTree('src/main/java')
8 | include '**/*.java'
9 | exclude '**/gen/**'
10 |
11 | classpath = files()
12 | showViolations true
13 |
14 | reports {
15 | xml.enabled = true
16 | html.enabled = true
17 | html.destination file("$project.buildDir/outputs/checkstyle/checkstyle.html")
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/TvFlixApiModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import retrofit2.Retrofit
8 | import javax.inject.Singleton
9 |
10 | @InstallIn(SingletonComponent::class)
11 | @Module
12 | object TvFlixApiModule {
13 | @Provides
14 | @Singleton
15 | fun provideTvFlixApi(
16 | retrofit: Retrofit
17 | ): TvFlixApi {
18 | return retrofit.create(TvFlixApi::class.java)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.di
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import javax.inject.Singleton
10 |
11 | @InstallIn(SingletonComponent::class)
12 | @Module(includes = [AppBindingModule::class])
13 | object AppModule {
14 | @Provides
15 | @Singleton
16 | fun provideContext(application: Application): Context {
17 | return application
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/home_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/favorite.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/loading_list_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/favorite/FavoriteShowState.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.favorite
2 |
3 | import com.android.tvflix.db.favouriteshow.FavoriteShow
4 |
5 | sealed class FavoriteShowState {
6 | data class AllFavorites(val favoriteShows: List) : FavoriteShowState()
7 | data class Error(val message: String) : FavoriteShowState()
8 | object Loading : FavoriteShowState()
9 | object Empty : FavoriteShowState()
10 | data class AddedToFavorites(val show: FavoriteShow) : FavoriteShowState()
11 | data class RemovedFromFavorites(val show: FavoriteShow) : FavoriteShowState()
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #ff393939
4 | #1e1e1e
5 | #DCC213
6 | #FFFFFF
7 | #ff303030
8 | #ff212121
9 | #387eed
10 | #cacdd1
11 | #1f000000
12 | #212121
13 | #E82C0C
14 |
15 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/ShowsLoadStateAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import android.view.ViewGroup
4 | import androidx.paging.LoadState
5 | import androidx.paging.LoadStateAdapter
6 |
7 | class ShowsLoadStateAdapter(private val retry: () -> Unit) :
8 | LoadStateAdapter() {
9 | override fun onBindViewHolder(holder: ShowsStateViewHolder, loadState: LoadState) {
10 | holder.bind(loadState)
11 | }
12 |
13 | override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ShowsStateViewHolder {
14 | return ShowsStateViewHolder.create(parent, retry)
15 | }
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/db/favouriteshow/ShowDao.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.db.favouriteshow
2 |
3 | import androidx.room.*
4 |
5 | @Dao
6 | interface ShowDao {
7 | @Query("SELECT * FROM favourite_shows")
8 | suspend fun allFavouriteShows(): List
9 |
10 | @Insert(onConflict = OnConflictStrategy.REPLACE)
11 | suspend fun insert(favoriteShow: FavoriteShow)
12 |
13 | @Delete
14 | suspend fun remove(favoriteShow: FavoriteShow)
15 |
16 | @Query("SELECT id from favourite_shows")
17 | suspend fun getFavoriteShowIds(): List
18 |
19 | @Query("DELETE FROM favourite_shows")
20 | suspend fun clear()
21 | }
22 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to **TvMaze**
2 |
3 | * *Issues* - If you have a question, or you think you've discovered an issue, then find/file an issue BEFORE starting work to fix/implement it.
4 | * *Enhancement* - If you want to contribute to the repository, go ahead, any kind of patches are encouraged, and may be submitted by forking this project and submitting a pull request. If you have something big in mind, or any architectural change, please raise an issue first to discuss it.
5 |
6 | 👍🎉🚀Please note that any piece of contribution will be cherished and TvMaze will love to have it. At the end, it is all about helping the Android Community to thrive for better development👍🎉🚀
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/home/HomeViewData.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import com.android.tvflix.network.home.Show
4 |
5 | data class HomeViewData(val heading: String, val episodes: List) {
6 | data class EpisodeViewData(
7 | val id: Long,
8 | val showViewData: ShowViewData,
9 | val url: String?,
10 | val name: String?,
11 | val season: Int?,
12 | val number: Int?,
13 | val airdate: String?,
14 | val airtime: String?,
15 | val runtime: Int?
16 | )
17 |
18 | data class ShowViewData(
19 | val show: Show,
20 | val isFavoriteShow: Boolean
21 | )
22 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/TvFlixApplication.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix
2 |
3 | import android.app.Application
4 | import com.android.tvflix.config.AppConfig
5 | import com.google.firebase.FirebaseApp
6 | import dagger.hilt.android.HiltAndroidApp
7 | import timber.log.Timber
8 | import javax.inject.Inject
9 |
10 | @HiltAndroidApp
11 | class TvFlixApplication : Application() {
12 | @Inject
13 | lateinit var appConfig: AppConfig
14 |
15 | override fun onCreate() {
16 | super.onCreate()
17 | FirebaseApp.initializeApp(this)
18 | if (BuildConfig.DEBUG) {
19 | Timber.plant(Timber.DebugTree())
20 | }
21 | appConfig.initialise()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/ShowsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.paging.PagingData
6 | import androidx.paging.cachedIn
7 | import com.android.tvflix.network.home.Show
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import kotlinx.coroutines.flow.Flow
10 | import javax.inject.Inject
11 |
12 | @HiltViewModel
13 | class ShowsViewModel @Inject
14 | constructor(
15 | private val showsRepository: ShowsRepository
16 | ) : ViewModel() {
17 | fun shows(): Flow> {
18 | return showsRepository.getShows()
19 | .cachedIn(viewModelScope)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/domain/AddToFavoritesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.domain
2 |
3 | import com.android.tvflix.db.favouriteshow.FavoriteShow
4 | import com.android.tvflix.di.IoDispatcher
5 | import com.android.tvflix.favorite.FavoriteShowsRepository
6 | import kotlinx.coroutines.CoroutineDispatcher
7 | import javax.inject.Inject
8 |
9 | class AddToFavoritesUseCase @Inject
10 | constructor(
11 | private val favoriteShowsRepository: FavoriteShowsRepository,
12 | @IoDispatcher ioDispatcher: CoroutineDispatcher
13 | ) : SuspendUseCase(ioDispatcher) {
14 | override suspend fun execute(parameters: FavoriteShow) {
15 | favoriteShowsRepository.insertIntoFavorites(parameters)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/favorite_border.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
12 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/android/tvflix/utils/MatcherUtils.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.utils
2 |
3 | import android.view.View
4 | import androidx.recyclerview.widget.RecyclerView
5 | import org.hamcrest.Description
6 | import org.hamcrest.Matcher
7 | import org.hamcrest.TypeSafeMatcher
8 |
9 | object MatcherUtils {
10 | fun withListSize(size: Int): Matcher {
11 | return object : TypeSafeMatcher() {
12 | override fun matchesSafely(view: View): Boolean {
13 | return (view as RecyclerView).adapter!!.itemCount >= size
14 | }
15 |
16 | override fun describeTo(description: Description) {
17 | description.appendText("RecyclerView should have $size items")
18 | }
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.ap_
4 |
5 | # Files for the Dalvik VM
6 | *.dex
7 |
8 | # Java class files
9 | *.class
10 |
11 | # Generated files
12 | bin/
13 | gen/
14 |
15 | # Gradle files
16 | .gradle/
17 | build/
18 |
19 | # Local configuration file (sdk path, etc)
20 | local.properties
21 |
22 | # Proguard folder generated by Eclipse
23 | proguard/
24 |
25 | # Log Files
26 | *.log
27 |
28 | # Android Studio Navigation editor temp files
29 | .navigation/
30 |
31 | # Android Studio captures folder
32 | captures/
33 |
34 | # User-specific configurations
35 | .idea/
36 | *.iml
37 |
38 | # OS-specific files
39 | .DS_Store
40 | .DS_Store?
41 | ._*
42 | .Spotlight-V100
43 | .Trashes
44 | ehthumbs.db
45 | Thumbs.db
46 |
47 | *.hprof
48 | tvflix-*.json
49 | tester-groups.txt
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/domain/RemoveFromFavoritesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.domain
2 |
3 | import com.android.tvflix.db.favouriteshow.FavoriteShow
4 | import com.android.tvflix.di.IoDispatcher
5 | import com.android.tvflix.favorite.FavoriteShowsRepository
6 | import com.android.tvflix.network.home.Show
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import javax.inject.Inject
9 |
10 | class RemoveFromFavoritesUseCase
11 | @Inject
12 | constructor(
13 | private val favoriteShowsRepository: FavoriteShowsRepository,
14 | @IoDispatcher ioDispatcher: CoroutineDispatcher
15 | ) : SuspendUseCase(ioDispatcher) {
16 | override suspend fun execute(parameters: FavoriteShow) {
17 | favoriteShowsRepository.removeFromFavorites(parameters)
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/android/tvflix/shows/AllShowsRobot.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import androidx.test.espresso.Espresso.onView
4 | import androidx.test.espresso.assertion.ViewAssertions.matches
5 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
6 | import androidx.test.espresso.matcher.ViewMatchers.withId
7 | import androidx.test.espresso.matcher.ViewMatchers.withText
8 | import com.android.tvflix.R
9 | import com.android.tvflix.utils.MatcherUtils
10 |
11 | fun launchAllShows(func: AllShowsRobot.() -> Unit) = AllShowsRobot().apply { func() }
12 | class AllShowsRobot {
13 | fun verifyAllShows() {
14 | onView(withText(R.string.shows)).check(matches(isDisplayed()))
15 | onView(withId(R.id.shows))
16 | .check(matches(MatcherUtils.withListSize(1)))
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/ShowsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import androidx.paging.Pager
4 | import androidx.paging.PagingConfig
5 | import androidx.paging.PagingData
6 | import com.android.tvflix.network.home.Show
7 | import kotlinx.coroutines.flow.Flow
8 | import javax.inject.Inject
9 |
10 | class ShowsRepository
11 | @Inject
12 | constructor(private val showsPagingSource: ShowsPagingSource) {
13 | fun getShows(): Flow> {
14 | return Pager(
15 | config = PagingConfig(
16 | pageSize = SHOWS_PAGE_SIZE,
17 | enablePlaceholders = true
18 | ),
19 | pagingSourceFactory = { showsPagingSource }
20 | ).flow
21 | }
22 |
23 | companion object {
24 | const val SHOWS_PAGE_SIZE = 20
25 | }
26 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/domain/GetFavoriteShowsUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.domain
2 |
3 | import com.android.tvflix.db.favouriteshow.FavoriteShow
4 | import com.android.tvflix.di.IoDispatcher
5 | import com.android.tvflix.favorite.FavoriteShowsRepository
6 | import com.android.tvflix.home.HomeViewModel
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import java.text.SimpleDateFormat
9 | import java.util.*
10 | import javax.inject.Inject
11 |
12 | class GetFavoriteShowsUseCase
13 | @Inject
14 | constructor(
15 | private val favoriteShowsRepository: FavoriteShowsRepository,
16 | @IoDispatcher ioDispatcher: CoroutineDispatcher
17 | ) : SuspendUseCase>(ioDispatcher) {
18 | override suspend fun execute(parameters: Unit): List {
19 | return favoriteShowsRepository.allFavoriteShowIds()
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/android/tvflix/utils/ViewActionUtils.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.utils
2 |
3 | import android.view.View
4 | import androidx.test.espresso.UiController
5 | import androidx.test.espresso.ViewAction
6 | import org.hamcrest.Matcher
7 |
8 | object ViewActionUtils {
9 | fun clickChildViewWithId(id: Int): ViewAction {
10 | return object : ViewAction {
11 | override fun getConstraints(): Matcher? {
12 | return null
13 | }
14 |
15 | override fun getDescription(): String {
16 | return "Click on a child view with id $id"
17 | }
18 |
19 | override fun perform(uiController: UiController, view: View) {
20 | val v = view.findViewById(id)
21 | v.performClick()
22 | }
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/ApiInterceptor.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network
2 |
3 | import okhttp3.Interceptor
4 | import okhttp3.Response
5 | import java.io.IOException
6 | import javax.inject.Inject
7 | import javax.inject.Singleton
8 |
9 | @Singleton
10 | class ApiInterceptor @Inject constructor() : Interceptor {
11 |
12 | @Throws(IOException::class)
13 | override fun intercept(chain: Interceptor.Chain): Response {
14 | val original = chain.request()
15 | val originalHttpUrl = original.url
16 |
17 | val url = originalHttpUrl.newBuilder()
18 | .addQueryParameter("api_key", "")
19 | .build()
20 |
21 | val requestBuilder = original.newBuilder()
22 | .url(url)
23 |
24 | val request = requestBuilder.build()
25 | return chain.proceed(request)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | TvFlix
3 | Popular shows in %s tonight
4 | Schedule for %s
5 | Shows
6 | Retry
7 | Rating: %s
8 | Premiered on: %s
9 | Not Rated
10 | \"%s\" removed from favorites
11 | \"%s\" added to favorites
12 | Favorite Shows
13 | Click on the icon \nto mark the show as favorite
14 | Show image
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/InterceptorModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network
2 |
3 | import com.chuckerteam.chucker.api.ChuckerInterceptor
4 | import dagger.Binds
5 | import dagger.Module
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.components.SingletonComponent
8 | import dagger.multibindings.IntoSet
9 | import okhttp3.Interceptor
10 | import okhttp3.logging.HttpLoggingInterceptor
11 |
12 | @InstallIn(SingletonComponent::class)
13 | @Module
14 | abstract class InterceptorModule {
15 | @Binds
16 | @IntoSet
17 | abstract fun bindApiInterceptor(apiInterceptor: ApiInterceptor): Interceptor
18 |
19 | @Binds
20 | @IntoSet
21 | abstract fun bindHttpLoggingInterceptor(httpLoggingInterceptor: HttpLoggingInterceptor): Interceptor
22 |
23 | @Binds
24 | @IntoSet
25 | abstract fun bindChuckerInterceptor(chuckerInterceptor: ChuckerInterceptor): Interceptor
26 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/di/CoroutinesDispatcherModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.Dispatchers
9 |
10 | @InstallIn(SingletonComponent::class)
11 | @Module
12 | internal object CoroutinesDispatcherModule {
13 | @DefaultDispatcher
14 | @Provides
15 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
16 |
17 | @IoDispatcher
18 | @Provides
19 | fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
20 |
21 | @MainDispatcher
22 | @Provides
23 | fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
24 |
25 | @MainImmediateDispatcher
26 | @Provides
27 | fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
28 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/utils/GridItemDecoration.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.utils
2 |
3 | import android.graphics.Rect
4 | import android.view.View
5 | import androidx.recyclerview.widget.RecyclerView
6 |
7 | class GridItemDecoration(private val space: Int, private val noOfColumns: Int) :
8 | RecyclerView.ItemDecoration() {
9 |
10 | override fun getItemOffsets(
11 | outRect: Rect, view: View,
12 | parent: RecyclerView,
13 | state: RecyclerView.State
14 | ) {
15 | outRect.left = space
16 | outRect.right = space
17 | outRect.bottom = space
18 | outRect.top = space
19 | when {
20 | parent.getChildLayoutPosition(view) % noOfColumns == 0 -> {
21 | outRect.left = 0
22 | outRect.right = space
23 | }
24 | parent.getChildLayoutPosition(view) % noOfColumns == noOfColumns - 1 -> {
25 | outRect.left = space
26 | outRect.right = 0
27 | }
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/db/TvFlixDbModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.db
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import com.android.tvflix.db.favouriteshow.ShowDao
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 |
12 | import javax.inject.Singleton
13 |
14 | @InstallIn(SingletonComponent::class)
15 | @Module
16 | object TvFlixDbModule {
17 | @Singleton
18 | @Provides
19 | fun provideTvFlixDb(@ApplicationContext context: Context): TvFlixDatabase {
20 | return Room.databaseBuilder(
21 | context,
22 | TvFlixDatabase::class.java, TvFlixDatabase.DATABASE_NAME
23 | )
24 | .fallbackToDestructiveMigration()
25 | .allowMainThreadQueries()
26 | .build()
27 | }
28 |
29 | @Singleton
30 | @Provides
31 | fun provideShowDao(tvFlixDatabase: TvFlixDatabase): ShowDao {
32 | return tvFlixDatabase.showDao()
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Ashwini Kumar
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/google-services.json:
--------------------------------------------------------------------------------
1 | {
2 | "project_info": {
3 | "project_number": "352317035381",
4 | "project_id": "tvflix-b45cd",
5 | "storage_bucket": "tvflix-b45cd.appspot.com"
6 | },
7 | "client": [
8 | {
9 | "client_info": {
10 | "mobilesdk_app_id": "1:352317035381:android:d68e4d8178aaa27e62793c",
11 | "android_client_info": {
12 | "package_name": "com.android.tvflix"
13 | }
14 | },
15 | "oauth_client": [
16 | {
17 | "client_id": "352317035381-gknu987p38ks98itjcds99pehar6539u.apps.googleusercontent.com",
18 | "client_type": 3
19 | }
20 | ],
21 | "api_key": [
22 | {
23 | "current_key": "AIzaSyDomD9NnOHReomJXzki3akzw9M_JZ--DyU"
24 | }
25 | ],
26 | "services": {
27 | "appinvite_service": {
28 | "other_platform_oauth_client": [
29 | {
30 | "client_id": "352317035381-gknu987p38ks98itjcds99pehar6539u.apps.googleusercontent.com",
31 | "client_type": 3
32 | }
33 | ]
34 | }
35 | }
36 | }
37 | ],
38 | "configuration_version": "1"
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/splash/SplashActivity.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.splash
2 |
3 | import android.os.Bundle
4 | import androidx.activity.viewModels
5 | import androidx.appcompat.app.AppCompatActivity
6 | import androidx.lifecycle.lifecycleScope
7 | import com.android.tvflix.R
8 | import com.android.tvflix.home.HomeActivity
9 | import dagger.hilt.android.AndroidEntryPoint
10 | import kotlinx.coroutines.flow.collect
11 |
12 | @AndroidEntryPoint
13 | class SplashActivity : AppCompatActivity() {
14 | private val splashViewModel: SplashViewModel by viewModels()
15 | override fun onCreate(savedInstanceState: Bundle?) {
16 | super.onCreate(savedInstanceState)
17 | setContentView(R.layout.activity_splash)
18 | splashViewModel.fetchConfig()
19 | lifecycleScope.launchWhenStarted {
20 | splashViewModel.splashViewStateFlow.collect {
21 | when (it) {
22 | is SplashViewState.Idle -> {
23 | // do nothing
24 | }
25 | is SplashViewState.NavigateToHome -> HomeActivity.start(this@SplashActivity)
26 | }
27 | }
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/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=-Xmx4096m -XX\:MaxPermSize\=4096m -XX\:+HeapDumpOnOutOfMemoryError -Dfile.encoding\=UTF-8
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 | # Google Play and Firebase dependencies are migrating to androidX. This is to fix the duplicate class resolution build error to use the androidX libraries
15 | android.useAndroidX=true
16 | # All kapt to use workers
17 | kapt.use.worker.api=true
18 | kapt.incremental.apt=true
19 | android.databinding.incremental=true
20 | kapt.include.compile.classpath=false
21 |
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/splash/SplashViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.splash
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.android.tvflix.config.AppConfig
6 | import com.android.tvflix.di.IoDispatcher
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import kotlinx.coroutines.CoroutineDispatcher
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.asStateFlow
11 | import kotlinx.coroutines.launch
12 | import javax.inject.Inject
13 |
14 | @HiltViewModel
15 | class SplashViewModel
16 | @Inject
17 | constructor(
18 | private val appConfig: AppConfig,
19 | @IoDispatcher private val dispatcher: CoroutineDispatcher
20 | ) : ViewModel() {
21 | private val _splashViewStateFlow = MutableStateFlow(SplashViewState.Idle)
22 |
23 | // Represents _splashViewStateFlow mutable state flow as a read-only state flow.
24 | val splashViewStateFlow = _splashViewStateFlow.asStateFlow()
25 | fun fetchConfig() {
26 | viewModelScope.launch(dispatcher) {
27 | appConfig.fetch()
28 | _splashViewStateFlow.emit(SplashViewState.NavigateToHome)
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/analytics/FirebaseAnalyticsHandler.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.analytics
2 |
3 | import com.google.firebase.analytics.ktx.analytics
4 | import com.google.firebase.analytics.ktx.logEvent
5 | import com.google.firebase.ktx.Firebase
6 | import javax.inject.Inject
7 | import javax.inject.Singleton
8 |
9 | @Singleton
10 | class FirebaseAnalyticsHandler
11 | @Inject
12 | constructor() : Analytics {
13 | private val firebaseAnalytics by lazy { Firebase.analytics }
14 | override fun sendEvent(event: Event) {
15 | firebaseAnalytics.logEvent(event.eventName) {
16 | param(EventParams.CONTENT_ID, event.eventParams[EventParams.CONTENT_ID] as Long)
17 | param(EventParams.CONTENT_TITLE, event.eventParams[EventParams.CONTENT_TITLE] as String)
18 | param(
19 | EventParams.IS_FAVORITE_MARKED,
20 | event.eventParams[EventParams.IS_FAVORITE_MARKED].toString()
21 | )
22 | param(
23 | EventParams.CLICK_CONTEXT,
24 | event.eventParams[EventParams.CLICK_CONTEXT] as String
25 | )
26 | }
27 | }
28 |
29 | override fun setUserId(userId: String) {
30 | firebaseAnalytics.setUserId(userId)
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/android/tvflix/idlingresource/LoadingIdlingResource.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.idlingresource
2 |
3 | import android.app.Activity
4 | import android.view.View
5 | import android.widget.ProgressBar
6 | import androidx.test.espresso.IdlingResource
7 | import com.android.tvflix.R
8 |
9 | /**
10 | * An idling resource which checks which tells Espresso to wait until Progress Bar dismisses
11 | */
12 | class LoadingIdlingResource constructor(private val activity: Activity) : IdlingResource {
13 | private var resourceCallback: IdlingResource.ResourceCallback? = null
14 | override fun getName(): String {
15 | return "LoadingIdlingResource"
16 | }
17 |
18 | override fun isIdleNow(): Boolean {
19 | return if (!isProgressShowing()) {
20 | resourceCallback?.onTransitionToIdle()
21 | true
22 | } else {
23 | false
24 | }
25 | }
26 |
27 | override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
28 | resourceCallback = callback
29 | }
30 |
31 | private fun isProgressShowing(): Boolean {
32 | val progress = activity.findViewById(R.id.progress)
33 | return progress.visibility == View.VISIBLE
34 | }
35 | }
--------------------------------------------------------------------------------
/tools/rules-findbugs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/domain/GetSchedulesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.domain
2 |
3 | import com.android.tvflix.di.IoDispatcher
4 | import com.android.tvflix.model.respositores.SchedulesRepository
5 | import com.android.tvflix.network.home.Episode
6 | import kotlinx.coroutines.CoroutineDispatcher
7 | import java.text.SimpleDateFormat
8 | import java.util.*
9 | import javax.inject.Inject
10 |
11 |
12 | class GetSchedulesUseCase
13 | @Inject
14 | constructor(
15 | private val schedulesRepository: SchedulesRepository,
16 | @IoDispatcher ioDispatcher: CoroutineDispatcher
17 | ) :
18 | SuspendUseCase>(ioDispatcher) {
19 | override suspend fun execute(parameters: Unit): List {
20 | return schedulesRepository.getSchedule(country = COUNTRY, currentDate = currentDate)
21 | }
22 |
23 | companion object {
24 | private const val QUERY_DATE_FORMAT = "yyyy-MM-dd"
25 | const val COUNTRY = "US"
26 | private val currentDate: String
27 | get() {
28 | val simpleDateFormat = SimpleDateFormat(QUERY_DATE_FORMAT, Locale.US)
29 | val calendar = Calendar.getInstance()
30 | return simpleDateFormat.format(calendar.time)
31 | }
32 | }
33 |
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/domain/SuspendUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.domain
2 |
3 | import kotlinx.coroutines.CoroutineDispatcher
4 | import kotlinx.coroutines.withContext
5 |
6 | /**
7 | * Executes business logic synchronously or asynchronously using Coroutines.
8 | *
9 | * The [execute] method of [SuspendUseCase] is a suspend function
10 | */
11 | abstract class SuspendUseCase(private val coroutineDispatcher: CoroutineDispatcher) {
12 |
13 | /** Executes the use case asynchronously and returns a [Result].
14 | *
15 | * @return a [Result].
16 | *
17 | * @param parameters the input parameters to run the use case with
18 | */
19 | val tag: String = this.javaClass.simpleName
20 |
21 | suspend operator fun invoke(parameters: P): R {
22 | // Moving all use case's executions to the injected dispatcher
23 | // In production code, this is usually the Default dispatcher (background thread)
24 | // In tests, this becomes a TestCoroutineDispatcher
25 | return withContext(coroutineDispatcher) {
26 | execute(parameters)
27 | }
28 | }
29 |
30 | /**
31 | * Override this to set the code to be executed.
32 | */
33 | @Throws(RuntimeException::class)
34 | protected abstract suspend fun execute(parameters: P): R
35 | }
36 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/ShowsStateViewHolder.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import androidx.core.view.isVisible
6 | import androidx.paging.LoadState
7 | import androidx.recyclerview.widget.RecyclerView
8 | import com.android.tvflix.databinding.NetworkFailureListItemBinding
9 |
10 | class ShowsStateViewHolder(
11 | private val binding: NetworkFailureListItemBinding,
12 | retry: () -> Unit
13 | ) : RecyclerView.ViewHolder(binding.root) {
14 | init {
15 | binding.retry.setOnClickListener { retry.invoke() }
16 | }
17 |
18 | fun bind(loadState: LoadState) {
19 | if (loadState is LoadState.Error) {
20 | binding.errorMsg.text = loadState.error.localizedMessage
21 | }
22 | binding.progress.isVisible = loadState is LoadState.Loading
23 | binding.errorGroup.isVisible = loadState !is LoadState.Loading
24 | }
25 |
26 | companion object {
27 | fun create(parent: ViewGroup, retry: () -> Unit): ShowsStateViewHolder {
28 | val layoutInflater = LayoutInflater.from(parent.context)
29 | val binding = NetworkFailureListItemBinding.inflate(layoutInflater, parent, false)
30 | return ShowsStateViewHolder(binding, retry)
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/tools/rules-pmd.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 | Custom ruleset for TvMaze Android application
8 |
9 | .*/R.java
10 | .*/gen/.*
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/show_list_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 |
12 |
13 |
16 |
17 |
22 |
23 |
29 |
30 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/android/tvflix/shows/AllShowsTest.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import androidx.test.espresso.IdlingRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.LargeTest
6 | import androidx.test.rule.ActivityTestRule
7 | import com.android.tvflix.idlingresource.LoadingIdlingResource
8 | import org.junit.After
9 | import org.junit.Before
10 | import org.junit.Rule
11 | import org.junit.Test
12 | import org.junit.runner.RunWith
13 |
14 | @LargeTest
15 | @RunWith(AndroidJUnit4::class)
16 | class AllShowsTest {
17 | // @get:Rule
18 | // val homeActivityTestRule = ActivityTestRule(HomeActivity::class.java)
19 | @get:Rule
20 | val allShowsActivityTestRule = ActivityTestRule(AllShowsActivity::class.java)
21 |
22 | private lateinit var loadingIdlingResource: LoadingIdlingResource
23 | @Before
24 | fun setUp() {
25 | loadingIdlingResource =
26 | LoadingIdlingResource(allShowsActivityTestRule.activity)
27 | IdlingRegistry.getInstance().register(loadingIdlingResource)
28 | }
29 |
30 | @Test
31 | fun testAllShowsViaHome() {
32 | launchAllShows {
33 | verifyAllShows()
34 | }
35 | }
36 |
37 | @After
38 | fun tearDown() {
39 | IdlingRegistry.getInstance().unregister(loadingIdlingResource)
40 | }
41 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/android/tvflix/utils/MainCoroutineRule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.utils
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.TestCoroutineDispatcher
6 | import kotlinx.coroutines.test.TestCoroutineScope
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.runBlockingTest
9 | import kotlinx.coroutines.test.setMain
10 | import org.junit.rules.TestWatcher
11 | import org.junit.runner.Description
12 |
13 | /**
14 | * Sets the main coroutines dispatcher to a [TestCoroutineScope] for unit testing. A
15 | * [TestCoroutineScope] provides control over the execution of coroutines.
16 | *
17 | */
18 | @ExperimentalCoroutinesApi
19 | class MainCoroutineRule(
20 | val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
21 | ) : TestWatcher() {
22 |
23 | override fun starting(description: Description?) {
24 | super.starting(description)
25 | Dispatchers.setMain(testDispatcher)
26 | }
27 |
28 | override fun finished(description: Description?) {
29 | super.finished(description)
30 | Dispatchers.resetMain()
31 | testDispatcher.cleanupTestCoroutines()
32 | }
33 | }
34 |
35 | @ExperimentalCoroutinesApi
36 | fun MainCoroutineRule.runBlockingTest(block: suspend () -> Unit) =
37 | this.testDispatcher.runBlockingTest {
38 | block()
39 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
15 |
16 |
24 |
25 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/home/Show.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network.home
2 |
3 | import android.os.Parcelable
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 | import kotlinx.parcelize.Parcelize
7 |
8 | @Parcelize
9 | @JsonClass(generateAdapter = true)
10 | data class Show(
11 | val id: Long,
12 | val url: String?,
13 | val name: String,
14 | val type: String?,
15 | val language: String?,
16 | val genres: List?,
17 | val status: String?,
18 | val runtime: Int?,
19 | val premiered: String?,
20 | val officialSite: String?,
21 | @Json(name = "network") val airChannel: Channel?,
22 | val webChannel: Channel?,
23 | val image: Map?,
24 | @Json(name = "externals") val externalInfo: ExternalInfo?,
25 | val summary: String?,
26 | val rating: Map?
27 | ) : Parcelable {
28 | @Parcelize
29 | @JsonClass(generateAdapter = true)
30 | data class Channel(
31 | val id: Long?,
32 | val name: String?,
33 | val country: Country?
34 | ) : Parcelable {
35 | @Parcelize
36 | @JsonClass(generateAdapter = true)
37 | data class Country(
38 | val name: String,
39 | val code: String,
40 | val timezone: String
41 | ) : Parcelable
42 | }
43 |
44 | @Parcelize
45 | @JsonClass(generateAdapter = true)
46 | data class ExternalInfo(
47 | val tvrage: String?,
48 | val thetvdb: Long?,
49 | val imdb: String?
50 | ) : Parcelable
51 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/android/tvflix/home/HomeTest.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import androidx.test.espresso.Espresso.pressBack
4 | import androidx.test.espresso.IdlingRegistry
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import androidx.test.filters.LargeTest
7 | import androidx.test.rule.ActivityTestRule
8 | import com.android.tvflix.idlingresource.LoadingIdlingResource
9 | import org.junit.After
10 | import org.junit.Before
11 | import org.junit.Rule
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 |
15 | @LargeTest
16 | @RunWith(AndroidJUnit4::class)
17 | class HomeTest {
18 | @get:Rule
19 | val homeActivityTestRule = ActivityTestRule(HomeActivity::class.java)
20 | private lateinit var loadingIdlingResource: LoadingIdlingResource
21 |
22 | @Before
23 | fun setUp() {
24 | loadingIdlingResource =
25 | LoadingIdlingResource(homeActivityTestRule.activity)
26 | IdlingRegistry.getInstance().register(loadingIdlingResource)
27 | }
28 |
29 | @Test
30 | fun testHomePageWithFavorites() {
31 | launchHome {
32 | verifyHome()
33 | // Click on add to favorites icon
34 | verifyFavorite()
35 | // Verify that added to favorites toast is shown
36 | verifyToast(homeActivityTestRule.activity)
37 | verifyFavoriteScreen()
38 | // Verify that pressing back from favorites goes to home
39 | pressBack()
40 | verifyHome()
41 | }
42 | }
43 |
44 | @After
45 | fun tearDown() {
46 | IdlingRegistry.getInstance().unregister(loadingIdlingResource)
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/home/ShowDiffUtilCallback.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import android.os.Bundle
4 | import androidx.recyclerview.widget.DiffUtil
5 |
6 | class ShowDiffUtilCallback(
7 | private val oldList: List,
8 | private val newList: List
9 | ) : DiffUtil.Callback() {
10 |
11 | override fun getOldListSize(): Int {
12 | return oldList.size
13 | }
14 |
15 | override fun getNewListSize(): Int {
16 | return newList.size
17 | }
18 |
19 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
20 | val oldEpisodeViewData = oldList[oldItemPosition]
21 | val newEpisodeViewData = newList[newItemPosition]
22 | return oldEpisodeViewData.showViewData.show.id == newEpisodeViewData.showViewData.show.id
23 | }
24 |
25 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
26 | val oldEpisodeViewData = oldList[oldItemPosition]
27 | val newEpisodeViewData = newList[newItemPosition]
28 | return oldEpisodeViewData.showViewData.isFavoriteShow == newEpisodeViewData.showViewData.isFavoriteShow
29 | }
30 |
31 | override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Bundle {
32 | val oldEpisodeViewData = oldList[oldItemPosition]
33 | val newEpisodeViewData = newList[newItemPosition]
34 | val bundle = Bundle()
35 | if (oldEpisodeViewData.showViewData.isFavoriteShow != newEpisodeViewData.showViewData.isFavoriteShow) {
36 | bundle.putBoolean(IS_FAVORITE, newEpisodeViewData.showViewData.isFavoriteShow)
37 | }
38 | return bundle
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/ShowsPagingSource.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import com.android.tvflix.network.TvFlixApi
6 | import com.android.tvflix.network.home.Show
7 | import retrofit2.HttpException
8 | import java.io.IOException
9 | import javax.inject.Inject
10 |
11 | class ShowsPagingSource
12 | @Inject
13 | constructor(private val tvFlixApi: TvFlixApi) : PagingSource() {
14 | companion object {
15 | const val SHOWS_STARTING_INDEX = 1
16 | }
17 |
18 | override suspend fun load(params: LoadParams): LoadResult {
19 | val position = params.key ?: SHOWS_STARTING_INDEX
20 | return try {
21 | val showsList = tvFlixApi.getShows(position)
22 | LoadResult.Page(
23 | data = showsList,
24 | prevKey = if (position == SHOWS_STARTING_INDEX) null else position - 1,
25 | nextKey = if (showsList.isEmpty()) null else position + 1
26 | )
27 | } catch (exception: IOException) {
28 | LoadResult.Error(exception)
29 | } catch (exception: HttpException) {
30 | LoadResult.Error(exception)
31 | }
32 | }
33 |
34 | override fun getRefreshKey(state: PagingState): Int? {
35 | // We need to get the previous key (or next key if previous is null) of the page
36 | // that was closest to the most recently accessed index.
37 | // Anchor position is the most recently accessed index
38 | return state.anchorPosition?.let { anchorPosition ->
39 | state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
40 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
33 |
36 |
39 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.favorite
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import android.widget.ImageView
6 | import androidx.appcompat.content.res.AppCompatResources
7 | import androidx.recyclerview.widget.RecyclerView
8 | import com.android.tvflix.R
9 | import com.android.tvflix.databinding.ShowListItemBinding
10 | import com.android.tvflix.db.favouriteshow.FavoriteShow
11 | import com.bumptech.glide.Glide
12 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
13 | import com.bumptech.glide.request.RequestOptions
14 |
15 | class FavoriteShowsAdapter(
16 | private val favoriteShows: MutableList
17 | ) : RecyclerView.Adapter() {
18 |
19 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteShowHolder {
20 | val layoutInflater = LayoutInflater.from(parent.context)
21 | val showListItemBinding = ShowListItemBinding.inflate(layoutInflater, parent, false)
22 | val holder = FavoriteShowHolder(showListItemBinding)
23 | holder.binding.showFavoriteIcon = false
24 | return holder
25 | }
26 |
27 | override fun onBindViewHolder(holder: FavoriteShowHolder, position: Int) {
28 | val favoriteShow = favoriteShows[position]
29 | Glide.with(holder.itemView.context).load(favoriteShow.imageUrl)
30 | .apply(RequestOptions.placeholderOf(R.color.grey))
31 | .transition(DrawableTransitionOptions.withCrossFade())
32 | .into(holder.binding.showImage)
33 | }
34 |
35 | override fun getItemCount(): Int {
36 | return favoriteShows.size
37 | }
38 |
39 | class FavoriteShowHolder(val binding: ShowListItemBinding) :
40 | RecyclerView.ViewHolder(binding.root)
41 | }
42 |
--------------------------------------------------------------------------------
/tools/rules-checkstyle.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.favorite
2 |
3 | import com.android.tvflix.db.favouriteshow.FavoriteShow
4 | import com.android.tvflix.db.favouriteshow.ShowDao
5 | import com.android.tvflix.network.home.Show
6 | import javax.inject.Inject
7 | import javax.inject.Singleton
8 |
9 | @Singleton
10 | class FavoriteShowsRepository @Inject
11 | constructor(private val showDao: ShowDao) {
12 |
13 | suspend fun allFavoriteShows(): List {
14 | return showDao.allFavouriteShows()
15 | }
16 |
17 | suspend fun insertShowIntoFavorites(show: Show) {
18 | val favoriteShow = FavoriteShow(
19 | id = show.id,
20 | name = show.name,
21 | premiered = show.premiered,
22 | imageUrl = show.image!!["original"],
23 | summary = show.summary,
24 | rating = show.rating!!["average"],
25 | runtime = show.runtime!!
26 | )
27 | showDao.insert(favoriteShow)
28 | }
29 |
30 | suspend fun removeShowFromFavorites(show: Show) {
31 | val favoriteShow = FavoriteShow(
32 | id = show.id,
33 | name = show.name,
34 | premiered = show.premiered,
35 | imageUrl = show.image!!["original"],
36 | summary = show.summary,
37 | rating = show.rating!!["average"],
38 | runtime = show.runtime!!
39 | )
40 | showDao.remove(favoriteShow)
41 | }
42 |
43 | suspend fun insertIntoFavorites(favoriteShow: FavoriteShow) {
44 | showDao.insert(favoriteShow)
45 | }
46 |
47 | suspend fun removeFromFavorites(favoriteShow: FavoriteShow) {
48 | showDao.remove(favoriteShow)
49 | }
50 |
51 | suspend fun allFavoriteShowIds(): List {
52 | return showDao.getFavoriteShowIds()
53 | }
54 |
55 | suspend fun clearAll() {
56 | showDao.clear()
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/android/tvflix/home/HomeRobot.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import android.app.Activity
4 | import androidx.test.espresso.Espresso.onView
5 | import androidx.test.espresso.action.ViewActions.click
6 | import androidx.test.espresso.assertion.ViewAssertions.matches
7 | import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
8 | import androidx.test.espresso.matcher.RootMatchers.withDecorView
9 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
10 | import androidx.test.espresso.matcher.ViewMatchers.withId
11 | import androidx.test.espresso.matcher.ViewMatchers.withText
12 | import com.android.tvflix.R
13 | import com.android.tvflix.utils.MatcherUtils
14 | import com.android.tvflix.utils.ViewActionUtils
15 | import org.hamcrest.Matchers.`is`
16 | import org.hamcrest.Matchers.allOf
17 | import org.hamcrest.Matchers.not
18 |
19 | fun launchHome(func: HomeRobot.() -> Unit) = HomeRobot().apply { func() }
20 | class HomeRobot {
21 | fun verifyHome() {
22 | onView(withId(R.id.popular_show_header)).check(matches(isDisplayed()))
23 | onView(allOf(withId(R.id.popular_shows), isDisplayed()))
24 | }
25 |
26 | fun verifyFavorite() {
27 | // Click on 1st item and mark as favorite
28 | onView(withId(R.id.popular_shows))
29 | .perform(
30 | actionOnItemAtPosition
31 | (0, ViewActionUtils.clickChildViewWithId(R.id.favorite))
32 | )
33 | }
34 |
35 | fun verifyToast(activity: Activity) {
36 | onView(withText(R.string.added_to_favorites)).inRoot(withDecorView(not(`is`(activity.window.decorView))))
37 | .check(matches(isDisplayed()))
38 | }
39 |
40 | fun verifyFavoriteScreen() {
41 | onView(withId(R.id.action_favorites))
42 | .perform(click())
43 | onView(withText(R.string.favorite_shows)).check(matches(isDisplayed()))
44 | onView(withId(R.id.shows))
45 | .check(matches(MatcherUtils.withListSize(1)))
46 | }
47 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/ShowsPagedAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import android.content.Context
4 | import android.view.LayoutInflater
5 | import android.view.ViewGroup
6 | import androidx.paging.PagingDataAdapter
7 | import androidx.recyclerview.widget.DiffUtil
8 | import androidx.recyclerview.widget.RecyclerView
9 | import com.android.tvflix.R
10 | import com.android.tvflix.databinding.AllShowListItemBinding
11 | import com.android.tvflix.network.home.Show
12 | import com.bumptech.glide.Glide
13 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
14 | import com.bumptech.glide.request.RequestOptions
15 |
16 | class ShowsPagedAdapter constructor(
17 | diffCallback: DiffUtil.ItemCallback
18 | ) : PagingDataAdapter(diffCallback) {
19 | private lateinit var context: Context
20 |
21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
22 | context = parent.context
23 | val layoutInflater = LayoutInflater.from(context)
24 | val showListItemBinding = AllShowListItemBinding.inflate(layoutInflater, parent, false)
25 | return ShowHolder(showListItemBinding)
26 | }
27 |
28 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
29 | if (holder is ShowHolder) {
30 | val show = getItem(position)
31 | holder.binding.show = show
32 | if (show!!.rating != null) {
33 | holder.binding.rating = show.rating!!["average"]
34 | }
35 | configureImage(holder, show)
36 | }
37 | }
38 |
39 | private fun configureImage(holder: ShowHolder, show: Show) {
40 | if (show.image != null) {
41 | Glide.with(context).load(show.image["original"])
42 | .apply(RequestOptions.placeholderOf(R.color.grey))
43 | .centerCrop()
44 | .transition(DrawableTransitionOptions.withCrossFade())
45 | .into(holder.binding.showImage)
46 | }
47 | }
48 |
49 | class ShowHolder(val binding: AllShowListItemBinding) : RecyclerView.ViewHolder(binding.root)
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/config/AppConfig.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.config
2 |
3 | import com.android.tvflix.BuildConfig
4 | import com.google.firebase.ktx.Firebase
5 | import com.google.firebase.remoteconfig.ktx.remoteConfig
6 | import com.google.firebase.remoteconfig.ktx.remoteConfigSettings
7 | import kotlinx.coroutines.suspendCancellableCoroutine
8 | import javax.inject.Inject
9 | import javax.inject.Singleton
10 | import kotlin.coroutines.resume
11 | import kotlin.coroutines.resumeWithException
12 |
13 | @Singleton
14 | class AppConfig
15 | @Inject
16 | constructor() {
17 | companion object {
18 | const val FETCH_TIMEOUT_S = 20L
19 | const val MIN_FETCH_INTERVAL_S = 3600L
20 | }
21 |
22 | private val remoteConfig by lazy { Firebase.remoteConfig }
23 |
24 | fun initialise() {
25 | val minFetchInterval: Long = if (BuildConfig.DEBUG) {
26 | 0
27 | } else {
28 | MIN_FETCH_INTERVAL_S
29 | }
30 | val remoteConfigSettings = remoteConfigSettings {
31 | fetchTimeoutInSeconds = FETCH_TIMEOUT_S
32 | minimumFetchIntervalInSeconds = minFetchInterval
33 | }
34 | remoteConfig.apply {
35 | setConfigSettingsAsync(remoteConfigSettings)
36 | setDefaultsAsync(ConfigDefaults.getDefaultValues())
37 | }
38 | }
39 |
40 | suspend fun fetch(): Boolean =
41 | suspendCancellableCoroutine { continuation ->
42 | remoteConfig.fetchAndActivate().addOnSuccessListener {
43 | continuation.resume(true)
44 | }.addOnFailureListener { exc -> continuation.resumeWithException(exc) }
45 | }
46 |
47 | fun getString(key: String): String {
48 | return remoteConfig.getString(key)
49 | }
50 |
51 | fun getBoolean(key: String): Boolean {
52 | return remoteConfig.getBoolean(key)
53 | }
54 |
55 | fun getDouble(key: String): Double {
56 | return remoteConfig.getDouble(key)
57 | }
58 |
59 | fun getLong(key: String): Long {
60 | return remoteConfig.getLong(key)
61 | }
62 |
63 | fun getInt(key: String): Int {
64 | return remoteConfig.getLong(key).toInt()
65 | }
66 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_home.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
10 |
11 |
15 |
16 |
28 |
29 |
39 |
40 |
49 |
50 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.favorite
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.android.tvflix.db.favouriteshow.FavoriteShow
6 | import com.android.tvflix.di.IoDispatcher
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import kotlinx.coroutines.CoroutineDispatcher
9 | import kotlinx.coroutines.CoroutineExceptionHandler
10 | import kotlinx.coroutines.flow.MutableStateFlow
11 | import kotlinx.coroutines.flow.asStateFlow
12 | import kotlinx.coroutines.launch
13 | import timber.log.Timber
14 | import java.util.*
15 | import javax.inject.Inject
16 |
17 | @HiltViewModel
18 | class FavoriteShowsViewModel @Inject
19 | constructor(
20 | private val favoriteShowsRepository: FavoriteShowsRepository,
21 | // Inject coroutineDispatcher to facilitate Unit Testing
22 | @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
23 | ) : ViewModel() {
24 | private val _favoriteShowsStateFlow =
25 | MutableStateFlow(FavoriteShowState.Loading)
26 |
27 | // Represents _favoriteShowsStateFlow mutable state flow as a read-only state flow.
28 | val favoriteShowsStateFlow = _favoriteShowsStateFlow.asStateFlow()
29 |
30 | private lateinit var removedFromFavoriteShows: List
31 |
32 | fun loadFavoriteShows() {
33 | val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
34 | onError(exception)
35 | }
36 |
37 | viewModelScope.launch(ioDispatcher + coroutineExceptionHandler) {
38 | val favoriteShows = favoriteShowsRepository.allFavoriteShows()
39 |
40 | removedFromFavoriteShows = ArrayList(favoriteShows.size)
41 | if (favoriteShows.isNotEmpty()) {
42 | _favoriteShowsStateFlow.emit(FavoriteShowState.AllFavorites(favoriteShows))
43 | } else {
44 | _favoriteShowsStateFlow.emit(FavoriteShowState.Empty)
45 | }
46 | }
47 | }
48 |
49 | private fun onError(throwable: Throwable) {
50 | _favoriteShowsStateFlow.value =
51 | FavoriteShowState.Error(
52 | throwable.localizedMessage ?: "Some error occurred. Please try again."
53 | )
54 | Timber.d(throwable)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/src/test/java/com/android/tvflix/favorites/FavoritesRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.favorites
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import androidx.test.core.app.ApplicationProvider
6 | import com.android.tvflix.db.TvFlixDatabase
7 | import com.android.tvflix.db.favouriteshow.ShowDao
8 | import com.android.tvflix.favorite.FavoriteShowsRepository
9 | import com.android.tvflix.utils.TestUtil
10 | import com.google.common.truth.Truth.assertThat
11 | import kotlinx.coroutines.runBlocking
12 | import org.junit.After
13 | import org.junit.Before
14 | import org.junit.Test
15 | import org.junit.runner.RunWith
16 | import org.robolectric.RobolectricTestRunner
17 |
18 | @RunWith(RobolectricTestRunner::class)
19 | class FavoritesRepositoryTest {
20 | private lateinit var favoriteShowsRepository: FavoriteShowsRepository
21 | private lateinit var tvFlixDb: TvFlixDatabase
22 | private lateinit var showDao: ShowDao
23 |
24 | @Before
25 | fun setup() {
26 | val context = ApplicationProvider.getApplicationContext()
27 | tvFlixDb = Room.inMemoryDatabaseBuilder(
28 | context, TvFlixDatabase::class.java
29 | ).allowMainThreadQueries().build()
30 | showDao = tvFlixDb.showDao()
31 | favoriteShowsRepository = FavoriteShowsRepository(showDao)
32 | }
33 |
34 | @Test
35 | fun testShowInsertionInDb() {
36 | runBlocking {
37 | favoriteShowsRepository.insertIntoFavorites(TestUtil.getFakeShow())
38 | val favShows = favoriteShowsRepository.allFavoriteShows()
39 | assertThat(favShows.isNotEmpty()).isTrue()
40 | }
41 | }
42 |
43 | @Test
44 | fun testRemoveFromDb() {
45 | runBlocking {
46 | favoriteShowsRepository.clearAll()
47 | assertThat(favoriteShowsRepository.allFavoriteShows().isEmpty()).isTrue()
48 | }
49 | }
50 |
51 | @Test
52 | fun testFavoriteShows() {
53 | runBlocking {
54 | val fakeShow = TestUtil.getFakeShow()
55 | favoriteShowsRepository.insertIntoFavorites(fakeShow)
56 | val favoriteShows = favoriteShowsRepository.allFavoriteShows()
57 | assertThat(favoriteShows[0] == fakeShow).isTrue()
58 | }
59 | }
60 |
61 | @After
62 | fun release() {
63 | tvFlixDb.close()
64 | }
65 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_favorite_shows.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
10 |
11 |
15 |
16 |
27 |
28 |
37 |
38 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/network_failure_list_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
10 |
11 |
20 |
21 |
28 |
29 |
42 |
43 |
52 |
53 |
--------------------------------------------------------------------------------
/app/src/test/java/com/android/tvflix/utils/TestUtil.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.utils
2 |
3 | import com.android.tvflix.db.favouriteshow.FavoriteShow
4 | import com.android.tvflix.home.HomeViewData
5 | import com.android.tvflix.network.home.Episode
6 | import com.android.tvflix.network.home.Show
7 | import kotlin.collections.ArrayList
8 |
9 | object TestUtil {
10 | fun getFakeEpisodeViewDataList(isFavorite: Boolean): List {
11 | val episodes = getFakeEpisodeList()
12 | val episodeViewDataList = ArrayList(episodes.size)
13 | episodes.forEach {
14 | val showViewData = HomeViewData.ShowViewData(it.show, isFavorite)
15 | val episodeViewData = HomeViewData.EpisodeViewData(
16 | id = it.id,
17 | showViewData = showViewData,
18 | url = it.url,
19 | name = it.name,
20 | season = it.season,
21 | number = it.number,
22 | airdate = it.airdate,
23 | airtime = it.airtime,
24 | runtime = it.runtime
25 | )
26 | episodeViewDataList.add(episodeViewData)
27 | }
28 | return episodeViewDataList
29 | }
30 |
31 | fun getFakeEpisodeList(): List {
32 | val episodeList = ArrayList(2)
33 | val show1 = Show(
34 | id = 1, url = null, name = "Friends", type = "Show",
35 | language = "English", genres = emptyList(), status = "Ended", runtime = 13200,
36 | premiered = null, officialSite = null, airChannel = null, webChannel = null,
37 | image = null, externalInfo = null, summary = "Friends for life!", rating = null
38 | )
39 | val episode1 = Episode(
40 | show = show1, id = 101, url = null, name = "The One with the Reunion - Part 1",
41 | season = 11, number = 1, airdate = null, airtime = null, runtime = 13200
42 | )
43 | val show2 = Show(
44 | id = 2, url = null, name = "Breaking Bad", type = "Show",
45 | language = "English", genres = emptyList(), status = "Ended", runtime = 26000,
46 | premiered = null, officialSite = null, airChannel = null, webChannel = null,
47 | image = null, externalInfo = null, summary = "Crime Drama", rating = null
48 | )
49 | val episode2 = Episode(
50 | show = show2, id = 102, url = null, name = "I am the one who Knocks!",
51 | season = 11, number = 2, airdate = null, airtime = null, runtime = 13200
52 | )
53 | episodeList.add(episode1)
54 | episodeList.add(episode2)
55 | return episodeList
56 | }
57 |
58 | fun getFakeShow(): FavoriteShow {
59 | return FavoriteShow(
60 | id = 222, name = "Friends",
61 | premiered = "Aug 2002", imageUrl = null,
62 | summary = "Friends for life!", rating = "10 stars",
63 | runtime = 132000
64 | )
65 | }
66 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
34 |
35 | @rem Find java.exe
36 | if defined JAVA_HOME goto findJavaFromJavaHome
37 |
38 | set JAVA_EXE=java.exe
39 | %JAVA_EXE% -version >NUL 2>&1
40 | if "%ERRORLEVEL%" == "0" goto init
41 |
42 | echo.
43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
44 | echo.
45 | echo Please set the JAVA_HOME variable in your environment to match the
46 | echo location of your Java installation.
47 |
48 | goto fail
49 |
50 | :findJavaFromJavaHome
51 | set JAVA_HOME=%JAVA_HOME:"=%
52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
53 |
54 | if exist "%JAVA_EXE%" goto init
55 |
56 | echo.
57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
58 | echo.
59 | echo Please set the JAVA_HOME variable in your environment to match the
60 | echo location of your Java installation.
61 |
62 | goto fail
63 |
64 | :init
65 | @rem Get command-line arguments, handling Windows variants
66 |
67 | if not "%OS%" == "Windows_NT" goto win9xME_args
68 |
69 | :win9xME_args
70 | @rem Slurp the command line arguments.
71 | set CMD_LINE_ARGS=
72 | set _SKIP=2
73 |
74 | :win9xME_args_slurp
75 | if "x%~1" == "x" goto execute
76 |
77 | set CMD_LINE_ARGS=%*
78 |
79 | :execute
80 | @rem Setup the command line
81 |
82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
83 |
84 | @rem Execute Gradle
85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
86 |
87 | :end
88 | @rem End local scope for the variables with windows NT shell
89 | if "%ERRORLEVEL%"=="0" goto mainEnd
90 |
91 | :fail
92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
93 | rem the _cmd.exe /c_ return code!
94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
95 | exit /b 1
96 |
97 | :mainEnd
98 | if "%OS%"=="Windows_NT" endlocal
99 |
100 | :omega
101 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_all_shows.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
9 |
10 |
14 |
15 |
27 |
28 |
37 |
38 |
47 |
48 |
61 |
62 |
71 |
72 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at kumar.ashwini009@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/network/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.network
2 |
3 | import android.content.Context
4 | import android.os.Looper
5 | import com.android.tvflix.BuildConfig
6 | import com.android.tvflix.di.DaggerSet
7 | import com.chuckerteam.chucker.api.ChuckerCollector
8 | import com.chuckerteam.chucker.api.ChuckerInterceptor
9 | import dagger.Lazy
10 | import dagger.Module
11 | import dagger.Provides
12 | import dagger.hilt.InstallIn
13 | import dagger.hilt.components.SingletonComponent
14 | import okhttp3.Cache
15 | import okhttp3.Interceptor
16 | import okhttp3.OkHttpClient
17 | import okhttp3.logging.HttpLoggingInterceptor
18 | import retrofit2.Retrofit
19 | import retrofit2.converter.moshi.MoshiConverterFactory
20 | import javax.inject.Named
21 | import javax.inject.Qualifier
22 | import javax.inject.Singleton
23 |
24 | @Retention(AnnotationRetention.BINARY)
25 | @Qualifier
26 | private annotation class InternalApi
27 |
28 | @InstallIn(SingletonComponent::class)
29 | @Module(includes = [TvFlixApiModule::class, InterceptorModule::class])
30 | object NetworkModule {
31 | const val TVMAZE_BASE_URL = "tvmaze_base_url"
32 | private const val BASE_URL = "https://api.tvmaze.com/"
33 |
34 | @Provides
35 | @Named(TVMAZE_BASE_URL)
36 | fun provideBaseUrlString(): String {
37 | return BASE_URL
38 | }
39 |
40 | @Provides
41 | @Singleton
42 | fun provideLoggingInterceptor(): HttpLoggingInterceptor {
43 | return HttpLoggingInterceptor().apply {
44 | if (BuildConfig.DEBUG) {
45 | level = HttpLoggingInterceptor.Level.BODY
46 | }
47 | }
48 | }
49 |
50 | @Provides
51 | @Singleton
52 | fun provideChuckInterceptor(context: Context): ChuckerInterceptor {
53 | return ChuckerInterceptor.Builder(context)
54 | .collector(ChuckerCollector(context))
55 | .maxContentLength(250000L)
56 | .redactHeaders(emptySet())
57 | .alwaysReadResponseBody(false)
58 | .build()
59 | }
60 |
61 | // Use newBuilder() to customize so that thread-pool and connection-pool same are used
62 | @Provides
63 | fun provideOkHttpClientBuilder(
64 | @InternalApi okHttpClient: Lazy
65 | ): OkHttpClient.Builder {
66 | return okHttpClient.get().newBuilder()
67 | }
68 |
69 | @InternalApi
70 | @Provides
71 | @Singleton
72 | fun provideBaseOkHttpClient(
73 | interceptors: DaggerSet,
74 | cache: Cache
75 | ): OkHttpClient {
76 | check(Looper.myLooper() != Looper.getMainLooper()) { "HTTP client initialized on main thread." }
77 | val builder = OkHttpClient.Builder()
78 | builder.interceptors()
79 | .addAll(interceptors)
80 | builder.cache(cache)
81 | return builder.build()
82 | }
83 |
84 | @Singleton
85 | @Provides
86 | fun provideCache(context: Context): Cache {
87 | check(Looper.myLooper() != Looper.getMainLooper()) { "Cache initialized on main thread." }
88 | val cacheSize = 10 * 1024 * 1024 // 10 MB
89 | val cacheDir = context.cacheDir
90 | return Cache(cacheDir, cacheSize.toLong())
91 | }
92 |
93 | @Provides
94 | @Singleton
95 | fun provideRetrofit(
96 | @InternalApi
97 | okHttpClient: Lazy,
98 | @Named(TVMAZE_BASE_URL) baseUrl: String
99 | ): Retrofit {
100 | return Retrofit.Builder()
101 | .baseUrl(baseUrl)
102 | .addConverterFactory(MoshiConverterFactory.create())
103 | .callFactory { okHttpClient.get().newCall(it) }
104 | .build()
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/shows/AllShowsActivity.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.shows
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import android.view.LayoutInflater
7 | import android.view.MenuItem
8 | import android.widget.Toast
9 | import androidx.activity.viewModels
10 | import androidx.appcompat.app.AppCompatActivity
11 | import androidx.core.content.ContextCompat
12 | import androidx.core.view.isVisible
13 | import androidx.lifecycle.lifecycleScope
14 | import androidx.paging.LoadState
15 | import com.android.tvflix.R
16 | import com.android.tvflix.databinding.ActivityAllShowsBinding
17 | import dagger.hilt.android.AndroidEntryPoint
18 | import kotlinx.coroutines.flow.collectLatest
19 |
20 | @AndroidEntryPoint
21 | class AllShowsActivity : AppCompatActivity() {
22 | private val showsViewModel: ShowsViewModel by viewModels()
23 | private lateinit var adapter: ShowsPagedAdapter
24 | private lateinit var showsBinding: ActivityAllShowsBinding
25 | override fun onCreate(savedInstanceState: Bundle?) {
26 | super.onCreate(savedInstanceState)
27 | showsBinding = ActivityAllShowsBinding.inflate(LayoutInflater.from(this))
28 | setContentView(showsBinding.root)
29 | setToolbar()
30 | initAdapter()
31 | getShows()
32 | showsBinding.retry.setOnClickListener { adapter.retry() }
33 | }
34 |
35 | private fun getShows() {
36 | lifecycleScope.launchWhenStarted {
37 | showsViewModel.shows().collectLatest { adapter.submitData(it) }
38 | }
39 | }
40 |
41 | private fun initAdapter() {
42 | adapter = ShowsPagedAdapter(ShowDiffUtilItemCallback())
43 | showsBinding.shows.adapter = adapter.withLoadStateFooter(
44 | footer = ShowsLoadStateAdapter { adapter.retry() }
45 | )
46 |
47 | adapter.addLoadStateListener { combinedLoadStates ->
48 | // Handle the initial load state
49 | when (val loadState = combinedLoadStates.source.refresh) {
50 | is LoadState.NotLoading -> {
51 | showsBinding.progress.isVisible = false
52 | showsBinding.shows.isVisible = true
53 | showsBinding.errorGroup.isVisible = false
54 | }
55 | is LoadState.Loading -> {
56 | showsBinding.progress.isVisible = true
57 | showsBinding.errorGroup.isVisible = false
58 | }
59 | is LoadState.Error -> {
60 | showsBinding.progress.isVisible = false
61 | showsBinding.errorGroup.isVisible = true
62 | showsBinding.errorMsg.text = loadState.error.localizedMessage
63 | }
64 | }
65 |
66 | // Show message to the user when an error comes while loading the next page
67 | val errorState = combinedLoadStates.source.append as? LoadState.Error
68 | ?: combinedLoadStates.append as? LoadState.Error
69 | errorState?.let {
70 | Toast.makeText(this, errorState.error.localizedMessage, Toast.LENGTH_SHORT).show()
71 | }
72 | }
73 | }
74 |
75 | private fun setToolbar() {
76 | val toolbar = showsBinding.toolbar.toolbar
77 | setSupportActionBar(toolbar)
78 | toolbar.setTitleTextColor(ContextCompat.getColor(this, android.R.color.white))
79 | toolbar.setSubtitleTextColor(ContextCompat.getColor(this, android.R.color.white))
80 | supportActionBar!!.setDisplayHomeAsUpEnabled(true)
81 | setTitle(R.string.shows)
82 | }
83 |
84 | override fun onOptionsItemSelected(item: MenuItem): Boolean {
85 | return if (item.itemId == android.R.id.home) {
86 | finish()
87 | true
88 | } else {
89 | super.onOptionsItemSelected(item)
90 | }
91 | }
92 |
93 | companion object {
94 | fun start(context: Context) {
95 | val starter = Intent(context, AllShowsActivity::class.java)
96 | context.startActivity(starter)
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsActivity.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.favorite
2 |
3 | import android.app.Activity
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import android.text.SpannableString
7 | import android.text.Spanned
8 | import android.text.style.ImageSpan
9 | import android.view.MenuItem
10 | import android.widget.Toast
11 | import androidx.activity.viewModels
12 | import androidx.appcompat.app.AppCompatActivity
13 | import androidx.core.content.ContextCompat
14 | import androidx.core.view.isVisible
15 | import androidx.lifecycle.lifecycleScope
16 | import androidx.recyclerview.widget.GridLayoutManager
17 | import com.android.tvflix.R
18 | import com.android.tvflix.databinding.ActivityFavoriteShowsBinding
19 | import com.android.tvflix.db.favouriteshow.FavoriteShow
20 | import com.android.tvflix.utils.GridItemDecoration
21 | import dagger.hilt.android.AndroidEntryPoint
22 | import kotlinx.coroutines.flow.collect
23 |
24 | @AndroidEntryPoint
25 | class FavoriteShowsActivity : AppCompatActivity() {
26 | private val favoriteShowsViewModel: FavoriteShowsViewModel by viewModels()
27 | private val binding by lazy { ActivityFavoriteShowsBinding.inflate(layoutInflater) }
28 |
29 | override fun onCreate(savedInstanceState: Bundle?) {
30 | super.onCreate(savedInstanceState)
31 | setContentView(binding.root)
32 | setToolbar()
33 | favoriteShowsViewModel.loadFavoriteShows()
34 | lifecycleScope.launchWhenStarted {
35 | favoriteShowsViewModel.favoriteShowsStateFlow.collect { setViewState(it) }
36 | }
37 | }
38 |
39 | private fun setViewState(favoriteShowState: FavoriteShowState) {
40 | when (favoriteShowState) {
41 | is FavoriteShowState.Loading -> binding.progress.isVisible = true
42 | is FavoriteShowState.AllFavorites ->
43 | showFavorites(favoriteShowState.favoriteShows)
44 | is FavoriteShowState.Error, FavoriteShowState.Empty -> showEmptyState()
45 | is FavoriteShowState.AddedToFavorites ->
46 | Toast.makeText(this, R.string.added_to_favorites, Toast.LENGTH_SHORT).show()
47 | is FavoriteShowState.RemovedFromFavorites ->
48 | Toast.makeText(this, R.string.removed_from_favorites, Toast.LENGTH_SHORT).show()
49 | }
50 | }
51 |
52 | private fun setToolbar() {
53 | val toolbar = binding.toolbar.toolbar
54 | setSupportActionBar(toolbar)
55 | toolbar.setTitleTextColor(ContextCompat.getColor(this, android.R.color.white))
56 | toolbar.setSubtitleTextColor(ContextCompat.getColor(this, android.R.color.white))
57 | supportActionBar?.run { setDisplayHomeAsUpEnabled(true) }
58 | setTitle(R.string.favorite_shows)
59 | }
60 |
61 | private fun showFavorites(favoriteShows: List) {
62 | binding.progress.isVisible = false
63 | val layoutManager = GridLayoutManager(this, COLUMNS_COUNT)
64 | binding.shows.layoutManager = layoutManager
65 | val favoriteShowsAdapter = FavoriteShowsAdapter(favoriteShows.toMutableList())
66 | binding.shows.adapter = favoriteShowsAdapter
67 | val spacing = resources.getDimensionPixelSize(R.dimen.show_grid_spacing)
68 | binding.shows.addItemDecoration(GridItemDecoration(spacing, COLUMNS_COUNT))
69 | binding.shows.isVisible = true
70 | }
71 |
72 | private fun showEmptyState() {
73 | binding.progress.isVisible = false
74 | val bookmarkSpan = ImageSpan(this, R.drawable.favorite_border)
75 | val spannableString = SpannableString(getString(R.string.favorite_hint_msg))
76 | spannableString.setSpan(
77 | bookmarkSpan, FAVORITE_ICON_START_OFFSET,
78 | FAVORITE_ICON_END_OFFSET, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
79 | )
80 | binding.favoriteHint.text = spannableString
81 | binding.favoriteHint.isVisible = true
82 | }
83 |
84 | companion object {
85 | private const val FAVORITE_ICON_START_OFFSET = 13
86 | private const val FAVORITE_ICON_END_OFFSET = 14
87 | private const val COLUMNS_COUNT = 2
88 |
89 | fun start(context: Activity) {
90 | val starter = Intent(context, FavoriteShowsActivity::class.java)
91 | context.startActivity(starter)
92 | }
93 | }
94 |
95 | override fun onOptionsItemSelected(item: MenuItem): Boolean {
96 | return if (item.itemId == android.R.id.home) {
97 | finish()
98 | true
99 | } else {
100 | super.onOptionsItemSelected(item)
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # This is a configuration file for ProGuard.
2 | # http://proguard.sourceforge.net/index.html#manual/usage.html
3 |
4 |
5 | #To fix: Warning: com.comscore.instrumentation.InstrumentedMapActivity: can't find referenced class com.google.android.maps.MapActivity
6 | -dontwarn com.comscore.instrumentation.InstrumentedMapActivity
7 |
8 | # It's safe to ignore okio and okhttp warnings
9 | -dontwarn okio.**
10 | -dontwarn com.squareup.okhttp.**
11 | -dontwarn javax.annotation.Nullable
12 | -dontwarn javax.annotation.ParametersAreNonnullByDefault
13 |
14 | # ProGuard configuration for production build only
15 | # Remove Log.* method calls
16 | -assumenosideeffects class android.util.Log {
17 | public static int d(...);
18 | public static int v(...);
19 | public static int i(...);
20 | }
21 | -dontwarn org.apache.**
22 |
23 | # Needed by commons logging
24 | -keep class org.apache.commons.logging.* { *; }
25 |
26 | #Rx
27 | -dontwarn sun.misc.**
28 | -keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* {
29 | long producerIndex;
30 | long consumerIndex;
31 | }
32 | -keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueProducerNodeRef {
33 | rx.internal.util.atomic.LinkedQueueNode producerNode;
34 | }
35 | -keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueConsumerNodeRef {
36 | rx.internal.util.atomic.LinkedQueueNode consumerNode;
37 | }
38 | -dontnote rx.internal.util.PlatformDependent
39 |
40 | #Retrofit
41 | # Platform calls Class.forName on types which do not exist on Android to determine platform.
42 | -dontnote retrofit2.Platform
43 | # Platform used when running on RoboVM on iOS. Will not be used at runtime.
44 | -dontnote retrofit2.Platform$IOS$MainThreadExecutor
45 | # Platform used when running on Java 8 VMs. Will not be used at runtime.
46 | -dontwarn retrofit2.Platform$Java8
47 | # Retain generic type information for use by reflection by converters and adapters.
48 | -keepattributes Signature
49 | # Retain declared checked exceptions for use by a Proxy instance.
50 | -keepattributes Exceptions
51 | -keepclasseswithmembers class * {
52 | @retrofit2.http.* ;
53 | }
54 | #Retrolambda
55 | -dontwarn java.lang.invoke.*
56 |
57 | -keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable {
58 | public static final *** NULL;
59 | }
60 | -keepnames @com.google.android.gms.common.annotation.KeepName class *
61 | -keepclassmembernames class * {
62 | @com.google.android.gms.common.annotation.KeepName *;
63 | }
64 | -keepnames class * implements android.os.Parcelable {
65 | public static final ** CREATOR;
66 | }
67 |
68 | -keep class android.databinding.** { *; }
69 | -dontwarn android.databinding.**
70 |
71 | # dagger
72 | -dontwarn com.google.errorprone.annotations.*
73 | # Lambda
74 | -dontwarn **$$Lambda$*
75 |
76 | # Glide
77 | -keep public class * implements com.bumptech.glide.module.GlideModule
78 | -keep public class * extends com.bumptech.glide.GeneratedAppGlideModule
79 | -keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** {
80 | **[] $VALUES;
81 | public *;
82 | }
83 | -keep class com.bumptech.glide.integration.okhttp.OkHttpGlideModule
84 | -dontwarn com.bumptech.glide.integration.okhttp3.*
85 |
86 | # A resource is loaded with a relative path so the package of this class must be preserved.
87 | -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
88 |
89 | # Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
90 | -dontwarn org.codehaus.mojo.animal_sniffer.*
91 |
92 | # OkHttp platform used only on JVM and when Conscrypt dependency is available.
93 | -dontwarn okhttp3.internal.platform.ConscryptPlatform
94 |
95 | # Strip Timber Log statments in release
96 | -assumenosideeffects class timber.log.Timber* {
97 | public static *** v(...);
98 | public static *** d(...);
99 | public static *** i(...);
100 | }
101 |
102 | # OkHttp platform used only on JVM and when Conscrypt dependency is available.
103 | -dontwarn okhttp3.internal.platform.ConscryptPlatform
104 |
105 | # Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
106 | -dontwarn org.codehaus.mojo.animal_sniffer.*
107 |
108 | -keep public class * implements com.bumptech.glide.module.GlideModule
109 | -keep class * extends com.bumptech.glide.module.AppGlideModule {
110 | (...);
111 | }
112 | -keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
113 | **[] $VALUES;
114 | public *;
115 | }
116 | -keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
117 | *** rewind();
118 | }
119 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/home/ShowsAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.ViewGroup
6 | import android.widget.ImageView
7 | import androidx.appcompat.content.res.AppCompatResources
8 | import androidx.recyclerview.widget.DiffUtil
9 | import androidx.recyclerview.widget.RecyclerView
10 | import com.android.tvflix.R
11 | import com.android.tvflix.databinding.ShowListItemBinding
12 | import com.android.tvflix.network.home.Show
13 | import com.bumptech.glide.Glide
14 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
15 | import com.bumptech.glide.request.RequestOptions
16 |
17 | const val IS_FAVORITE = "IS_FAVORITE"
18 |
19 | class ShowsAdapter(
20 | private val callback: Callback,
21 | private val favoritesFeatureEnable: Boolean
22 | ) : RecyclerView.Adapter() {
23 | private var episodeViewDataList: MutableList = mutableListOf()
24 |
25 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShowHolder {
26 | val layoutInflater = LayoutInflater.from(parent.context)
27 | val showListItemBinding = ShowListItemBinding
28 | .inflate(layoutInflater, parent, false)
29 | val showHolder = ShowHolder(showListItemBinding)
30 | showHolder.binding.showFavoriteIcon = favoritesFeatureEnable
31 | showHolder.binding.favorite.setOnClickListener { onFavouriteIconClicked(showHolder.absoluteAdapterPosition) }
32 | return showHolder
33 | }
34 |
35 | fun updateList(newList: MutableList) {
36 | val showDiffUtilCallback = ShowDiffUtilCallback(episodeViewDataList, newList)
37 | val diffResult = DiffUtil.calculateDiff(showDiffUtilCallback)
38 | episodeViewDataList = newList
39 | diffResult.dispatchUpdatesTo(this)
40 | }
41 |
42 | private fun onFavouriteIconClicked(position: Int) {
43 | if (position != RecyclerView.NO_POSITION) {
44 | val episodeViewData = episodeViewDataList[position]
45 | val isFavorite = episodeViewData.showViewData.isFavoriteShow
46 | val updatedShowViewData =
47 | episodeViewData.showViewData.copy(isFavoriteShow = !isFavorite)
48 | val updatedEpisodeViewData = episodeViewData.copy(showViewData = updatedShowViewData)
49 | episodeViewDataList[position] = updatedEpisodeViewData
50 | notifyItemChanged(position)
51 | callback.onFavoriteClicked(episodeViewData.showViewData)
52 | }
53 | }
54 |
55 | override fun onBindViewHolder(holder: ShowHolder, position: Int) {
56 | val episodeViewData = episodeViewDataList[position]
57 | configureImage(holder.binding.showImage, episodeViewData.showViewData.show)
58 | configureFavoriteIcon(holder.binding.favorite, episodeViewData.showViewData.isFavoriteShow)
59 | }
60 |
61 | override fun onBindViewHolder(
62 | holder: ShowHolder,
63 | position: Int,
64 | payloads: List
65 | ) {
66 | if (payloads.isNotEmpty()) {
67 | val bundle = payloads[0] as Bundle
68 | val isFavorite = bundle.getBoolean(IS_FAVORITE)
69 | configureFavoriteIcon(holder.binding.favorite, isFavorite)
70 | } else {
71 | onBindViewHolder(holder, position)
72 | }
73 | }
74 |
75 | private fun configureFavoriteIcon(favoriteIcon: ImageView, favorite: Boolean) {
76 | if (favorite) {
77 | val favoriteDrawable = AppCompatResources
78 | .getDrawable(favoriteIcon.context, R.drawable.favorite)
79 | favoriteIcon.setImageDrawable(favoriteDrawable)
80 | } else {
81 | val unFavoriteDrawable = AppCompatResources
82 | .getDrawable(favoriteIcon.context, R.drawable.favorite_border)
83 | favoriteIcon.setImageDrawable(unFavoriteDrawable)
84 | }
85 | }
86 |
87 | private fun configureImage(showImage: ImageView, show: Show) {
88 | if (show.image != null) {
89 | Glide.with(showImage.context).load(show.image[ORIGINAL_IMAGE])
90 | .apply(RequestOptions.placeholderOf(R.color.grey))
91 | .transition(DrawableTransitionOptions.withCrossFade())
92 | .into(showImage)
93 | }
94 | }
95 |
96 | override fun getItemCount(): Int {
97 | return episodeViewDataList.size
98 | }
99 |
100 | class ShowHolder(val binding: ShowListItemBinding) : RecyclerView.ViewHolder(binding.root)
101 |
102 | interface Callback {
103 | fun onFavoriteClicked(showViewData: HomeViewData.ShowViewData)
104 | }
105 |
106 | companion object {
107 | private const val ORIGINAL_IMAGE = "original"
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/all_show_list_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
17 |
18 |
21 |
22 |
23 |
29 |
30 |
33 |
34 |
43 |
44 |
49 |
50 |
55 |
56 |
68 |
69 |
83 |
84 |
98 |
99 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/home/HomeActivity.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import android.app.Activity
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import android.view.Menu
7 | import android.view.MenuItem
8 | import android.widget.Toast
9 | import androidx.activity.viewModels
10 | import androidx.appcompat.app.AppCompatActivity
11 | import androidx.core.content.ContextCompat
12 | import androidx.core.view.isVisible
13 | import androidx.lifecycle.lifecycleScope
14 | import androidx.recyclerview.widget.GridLayoutManager
15 | import com.android.tvflix.R
16 | import com.android.tvflix.config.FavoritesFeatureFlag
17 | import com.android.tvflix.databinding.ActivityHomeBinding
18 | import com.android.tvflix.domain.GetSchedulesUseCase
19 | import com.android.tvflix.favorite.FavoriteShowsActivity
20 | import com.android.tvflix.shows.AllShowsActivity
21 | import com.android.tvflix.utils.GridItemDecoration
22 | import dagger.hilt.android.AndroidEntryPoint
23 | import kotlinx.coroutines.flow.collect
24 | import javax.inject.Inject
25 |
26 | @AndroidEntryPoint
27 | class HomeActivity : AppCompatActivity(), ShowsAdapter.Callback {
28 | private val homeViewModel: HomeViewModel by viewModels()
29 | private lateinit var showsAdapter: ShowsAdapter
30 | private val binding by lazy { ActivityHomeBinding.inflate(layoutInflater) }
31 |
32 | @JvmField
33 | @FavoritesFeatureFlag
34 | @Inject
35 | var favoritesFeatureEnable: Boolean = false
36 |
37 | companion object {
38 | private const val NO_OF_COLUMNS = 2
39 |
40 | @JvmStatic
41 | fun start(
42 | context: Activity
43 | ) {
44 | val intent = Intent(context, HomeActivity::class.java)
45 | .apply {
46 | flags = (Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
47 | or Intent.FLAG_ACTIVITY_CLEAR_TOP)
48 | }
49 | context.startActivity(intent)
50 | }
51 | }
52 |
53 | override fun onCreate(savedInstanceState: Bundle?) {
54 | super.onCreate(savedInstanceState)
55 | setContentView(binding.root)
56 | setToolbar()
57 | homeViewModel.onScreenCreated()
58 | lifecycleScope.launchWhenStarted {
59 | homeViewModel.homeViewStateFlow.collect { setViewState(it) }
60 | }
61 | }
62 |
63 | private fun setViewState(homeViewState: HomeViewState) {
64 | when (homeViewState) {
65 | is HomeViewState.Loading -> binding.progress.isVisible = true
66 | is HomeViewState.NetworkError -> {
67 | binding.progress.isVisible = false
68 | showError(homeViewState.message!!)
69 | }
70 | is HomeViewState.Success -> {
71 | with(binding) {
72 | progress.isVisible = false
73 | popularShowHeader.text = homeViewState.homeViewData.heading
74 | popularShowHeader.isVisible = true
75 | }
76 | showPopularShows(homeViewState.homeViewData)
77 | }
78 | is HomeViewState.AddedToFavorites ->
79 | Toast.makeText(
80 | this,
81 | getString(R.string.added_to_favorites, homeViewState.show.name),
82 | Toast.LENGTH_SHORT
83 | ).show()
84 | is HomeViewState.RemovedFromFavorites ->
85 | Toast.makeText(
86 | this,
87 | getString(R.string.removed_from_favorites, homeViewState.show.name),
88 | Toast.LENGTH_SHORT
89 | ).show()
90 | }
91 | }
92 |
93 | private fun setToolbar() {
94 | val toolbar = binding.toolbar.toolbar
95 | setSupportActionBar(toolbar)
96 | toolbar.setTitleTextColor(ContextCompat.getColor(this, android.R.color.white))
97 | toolbar.setSubtitleTextColor(ContextCompat.getColor(this, android.R.color.white))
98 | setTitle(R.string.app_name)
99 | }
100 |
101 | private fun showPopularShows(homeViewData: HomeViewData) {
102 | val gridLayoutManager = GridLayoutManager(this, NO_OF_COLUMNS)
103 | showsAdapter = ShowsAdapter(this, favoritesFeatureEnable)
104 | showsAdapter.updateList(homeViewData.episodes.toMutableList())
105 | binding.popularShows.apply {
106 | layoutManager = gridLayoutManager
107 | setHasFixedSize(true)
108 | adapter = showsAdapter
109 | val spacing = resources.getDimensionPixelSize(R.dimen.show_grid_spacing)
110 | addItemDecoration(GridItemDecoration(spacing, NO_OF_COLUMNS))
111 | }
112 | }
113 |
114 | private fun showError(message: String) {
115 | Toast.makeText(this, message, Toast.LENGTH_LONG).show()
116 | }
117 |
118 | override fun onCreateOptionsMenu(menu: Menu): Boolean {
119 | menuInflater.inflate(R.menu.home_menu, menu)
120 | menu.findItem(R.id.action_favorites).isVisible = favoritesFeatureEnable
121 | return super.onCreateOptionsMenu(menu)
122 | }
123 |
124 | override fun onOptionsItemSelected(item: MenuItem): Boolean {
125 | return when (item.itemId) {
126 | R.id.action_shows -> {
127 | AllShowsActivity.start(this)
128 | true
129 | }
130 | R.id.action_favorites -> {
131 | FavoriteShowsActivity.start(this)
132 | true
133 | }
134 | else -> super.onOptionsItemSelected(item)
135 | }
136 | }
137 |
138 | override fun onFavoriteClicked(showViewData: HomeViewData.ShowViewData) {
139 | homeViewModel.onFavoriteClick(showViewData)
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/app/src/main/java/com/android/tvflix/home/HomeViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import android.content.Context
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import com.android.tvflix.R
7 | import com.android.tvflix.analytics.Analytics
8 | import com.android.tvflix.analytics.Event
9 | import com.android.tvflix.analytics.EventNames
10 | import com.android.tvflix.analytics.EventParams
11 | import com.android.tvflix.db.favouriteshow.FavoriteShow
12 | import com.android.tvflix.di.IoDispatcher
13 | import com.android.tvflix.domain.AddToFavoritesUseCase
14 | import com.android.tvflix.domain.GetFavoriteShowsUseCase
15 | import com.android.tvflix.domain.GetSchedulesUseCase
16 | import com.android.tvflix.domain.RemoveFromFavoritesUseCase
17 | import com.android.tvflix.network.home.Episode
18 | import com.android.tvflix.utils.toFavoriteShow
19 | import dagger.hilt.android.lifecycle.HiltViewModel
20 | import dagger.hilt.android.qualifiers.ActivityContext
21 | import dagger.hilt.android.qualifiers.ApplicationContext
22 | import kotlinx.coroutines.CoroutineDispatcher
23 | import kotlinx.coroutines.CoroutineExceptionHandler
24 | import kotlinx.coroutines.flow.MutableStateFlow
25 | import kotlinx.coroutines.flow.asStateFlow
26 | import kotlinx.coroutines.launch
27 | import timber.log.Timber
28 | import java.util.*
29 | import javax.inject.Inject
30 | import kotlin.collections.ArrayList
31 |
32 | @HiltViewModel
33 | class HomeViewModel @Inject constructor(
34 | @ApplicationContext private val context: Context,
35 | private val getSchedulesUseCase: GetSchedulesUseCase,
36 | private val getFavoriteShowsUseCase: GetFavoriteShowsUseCase,
37 | private val addToFavoritesUseCase: AddToFavoritesUseCase,
38 | private val removeFromFavoritesUseCase: RemoveFromFavoritesUseCase,
39 | // Inject coroutineDispatcher to facilitate Unit Testing
40 | @IoDispatcher private val dispatcher: CoroutineDispatcher,
41 | private val analytics: Analytics
42 | ) : ViewModel() {
43 | private val _homeViewStateFlow = MutableStateFlow(HomeViewState.Loading)
44 |
45 | // Represents _homeViewStateFlow mutable state flow as a read-only state flow.
46 | val homeViewStateFlow = _homeViewStateFlow.asStateFlow()
47 |
48 | fun onScreenCreated() {
49 | val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
50 | onError(exception)
51 | }
52 | viewModelScope.launch(dispatcher + coroutineExceptionHandler) {
53 | val favoriteShowIds = getFavoriteShowsUseCase.invoke(Unit)
54 | val episodes = getSchedulesUseCase.invoke(Unit)
55 | _homeViewStateFlow.emit(
56 | HomeViewState.Success(
57 | HomeViewData(
58 | heading(),
59 | getShowsWithFavorites(
60 | episodes,
61 | favoriteShowIds
62 | )
63 | )
64 | )
65 | )
66 | }
67 | }
68 |
69 | fun heading(): String {
70 | return String.format(
71 | context.getString(R.string.popular_shows_airing_today),
72 | GetSchedulesUseCase.COUNTRY
73 | )
74 | }
75 |
76 | private fun getShowsWithFavorites(
77 | episodes: List,
78 | favoriteShowIds: List
79 | ): List {
80 | val episodeViewDataList = ArrayList(episodes.size)
81 | for (episode in episodes) {
82 | val show = episode.show
83 | val showViewData = if (favoriteShowIds.contains(show.id)) {
84 | HomeViewData.ShowViewData(show, true)
85 | } else {
86 | HomeViewData.ShowViewData(show, false)
87 | }
88 | val episodeViewData = HomeViewData.EpisodeViewData(
89 | id = episode.id,
90 | showViewData = showViewData, url = episode.url, name = episode.name,
91 | season = episode.season, number = episode.number, airdate = episode.airdate,
92 | airtime = episode.airtime, runtime = episode.runtime
93 | )
94 | episodeViewDataList.add(episodeViewData)
95 | }
96 | return episodeViewDataList
97 | }
98 |
99 | private fun onError(throwable: Throwable) {
100 | _homeViewStateFlow.value = HomeViewState.NetworkError(throwable.localizedMessage)
101 | Timber.e(throwable)
102 | }
103 |
104 | fun onFavoriteClick(showViewData: HomeViewData.ShowViewData) {
105 | viewModelScope.launch(dispatcher) {
106 | if (!showViewData.isFavoriteShow) {
107 | analytics.sendEvent(
108 | Event(
109 | EventNames.CLICK,
110 | hashMapOf(
111 | EventParams.CLICK_CONTEXT to "favorite",
112 | EventParams.CONTENT_ID to showViewData.show.id,
113 | EventParams.CONTENT_TITLE to showViewData.show.name,
114 | EventParams.IS_FAVORITE_MARKED to true
115 | )
116 | )
117 | )
118 | addToFavoritesUseCase.invoke(showViewData.show.toFavoriteShow())
119 | _homeViewStateFlow.emit(HomeViewState.AddedToFavorites(showViewData.show))
120 | } else {
121 | analytics.sendEvent(
122 | Event(
123 | EventNames.CLICK,
124 | hashMapOf(
125 | EventParams.CLICK_CONTEXT to "favorite",
126 | EventParams.CONTENT_ID to showViewData.show.id,
127 | EventParams.CONTENT_TITLE to showViewData.show.name,
128 | EventParams.IS_FAVORITE_MARKED to false
129 | )
130 | )
131 | )
132 | removeFromFavoritesUseCase.invoke(showViewData.show.toFavoriteShow())
133 | _homeViewStateFlow.emit(HomeViewState.RemovedFromFavorites(showViewData.show))
134 | }
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/app/src/test/java/com/android/tvflix/home/HomeViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.android.tvflix.home
2 |
3 | import android.content.Context
4 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
5 | import androidx.test.core.app.ApplicationProvider
6 | import com.android.tvflix.analytics.Analytics
7 | import com.android.tvflix.domain.AddToFavoritesUseCase
8 | import com.android.tvflix.domain.GetFavoriteShowsUseCase
9 | import com.android.tvflix.domain.GetSchedulesUseCase
10 | import com.android.tvflix.domain.RemoveFromFavoritesUseCase
11 | import com.android.tvflix.utils.MainCoroutineRule
12 | import com.android.tvflix.utils.TestUtil
13 | import com.android.tvflix.utils.runBlockingTest
14 | import com.google.common.truth.Truth.assertThat
15 | import kotlinx.coroutines.ExperimentalCoroutinesApi
16 | import kotlinx.coroutines.flow.first
17 | import org.junit.After
18 | import org.junit.Before
19 | import org.junit.Rule
20 | import org.junit.Test
21 | import org.junit.runner.RunWith
22 | import org.mockito.Mock
23 | import org.mockito.Mockito
24 | import org.mockito.MockitoAnnotations
25 | import org.mockito.kotlin.whenever
26 | import org.robolectric.RobolectricTestRunner
27 | import org.robolectric.annotation.Config
28 |
29 | @RunWith(RobolectricTestRunner::class)
30 | @Config(manifest = Config.NONE)
31 | @ExperimentalCoroutinesApi
32 | class HomeViewModelTest {
33 | private val context = ApplicationProvider.getApplicationContext()
34 |
35 | // Executes tasks in the Architecture Components in the same thread
36 | @get:Rule
37 | val instantTaskExecutorRule = InstantTaskExecutorRule()
38 |
39 | // Set the main coroutines dispatcher for unit testing.
40 | @get:Rule
41 | var coroutineRule = MainCoroutineRule()
42 |
43 | @Mock
44 | private lateinit var getSchedulesUseCase: GetSchedulesUseCase
45 |
46 | @Mock
47 | private lateinit var addToFavoritesUseCase: AddToFavoritesUseCase
48 |
49 | @Mock
50 | private lateinit var removeFromFavoritesUseCase: RemoveFromFavoritesUseCase
51 |
52 | @Mock
53 | private lateinit var getFavoriteShowsUseCase: GetFavoriteShowsUseCase
54 |
55 | private val testDispatcher = coroutineRule.testDispatcher
56 |
57 | @Mock
58 | private lateinit var analytics: Analytics
59 |
60 | @Before
61 | fun setUp() {
62 | MockitoAnnotations.openMocks(this)
63 | }
64 |
65 | @Test
66 | fun `test if home is loaded with shows and without favorites`() {
67 | coroutineRule.runBlockingTest {
68 | // Stubbing network calls with fake episode list
69 | whenever(getSchedulesUseCase.invoke(Unit))
70 | .thenReturn(TestUtil.getFakeEpisodeList())
71 | // Stub repository with empty list
72 | whenever(getFavoriteShowsUseCase.invoke(Unit))
73 | .thenReturn(emptyList())
74 |
75 | val homeViewModel = createHomeViewModel()
76 | homeViewModel.onScreenCreated()
77 |
78 | // Observe on `first` terminal operator for homeViewStateFlow
79 | val homeState = homeViewModel.homeViewStateFlow.first() as HomeViewState.Success
80 | assertThat(homeState).isNotNull()
81 | val episodes = homeState.homeViewData.episodes
82 | assertThat(episodes.isNotEmpty()).isTrue()
83 | // compare the response with fake list
84 | assertThat(episodes).hasSize(TestUtil.getFakeEpisodeList().size)
85 | // compare the data and also order
86 | assertThat(episodes).containsExactlyElementsIn(
87 | TestUtil.getFakeEpisodeViewDataList(
88 | false
89 | )
90 | ).inOrder()
91 | }
92 | }
93 |
94 | private fun createHomeViewModel(): HomeViewModel {
95 | return HomeViewModel(
96 | context,
97 | getSchedulesUseCase,
98 | getFavoriteShowsUseCase,
99 | addToFavoritesUseCase,
100 | removeFromFavoritesUseCase,
101 | testDispatcher,
102 | analytics
103 | )
104 | }
105 |
106 | @Test
107 | fun `test if home is loaded with shows and favorites`() {
108 | coroutineRule.runBlockingTest {
109 | // Stubbing network calls with fake episode list
110 | whenever(getSchedulesUseCase.invoke(Unit))
111 | .thenReturn(TestUtil.getFakeEpisodeList())
112 | // Stub repository with fake favorites
113 | whenever(getFavoriteShowsUseCase.invoke(Unit))
114 | .thenReturn(arrayListOf(1, 2))
115 | val homeViewModel = createHomeViewModel()
116 | homeViewModel.onScreenCreated()
117 | // Observe on home view state for the first item emitted by flow
118 | val homeState = homeViewModel.homeViewStateFlow.first() as HomeViewState.Success
119 | assertThat(homeState).isNotNull()
120 | val episodes = homeState.homeViewData.episodes
121 | assertThat(episodes.isNotEmpty()).isTrue()
122 | // compare the response with fake list
123 | assertThat(episodes).hasSize(TestUtil.getFakeEpisodeList().size)
124 | // compare the data and also order
125 | assertThat(episodes).containsExactlyElementsIn(
126 | TestUtil.getFakeEpisodeViewDataList(
127 | true
128 | )
129 | ).inOrder()
130 | }
131 | }
132 |
133 | @Test
134 | fun `test for failure`() {
135 | coroutineRule.runBlockingTest {
136 | val homeViewModel = createHomeViewModel()
137 | // Stubbing network calls with fake episode list
138 | whenever(getSchedulesUseCase.invoke(Unit))
139 | .thenThrow(RuntimeException("Error occurred"))
140 | // Stub repository with fake favorites
141 | whenever(getFavoriteShowsUseCase.invoke(Unit))
142 | .thenReturn(arrayListOf(1, 2))
143 |
144 | homeViewModel.onScreenCreated()
145 | val homeState = homeViewModel.homeViewStateFlow.first() as HomeViewState.NetworkError
146 | assertThat(homeState.message).isNotNull()
147 | assertThat(homeState.message).isEqualTo("Error occurred")
148 | }
149 | }
150 |
151 | @After
152 | fun release() {
153 | Mockito.framework().clearInlineMocks()
154 | }
155 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |   [](https://opensource.org/licenses/MIT) [](https://android-arsenal.com/api?level=21)  
2 |
3 |
4 | # TvFlix :tv:
5 |
6 | The aim of this app is to replicate the high level functionality of www.tvmaze.com and showcase an android app out of it.
7 | It connects with [TVDB API](https://api.thetvdb.com) to give you popular shows and let you mark anyone as favorite.
8 | TvFlix consists of 3 pieces of UI right now:
9 | 1. Home with Popular Shows
10 | 2. Favorites
11 | 3. All Shows
12 |
13 | This app is under development. :construction_worker: :hammer_and_wrench:
14 |
15 | *Note: TvFlix is an unofficial app built only for learning and sharing the latest concepts with #AndroidDevs*
16 |
17 | ## Android Development and Architecture
18 |
19 | * The entire codebase is in [Kotlin](https://kotlinlang.org/)
20 | * Uses Kotlin [Coroutines](https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html).
21 | * Uses MVVM Architecture by [Architecture Components](https://developer.android.com/topic/libraries/architecture/). Room, ViewModel, Paging
22 | * Uses [Hilt Android](https://developer.android.com/training/dependency-injection/hilt-android) with [Dagger](https://dagger.dev/) for dependency injection
23 | * Unit Testing by [Mockito](https://github.com/mockito/mockito)
24 | * Tests Coroutines and architecture components like ViewModel
25 | * UI Test by [Espresso](https://developer.android.com/training/testing/espresso) based on [Robot Pattern](https://academy.realm.io/posts/kau-jake-wharton-testing-robots/)
26 | * Uses [Kotlin Coroutines Test](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/) to unit test Kotlin Coroutines
27 | * Uses [StateFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) as a replacement over LiveData as a state-holder observable
28 | * Uses [Firebase Remote Config](https://firebase.google.com/products/remote-config) for experimentation and feature rollout
29 | * Uses [Firebase App Distribution](https://firebase.google.com/products/app-distribution) for internal distribution and quality testing
30 |
31 | ## Further Reading
32 |
33 | There are several articles written on this repository which state the design and architecture.
34 |
35 | ### Kotlin Everywhere. Coroutines, Tests, Robots and much more…
36 |
37 | The TvFlix complete repository has been re-written in Kotlin with Coroutines covering
38 | Unit Tests across ViewModels and UI tests for the app.
39 | Know more:
40 | [Kotlin Everywhere. Coroutines, Tests, Robots and much more…](https://proandroiddev.com/kotlin-everywhere-coroutines-tests-robots-and-much-more-b02030206cc9)
41 |
42 | ### MVVM using Android Architecture Components
43 |
44 | The codebase tries to follow Uncle Bob Clean Code Architecture with [SOLID principles](https://en.wikipedia.org/wiki/SOLID).
45 | Know more:
46 | [Migration from MVP to MVVM using Android Architecture Components](https://medium.com/@kumarashwini/migration-from-mvp-to-mvvm-using-android-architecture-components-4bc058a1f73c)
47 |
48 | ### Pagination using Paging Library
49 |
50 | The Shows screen displays the list of shows fetched from TvMaze API using [Paging3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) of Android Architecture Components. It also handles the retry if any network error occurred. Recently the repository has been [migrated to use Paging3](https://github.com/reactivedroid/TvFlix/pull/14).
51 | Paging3 is in heavy development, and if you want to catch up with stable library(Paging 2), then check out this blog
52 | [Pagination using Paging Library with RxJava and Dagger](https://medium.com/@kumarashwini/pagination-using-paging-library-with-rxjava-and-dagger-d9d05dbd8eac)
53 |
54 | ### Room Persistence Library
55 |
56 | The Favourites screen displays the list of shows marked favourites from the Home screen. The user can add/remove from
57 | the favorites as and when required. The implementation of the favorites is done using `Room` Persistence Library with RxJava and Dagger.
58 | Know more:
59 | [Room with RxJava and Dagger](https://medium.com/@kumarashwini/room-with-rxjava-and-dagger-2722f4420651)
60 |
61 | ### Static Code Analysis
62 |
63 | TvFlix has Static Code Analysis tools like FindBugs, PMD and Checkstyle integrated. These tools help in finding potential bugs that would have been missed and help in making the codebase clean.
64 | Know more:
65 | [Static Code Analysis for Android Using FindBugs, PMD and CheckStyle](https://blog.mindorks.com/static-code-analysis-for-android-using-findbugs-pmd-and-checkstyle-3a2861834c6a)
66 |
67 | ## Contributions
68 |
69 | If you have found an issue in this sample, please file it.
70 | Better yet, if you want to contribute to the repository, go ahead, any kind of patches are encouraged,
71 | and may be submitted by forking this project and submitting a pull request.
72 | If you have something big in mind, or any architectural change, please raise an issue first to discuss it.
73 |
74 | ## License
75 |
76 | ```
77 | Copyright (c) 2020 Ashwini Kumar
78 |
79 | Permission is hereby granted, free of charge, to any person obtaining a copy
80 | of this software and associated documentation files (the "Software"), to deal
81 | in the Software without restriction, including without limitation the rights
82 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
83 | copies of the Software, and to permit persons to whom the Software is
84 | furnished to do so, subject to the following conditions:
85 |
86 | The above copyright notice and this permission notice shall be included in all
87 | copies or substantial portions of the Software.
88 |
89 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
90 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
91 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
92 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
93 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
94 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
95 | SOFTWARE.
96 | ```
97 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 | # Determine the Java command to use to start the JVM.
86 | if [ -n "$JAVA_HOME" ] ; then
87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
88 | # IBM's JDK on AIX uses strange locations for the executables
89 | JAVACMD="$JAVA_HOME/jre/sh/java"
90 | else
91 | JAVACMD="$JAVA_HOME/bin/java"
92 | fi
93 | if [ ! -x "$JAVACMD" ] ; then
94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
95 |
96 | Please set the JAVA_HOME variable in your environment to match the
97 | location of your Java installation."
98 | fi
99 | else
100 | JAVACMD="java"
101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
102 |
103 | Please set the JAVA_HOME variable in your environment to match the
104 | location of your Java installation."
105 | fi
106 |
107 | # Increase the maximum file descriptors if we can.
108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
109 | MAX_FD_LIMIT=`ulimit -H -n`
110 | if [ $? -eq 0 ] ; then
111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
112 | MAX_FD="$MAX_FD_LIMIT"
113 | fi
114 | ulimit -n $MAX_FD
115 | if [ $? -ne 0 ] ; then
116 | warn "Could not set maximum file descriptor limit: $MAX_FD"
117 | fi
118 | else
119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
120 | fi
121 | fi
122 |
123 | # For Darwin, add options to specify how the application appears in the dock
124 | if $darwin; then
125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
126 | fi
127 |
128 | # For Cygwin or MSYS, switch paths to Windows format before running java
129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
132 | JAVACMD=`cygpath --unix "$JAVACMD"`
133 |
134 | # We build the pattern for arguments to be converted via cygpath
135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
136 | SEP=""
137 | for dir in $ROOTDIRSRAW ; do
138 | ROOTDIRS="$ROOTDIRS$SEP$dir"
139 | SEP="|"
140 | done
141 | OURCYGPATTERN="(^($ROOTDIRS))"
142 | # Add a user-defined pattern to the cygpath arguments
143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
145 | fi
146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
147 | i=0
148 | for arg in "$@" ; do
149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
151 |
152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
154 | else
155 | eval `echo args$i`="\"$arg\""
156 | fi
157 | i=`expr $i + 1`
158 | done
159 | case $i in
160 | 0) set -- ;;
161 | 1) set -- "$args0" ;;
162 | 2) set -- "$args0" "$args1" ;;
163 | 3) set -- "$args0" "$args1" "$args2" ;;
164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
170 | esac
171 | fi
172 |
173 | # Escape application args
174 | save () {
175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
176 | echo " "
177 | }
178 | APP_ARGS=`save "$@"`
179 |
180 | # Collect all arguments for the java command, following the shell quoting and substitution rules
181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
182 |
183 | exec "$JAVACMD" "$@"
184 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | kotlin("android")
4 | kotlin("kapt")
5 | kotlin("plugin.parcelize")
6 | id("dagger.hilt.android.plugin")
7 | id("com.google.gms.google-services")
8 | id("com.google.firebase.crashlytics")
9 | id("com.google.firebase.firebase-perf")
10 | id("com.google.firebase.appdistribution")
11 | }
12 |
13 | apply {
14 | from(rootProject.file("tools/checkstyle.gradle"))
15 | from(rootProject.file("tools/pmd.gradle"))
16 | }
17 |
18 | android {
19 | compileSdk = Deps.Versions.compile_sdk
20 |
21 | buildFeatures {
22 | viewBinding = true
23 | dataBinding = true
24 | buildConfig = true
25 | }
26 |
27 | defaultConfig {
28 | applicationId = "com.android.tvflix"
29 | minSdk = Deps.Versions.min_sdk
30 | targetSdk = Deps.Versions.target_sdk
31 | versionCode = Deps.Versions.app_version_code
32 | versionName = Deps.Versions.app_version_name
33 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
34 | javaCompileOptions {
35 | annotationProcessorOptions {
36 | // Refer https://developer.android.com/jetpack/androidx/releases/room#compiler-options
37 | arguments(
38 | mapOf(
39 | "room.schemaLocation" to "$projectDir/schemas",
40 | "room.incremental" to "true",
41 | "room.expandProjection" to "true"
42 | )
43 | )
44 | }
45 | }
46 | }
47 | flavorDimensions.addAll(listOf("default"))
48 | productFlavors {
49 | create("prod") {
50 | dimension = "default"
51 | applicationId = "com.android.tvflix"
52 | firebaseAppDistribution {
53 | releaseNotesFile = "release-notes.txt"
54 | groupsFile = "tester-groups.txt"
55 | serviceCredentialsFile = "tvflix-b45cd-5fa9e2fb3108.json"
56 | }
57 | }
58 | create("dev") {
59 | applicationId = "com.android.tvflix"
60 | firebaseAppDistribution {
61 | releaseNotesFile = "release-notes.txt"
62 | groupsFile = "tester-groups.txt"
63 | serviceCredentialsFile = "tvflix-b45cd-5fa9e2fb3108.json"
64 | }
65 | }
66 | }
67 | buildTypes {
68 | getByName("release") {
69 | isMinifyEnabled = true
70 | isShrinkResources = true
71 | isDebuggable = false
72 | proguardFiles(
73 | getDefaultProguardFile("proguard-android-optimize.txt"),
74 | "proguard-rules.pro"
75 | )
76 | }
77 | }
78 | compileOptions {
79 | sourceCompatibility = JavaVersion.VERSION_11
80 | targetCompatibility = JavaVersion.VERSION_11
81 | }
82 |
83 | testOptions {
84 | unitTests.isIncludeAndroidResources = true
85 | animationsDisabled = true
86 | }
87 |
88 | kapt {
89 | useBuildCache = true
90 | javacOptions {
91 | // Increase the max count of errors from annotation processors.
92 | // Default is 100.
93 | option("-Xmaxerrs", 500)
94 | }
95 | }
96 | testBuildType = "debug"
97 |
98 | packagingOptions {
99 | resources.excludes.addAll(
100 | listOf(
101 | "META-INF/ASL2.0",
102 | "META-INF/DEPENDENCIES",
103 | "META-INF/NOTICE",
104 | "META-INF/LICENSE",
105 | "META-INF/LICENSE.txt",
106 | "META-INF/NOTICE.txt",
107 | ".readme"
108 | )
109 | )
110 | }
111 |
112 | kotlinOptions {
113 | jvmTarget = "11"
114 | }
115 | }
116 |
117 | dependencies {
118 | implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
119 | implementation(Deps.Google.material)
120 | // start-region AndroidX
121 | implementation(Deps.AndroidX.ktx_core)
122 | implementation(Deps.AndroidX.ktx_fragment)
123 | implementation(Deps.AndroidX.ktx_activity)
124 | implementation(Deps.AndroidX.constraint_layout)
125 | kapt(Deps.AndroidX.Lifecycle.compiler)
126 | implementation(Deps.AndroidX.Lifecycle.viewmodel)
127 | implementation(Deps.AndroidX.Paging.runtime)
128 | testImplementation(Deps.AndroidX.Paging.common)
129 | implementation(Deps.AndroidX.Room.runtime)
130 | kapt(Deps.AndroidX.Room.compiler)
131 | testImplementation(Deps.AndroidX.Room.testing)
132 | implementation(Deps.AndroidX.Room.ktx)
133 | implementation(Deps.AndroidX.annotation)
134 | // end-region AndroidX
135 |
136 | implementation(platform(Deps.OkHttp.okhttp_bom))
137 | implementation(Deps.OkHttp.main)
138 | implementation(Deps.OkHttp.logging_interceptor)
139 | implementation(Deps.Glide.runtime)
140 | implementation(Deps.Glide.okhttp_integration)
141 | kapt(Deps.Glide.compiler)
142 | implementation(Deps.Retrofit.main)
143 | implementation(Deps.Retrofit.moshi)
144 |
145 | implementation(Deps.timber)
146 |
147 | // start-region Test
148 | testImplementation(Deps.junit)
149 | testImplementation(Deps.Test.Mockito.core)
150 | androidTestImplementation(Deps.Test.Mockito.android)
151 | testImplementation(Deps.Test.Mockito.kotlin)
152 | testImplementation(Deps.Test.Mockito.inline)
153 | testImplementation(Deps.AndroidX.Test.arch_core_testing)
154 | testImplementation(Deps.AndroidX.Test.core)
155 | androidTestImplementation(Deps.AndroidX.Test.runner)
156 | androidTestImplementation(Deps.AndroidX.Test.junit)
157 | // Espresso
158 | androidTestImplementation(Deps.AndroidX.Test.Espresso.core)
159 | androidTestImplementation(Deps.AndroidX.Test.Espresso.contrib)
160 | androidTestImplementation(Deps.AndroidX.Test.Espresso.idling_resource)
161 | androidTestImplementation(Deps.AndroidX.Test.rules)
162 | testImplementation(Deps.Test.truth)
163 | testImplementation(Deps.Test.robolectric)
164 | testImplementation(Deps.Coroutines.test)
165 | // end-region Test
166 |
167 | implementation(Deps.Moshi.kotlin)
168 | kapt(Deps.Moshi.codegen)
169 |
170 | implementation(Deps.Coroutines.core)
171 | implementation(Deps.Coroutines.android)
172 |
173 | debugImplementation(Deps.Chucker.debug)
174 | releaseImplementation(Deps.Chucker.release)
175 |
176 | implementation(Deps.Hilt.android)
177 | kapt(Deps.Hilt.android_compiler)
178 |
179 | // start-region Firebase
180 | implementation(platform(Deps.Firebase.firebase_bom))
181 | implementation(Deps.Firebase.crashlytics)
182 | implementation(Deps.Firebase.performance_monitoring)
183 | implementation(Deps.Firebase.analytics)
184 | implementation(Deps.Firebase.remote_config)
185 | // end-region Firebase
186 | }
187 |
188 |
189 |
--------------------------------------------------------------------------------