├── git
├── app
├── .gitignore
├── src
│ ├── test
│ │ ├── resources
│ │ │ └── mockito-extensions
│ │ │ │ └── org.mockito.plugins.MockMaker
│ │ └── java
│ │ │ └── ahmed
│ │ │ └── atwa
│ │ │ └── popularmovies
│ │ │ ├── CoroutineTestingRule.kt
│ │ │ └── movies
│ │ │ ├── domain
│ │ │ └── MovieMapperImpTest.kt
│ │ │ └── presentation
│ │ │ └── MoviesViewModelTest.kt
│ ├── main
│ │ ├── res
│ │ │ ├── drawable
│ │ │ │ ├── like.png
│ │ │ │ ├── dislike.png
│ │ │ │ ├── placeholder.png
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── values
│ │ │ │ ├── dimens.xml
│ │ │ │ ├── styles.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── strings.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── menu
│ │ │ │ └── search_menu.xml
│ │ │ ├── layout
│ │ │ │ ├── progress_dialog.xml
│ │ │ │ ├── activity_main.xml
│ │ │ │ ├── item_movie_view.xml
│ │ │ │ ├── load_state_layout.xml
│ │ │ │ ├── fragment_movies.xml
│ │ │ │ ├── item_trailer_view.xml
│ │ │ │ └── fragment_detail.xml
│ │ │ ├── navigation
│ │ │ │ └── movies_graph.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── ahmed
│ │ │ │ └── atwa
│ │ │ │ └── popularmovies
│ │ │ │ ├── utils
│ │ │ │ ├── network
│ │ │ │ │ ├── INetworkErrorHandler.kt
│ │ │ │ │ ├── ResultType.kt
│ │ │ │ │ ├── NoConnectionException.kt
│ │ │ │ │ ├── UrlModule.kt
│ │ │ │ │ ├── NetworkErrorHandler.kt
│ │ │ │ │ ├── NetworkRouter.kt
│ │ │ │ │ ├── NetworkConnectionInterceptor.kt
│ │ │ │ │ └── NetworkModule.kt
│ │ │ │ ├── commons
│ │ │ │ │ ├── CoroutineDispatcher.kt
│ │ │ │ │ ├── TestDispatcher.kt
│ │ │ │ │ ├── AppConstants.kt
│ │ │ │ │ ├── ViewModelProviderFactory.kt
│ │ │ │ │ ├── AppUtils.kt
│ │ │ │ │ ├── GridSpacingItemDecoration.kt
│ │ │ │ │ └── LiveEvent.kt
│ │ │ │ ├── database
│ │ │ │ │ ├── MovieDao.kt
│ │ │ │ │ ├── MovieDb.kt
│ │ │ │ │ └── DbModule.kt
│ │ │ │ └── di
│ │ │ │ │ ├── module
│ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ └── RepoModule.kt
│ │ │ │ │ ├── builder
│ │ │ │ │ └── ActivityBuilder.kt
│ │ │ │ │ └── component
│ │ │ │ │ └── AppComponent.kt
│ │ │ │ ├── detail
│ │ │ │ ├── data
│ │ │ │ │ ├── TrailerResponse.kt
│ │ │ │ │ ├── TrailerRemote.kt
│ │ │ │ │ └── TrailerApi.kt
│ │ │ │ ├── presentation
│ │ │ │ │ ├── DetailViewState.kt
│ │ │ │ │ ├── TrailerAdapter.kt
│ │ │ │ │ └── DetailFragment.kt
│ │ │ │ └── di
│ │ │ │ │ ├── DetailFragmentProvider.kt
│ │ │ │ │ └── DetailFragmentModule.kt
│ │ │ │ ├── movies
│ │ │ │ ├── presentation
│ │ │ │ │ ├── MoviesViewState.kt
│ │ │ │ │ ├── MovieStateAdapter.kt
│ │ │ │ │ ├── MovieAdapter.kt
│ │ │ │ │ ├── MoviesViewModel.kt
│ │ │ │ │ └── MoviesFragment.kt
│ │ │ │ ├── data
│ │ │ │ │ ├── MovieResponse.kt
│ │ │ │ │ ├── MovieRepo.kt
│ │ │ │ │ ├── MovieApi.kt
│ │ │ │ │ ├── MovieFilterSource.kt
│ │ │ │ │ ├── MoviePagingSource.kt
│ │ │ │ │ ├── Movie.kt
│ │ │ │ │ └── MovieRepoImp.kt
│ │ │ │ ├── di
│ │ │ │ │ ├── MoviesFragmentProvider.kt
│ │ │ │ │ ├── MovieSourceModule.kt
│ │ │ │ │ └── MoviesFragmentModule.kt
│ │ │ │ └── domain
│ │ │ │ │ └── MovieSourceFactory.kt
│ │ │ │ ├── base
│ │ │ │ ├── BaseViewModel.kt
│ │ │ │ ├── BaseFragment.kt
│ │ │ │ └── BaseActivity.kt
│ │ │ │ ├── PopMovApp.kt
│ │ │ │ └── main
│ │ │ │ ├── di
│ │ │ │ └── MainActivityModule.kt
│ │ │ │ └── presentation
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ └── androidTest
│ │ └── java
│ │ └── ahmed
│ │ └── atwa
│ │ └── popularmovies
│ │ ├── util
│ │ ├── UiRunner.kt
│ │ └── CustomMatcher.kt
│ │ ├── utils
│ │ └── di
│ │ │ ├── MockDbModule.kt
│ │ │ ├── MockUrlModule.kt
│ │ │ └── TestAppComponent.kt
│ │ ├── movie
│ │ └── presentation
│ │ │ └── MoviesFragmentTest.kt
│ │ ├── main
│ │ └── presentation
│ │ │ └── MainActivityTest.kt
│ │ └── detail
│ │ └── presentation
│ │ └── DetailFragmentTest.kt
├── gradle
│ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
├── .idea
│ ├── modules.xml
│ ├── misc.xml
│ ├── runConfigurations.xml
│ ├── gradle.xml
│ ├── codeStyles
│ │ └── Project.xml
│ └── workspace.xml
├── local.properties
├── proguard-rules.pro
├── gradlew.bat
├── build.gradle
└── gradlew
├── detail.png
├── movies.png
├── detail_liked.png
├── movies_search.png
├── movies_search_hint.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .idea
├── caches
│ └── build_file_checksums.ser
├── encodings.xml
├── vcs.xml
├── compiler.xml
├── runConfigurations.xml
├── DtonatorPreferences.xml
├── gradle.xml
├── jarRepositories.xml
├── navEditor.xml
├── codeStyles
│ └── Project.xml
└── misc.xml
├── settings.gradle
├── .gitignore
├── gradle.properties
├── README.md
├── gradlew.bat
└── gradlew
/git:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/detail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/detail.png
--------------------------------------------------------------------------------
/movies.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/movies.png
--------------------------------------------------------------------------------
/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker:
--------------------------------------------------------------------------------
1 | mock-maker-inline
2 |
--------------------------------------------------------------------------------
/detail_liked.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/detail_liked.png
--------------------------------------------------------------------------------
/movies_search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/movies_search.png
--------------------------------------------------------------------------------
/movies_search_hint.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/movies_search_hint.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable/like.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/drawable/like.png
--------------------------------------------------------------------------------
/.idea/caches/build_file_checksums.ser:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/.idea/caches/build_file_checksums.ser
--------------------------------------------------------------------------------
/app/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable/dislike.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/drawable/dislike.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/drawable/placeholder.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Atwa/PopularMovies-MVVM-Sample/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | /*
2 | * *
3 | * * Created by Ahmed Atwa on 19/10/2018
4 | * * Copyright (c) 19/10/2018 . All rights reserved.
5 | * *
6 | */
7 |
8 | include ':app'
9 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/libraries
5 | /.idea/modules.xml
6 | /.idea/workspace.xml
7 | .DS_Store
8 | /build
9 | /captures
10 | .externalNativeBuild
11 |
12 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/INetworkErrorHandler.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.network
2 |
3 | import retrofit2.Response
4 |
5 | interface INetworkErrorHandler {
6 | fun resolveErrorMessage(response: Response): ResultType.Error
7 | }
--------------------------------------------------------------------------------
/app/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sun Jun 13 21:59:53 EET 2021
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-bin.zip
7 |
--------------------------------------------------------------------------------
/app/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Dec 23 16:04:48 EET 2019
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
7 |
--------------------------------------------------------------------------------
/app/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/ResultType.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.network
2 |
3 | /**
4 | * Created by Ahmed Atwa on 11/7/2019.
5 | */
6 | sealed class ResultType {
7 | data class Success(val data: T) : ResultType()
8 | data class Error(val error: Exception) : ResultType()
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/commons/CoroutineDispatcher.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.commons
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlin.coroutines.CoroutineContext
5 |
6 | open class CoroutineDispatcher {
7 | open val Main: CoroutineContext by lazy { Dispatchers.Main }
8 | open val IO: CoroutineContext by lazy { Dispatchers.IO }
9 | }
--------------------------------------------------------------------------------
/app/local.properties:
--------------------------------------------------------------------------------
1 | ## This file must *NOT* be checked into Version Control Systems,
2 | # as it contains information specific to your local configuration.
3 | #
4 | # Location of the SDK. This is only used by Gradle.
5 | # For customization when using a Version Control System, please read the
6 | # header note.
7 | #Mon Dec 23 16:04:24 EET 2019
8 | sdk.dir=C\:\\Users\\aatwa\\AppData\\Local\\Android\\Sdk
9 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/commons/TestDispatcher.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.commons
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlin.coroutines.CoroutineContext
5 |
6 | class TestDispatcher : CoroutineDispatcher() {
7 | override val Main: CoroutineContext = Dispatchers.Unconfined
8 | override val IO: CoroutineContext = Dispatchers.Unconfined
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/data/TrailerResponse.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.data
2 |
3 | import android.os.Parcelable
4 | import kotlinx.android.parcel.Parcelize
5 |
6 | /**
7 | * Created by Ahmed Atwa on 10/19/18.
8 | */
9 | @Parcelize
10 | data class TrailerResponse(
11 | var id: Int,
12 | var results: List
13 | ) : Parcelable
14 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/NoConnectionException.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.network
2 |
3 | import java.io.IOException
4 |
5 | /**
6 | * Created by Ahmed Atwa on 11/7/2019.
7 | */
8 | class NoConnectionException : IOException() {
9 |
10 | // You can send any message whatever you want from here.
11 | override val message: String
12 | get() = "No Internet Connection"
13 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/presentation/MoviesViewState.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.presentation
2 |
3 | import ahmed.atwa.popularmovies.movies.data.Movie
4 | import androidx.paging.PagingData
5 |
6 |
7 | sealed class MoviesViewState {
8 | class FetchingMoviesError(val errorMessage: String?) : MoviesViewState()
9 | class FetchingMoviesSuccess(val movies: PagingData) : MoviesViewState()
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 8dp
5 | 150dp
6 | 250dp
7 | 30dp
8 | 60dp
9 | 56dp
10 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/data/MovieResponse.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.data
2 |
3 |
4 | import android.os.Parcelable
5 | import kotlinx.android.parcel.Parcelize
6 |
7 | /**
8 | * Created by Ahmed Atwa on 10/19/18.
9 | */
10 | @Parcelize
11 | data class MovieResponse(
12 | var page: Int?,
13 | var total_results: Int?,
14 | var total_pages: Int,
15 | var results: ArrayList) : Parcelable
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/database/MovieDao.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.database
2 |
3 | import ahmed.atwa.popularmovies.movies.data.Movie
4 | import androidx.room.*
5 |
6 | @Dao
7 | interface MovieDao {
8 |
9 | @Insert(onConflict = OnConflictStrategy.REPLACE)
10 | fun insertMovie(movie: Movie)
11 |
12 | @Query("SELECT id FROM movies")
13 | fun fetchFavouriteMovies(): List
14 |
15 | @Delete()
16 | fun removeMovie(movie: Movie)
17 |
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/database/MovieDb.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.database
2 |
3 | import ahmed.atwa.popularmovies.movies.data.Movie
4 | import androidx.room.*
5 | import javax.inject.Singleton
6 |
7 | /**
8 | * Created by Ahmed Atwa on 10/19/18.
9 | */
10 |
11 | @Singleton
12 | @Database(entities = [(Movie::class)], version = 1, exportSchema = false)
13 | abstract class MovieDb : RoomDatabase() {
14 | abstract fun movieDao(): MovieDao
15 | }
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/search_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/data/TrailerRemote.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.data
2 |
3 | import android.os.Parcelable
4 | import kotlinx.android.parcel.Parcelize
5 |
6 | @Parcelize
7 | data class TrailerRemote(
8 | val id: String,
9 | val iso_639_1: String,
10 | val iso_3166_1: String,
11 | val key: String,
12 | val name: String,
13 | val site: String,
14 | var size: Int,
15 | val type: String
16 | ) : Parcelable
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/presentation/DetailViewState.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.presentation
2 |
3 | import ahmed.atwa.popularmovies.detail.data.TrailerRemote
4 |
5 | sealed class DetailViewState {
6 | object TrailersFetchedError : DetailViewState()
7 | class TrailersFetchedSuccess(val trailers: List) : DetailViewState()
8 | class LikeState(val isLiked: Boolean) : DetailViewState()
9 | class MessageRes(val resId: Int) : DetailViewState()
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/commons/AppConstants.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.commons
2 |
3 | /**
4 | * Created by Ahmed Atwa on 10/19/18.
5 | */
6 |
7 | class AppConstants {
8 |
9 | companion object {
10 |
11 | const val DB_NAME = "PopMov.db"
12 | const val DB_MOCK_NAME = "PopMovMock.db"
13 | const val API_KEY_QUERY: String = "api_key"
14 | const val BASE_URL_KEY: String = ("BASE_URL")
15 | const val DB_NAME_KEY: String = ("Database_name")
16 | }
17 |
18 |
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/UrlModule.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.network
2 |
3 | import ahmed.atwa.popularmovies.BuildConfig
4 | import ahmed.atwa.popularmovies.utils.commons.AppConstants.Companion.BASE_URL_KEY
5 | import dagger.Module
6 | import dagger.Provides
7 | import javax.inject.Named
8 | import javax.inject.Singleton
9 |
10 | @Module
11 | class UrlModule {
12 | @Provides
13 | @Singleton
14 | @Named(BASE_URL_KEY)
15 | fun provideBaseUrl(): String {
16 | return BuildConfig.BASE_URL
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/di/module/AppModule.kt:
--------------------------------------------------------------------------------
1 |
2 |
3 | package ahmed.atwa.popularmovies.utils.di.module
4 |
5 | import android.app.Application
6 | import android.content.Context
7 | import dagger.Module
8 | import dagger.Provides
9 | import javax.inject.Singleton
10 |
11 |
12 | /**
13 | * Created by Ahmed Atwa on 10/19/18.
14 | */
15 |
16 |
17 | @Module
18 | class AppModule {
19 |
20 |
21 | @Provides
22 | @Singleton
23 | internal fun provideContext(application: Application): Context {
24 | return application
25 | }
26 |
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/data/MovieRepo.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.data
2 |
3 | import ahmed.atwa.popularmovies.detail.data.TrailerResponse
4 | import ahmed.atwa.popularmovies.utils.network.ResultType
5 |
6 | interface MovieRepo {
7 | suspend fun getPopularMovies(page: Int): ResultType
8 | suspend fun getFilteredPopularMovies(filterText: String): MovieResponse?
9 | suspend fun fetchMovieTrailers(movieId: Int): ResultType?
10 | fun isMovieLiked(id: Int): Boolean
11 | fun changeLikeState(movie: Movie, newLikeState: Boolean)
12 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
--------------------------------------------------------------------------------
/app/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | @color/blue_black_light
4 | @color/black
5 | #0d7cb3
6 | #000000
7 | #ffffff
8 | #2a2a2a
9 | #F06423
10 | #D85A1B
11 | #13171d
12 | #516179
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/di/DetailFragmentProvider.kt:
--------------------------------------------------------------------------------
1 |
2 |
3 | package ahmed.atwa.popularmovies.detail.di
4 |
5 | import ahmed.atwa.popularmovies.detail.di.DetailFragmentModule
6 | import ahmed.atwa.popularmovies.detail.presentation.DetailFragment
7 | import dagger.Module
8 | import dagger.android.ContributesAndroidInjector
9 |
10 | /**
11 | * Created by Ahmed Atwa on 10/19/18.
12 | */
13 |
14 | @Module
15 | abstract class DetailFragmentProvider {
16 |
17 | @ContributesAndroidInjector(modules =[(DetailFragmentModule::class)])
18 | internal abstract fun provideDetailFragmentFactory(): DetailFragment
19 |
20 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/progress_dialog.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/base/BaseViewModel.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.base
2 |
3 | import ahmed.atwa.popularmovies.utils.commons.toSingleEvent
4 | import androidx.lifecycle.LiveData
5 | import androidx.lifecycle.MutableLiveData
6 | import androidx.lifecycle.ViewModel
7 |
8 | /**
9 | * Created by Ahmed Atwa on 10/19/18.
10 | */
11 |
12 | abstract class BaseViewModel() : ViewModel() {
13 |
14 | private val mUiState = MutableLiveData()
15 | open val uiState: LiveData = mUiState.toSingleEvent()
16 |
17 | fun updateViewState(result: T) {
18 | mUiState.value = result
19 | }
20 |
21 | }
--------------------------------------------------------------------------------
/.idea/DtonatorPreferences.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/commons/ViewModelProviderFactory.kt:
--------------------------------------------------------------------------------
1 |
2 |
3 | package ahmed.atwa.popularmovies.utils.commons
4 |
5 | import androidx.lifecycle.ViewModel
6 | import androidx.lifecycle.ViewModelProvider
7 |
8 | /**
9 | * Created by Ahmed Atwa on 10/19/18.
10 | */
11 |
12 | class ViewModelProviderFactory(private var viewModel: V) : ViewModelProvider.Factory {
13 |
14 | override fun create(modelClass: Class): T {
15 | if (modelClass.isAssignableFrom(viewModel.javaClass)) {
16 | return viewModel as T
17 | }
18 | throw IllegalArgumentException("Unknown class name")
19 | }
20 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | PopularMovies
3 | Released in : %s
4 | Votes : %s
5 | Some error occurred !
6 | Movie Removed from favourites
7 | Movie added to favourites
8 | No movies found
9 | No trailers found
10 | Retry
11 | Search movies
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/data/TrailerApi.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.data
2 |
3 | import ahmed.atwa.popularmovies.utils.commons.AppConstants
4 | import retrofit2.Response
5 | import retrofit2.http.GET
6 | import retrofit2.http.Path
7 | import retrofit2.http.Query
8 |
9 | /**
10 | * Created by Ahmed Atwa on 10/17/2019.
11 | */
12 |
13 | interface TrailerApi {
14 |
15 |
16 | companion object {
17 |
18 | const val GET_MOVIE_TRAILERS: String = ("movie/{movie_id}/videos")
19 | }
20 |
21 | @GET(GET_MOVIE_TRAILERS)
22 | suspend fun getMovieTrailer(@Path("movie_id") id: Int, @Query(AppConstants.API_KEY_QUERY) apiKey: String): Response
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/di/MoviesFragmentProvider.kt:
--------------------------------------------------------------------------------
1 |
2 |
3 | package ahmed.atwa.popularmovies.movies.di
4 |
5 | import ahmed.atwa.popularmovies.movies.di.MoviesFragmentModule
6 | import ahmed.atwa.popularmovies.movies.presentation.MoviesFragment
7 | import ahmed.atwa.popularmovies.utils.network.UrlModule
8 | import dagger.Module
9 | import dagger.android.ContributesAndroidInjector
10 |
11 | /**
12 | * Created by Ahmed Atwa on 10/19/18.
13 | */
14 |
15 | @Module
16 | abstract class MoviesFragmentProvider {
17 |
18 | @ContributesAndroidInjector(modules =[(MoviesFragmentModule::class),(MovieSourceModule::class),])
19 | internal abstract fun provideMainFragmentFactory(): MoviesFragment
20 |
21 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/util/UiRunner.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.util
2 |
3 | import ahmed.atwa.popularmovies.PopMovApp
4 | import android.app.Application
5 | import android.content.Context
6 | import android.os.Bundle
7 | import android.os.StrictMode
8 | import androidx.test.runner.AndroidJUnitRunner
9 |
10 | open class UiRunner : AndroidJUnitRunner() {
11 |
12 | override fun onCreate(arguments: Bundle?) {
13 | StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().permitAll().build())
14 | super.onCreate(arguments)
15 | }
16 |
17 | override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
18 | return super.newApplication(cl, PopMovApp::class.java.name, context)
19 | }
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/di/DetailFragmentModule.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.di
2 |
3 | import ahmed.atwa.popularmovies.detail.presentation.DetailFragment
4 | import ahmed.atwa.popularmovies.detail.presentation.TrailerAdapter
5 | import androidx.recyclerview.widget.LinearLayoutManager
6 | import dagger.Module
7 | import dagger.Provides
8 |
9 | /**
10 | * Created by Ahmed Atwa on 10/19/18.
11 | */
12 |
13 | @Module
14 | class DetailFragmentModule {
15 |
16 | @Provides
17 | internal fun provideLinearLayoutManager(fragment: DetailFragment): LinearLayoutManager {
18 | return LinearLayoutManager(fragment.activity)
19 | }
20 |
21 | @Provides
22 | internal fun provideTrailerAdapter(): TrailerAdapter {
23 | return TrailerAdapter(ArrayList())
24 | }
25 |
26 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/di/builder/ActivityBuilder.kt:
--------------------------------------------------------------------------------
1 |
2 |
3 | package ahmed.atwa.popularmovies.utils.di.builder
4 |
5 | import ahmed.atwa.popularmovies.detail.di.DetailFragmentProvider
6 | import ahmed.atwa.popularmovies.main.di.MainActivityModule
7 | import ahmed.atwa.popularmovies.movies.di.MoviesFragmentProvider
8 | import ahmed.atwa.popularmovies.main.presentation.MainActivity
9 | import dagger.Module
10 | import dagger.android.ContributesAndroidInjector
11 |
12 | /**
13 | * Created by Ahmed Atwa on 10/19/18.
14 | */
15 |
16 | @Module
17 | abstract class ActivityBuilder {
18 |
19 |
20 | @ContributesAndroidInjector(modules = [(MainActivityModule::class), (MoviesFragmentProvider::class), (DetailFragmentProvider::class)])
21 | internal abstract fun bindMainActivity(): MainActivity
22 |
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/data/MovieApi.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.data
2 |
3 | import ahmed.atwa.popularmovies.utils.commons.AppConstants.Companion.API_KEY_QUERY
4 | import retrofit2.Response
5 | import retrofit2.http.GET
6 | import retrofit2.http.Query
7 | import javax.inject.Singleton
8 |
9 | /**
10 | * Created by Ahmed Atwa on 10/19/18.
11 | */
12 |
13 | @Singleton
14 | interface MovieApi {
15 |
16 |
17 | companion object {
18 | const val POPULAR_MOVIES_QUERY: String = ("discover/movie?sort_by=popularity.desc")
19 | const val PAGE_QUERY: String = ("page")
20 | }
21 |
22 |
23 |
24 | @GET(POPULAR_MOVIES_QUERY)
25 | suspend fun getMostPopular(@Query(API_KEY_QUERY) apiKey: String,@Query(PAGE_QUERY) page:Int): Response
26 |
27 |
28 |
29 |
30 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/NetworkErrorHandler.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.network
2 |
3 | import org.json.JSONObject
4 | import retrofit2.Response
5 | import java.io.IOException
6 |
7 | class NetworkErrorHandler : INetworkErrorHandler {
8 |
9 | override fun resolveErrorMessage(response: Response): ResultType.Error {
10 | val code = response.code().toString()
11 | val message = try {
12 | response.errorBody()?.string()?.let {
13 | val jObjError = JSONObject(it)
14 | jObjError.getJSONObject("error").getString("message")
15 | }
16 | } catch (e: Exception) {
17 | e.message
18 | }
19 | val errorMessage = if (message.isNullOrEmpty()) " error code = $code "
20 | else " error code = $code & error message = $message "
21 | return ResultType.Error(IOException(errorMessage))
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_movie_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/PopMovApp.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies
2 |
3 | import ahmed.atwa.popularmovies.utils.di.component.DaggerAppComponent
4 | import android.app.Activity
5 | import android.app.Application
6 | import dagger.android.AndroidInjector
7 | import dagger.android.DispatchingAndroidInjector
8 | import dagger.android.HasActivityInjector
9 | import javax.inject.Inject
10 |
11 | /**
12 | * Created by Ahmed Atwa on 10/19/18.
13 | */
14 |
15 | class PopMovApp : Application(), HasActivityInjector {
16 |
17 | @Inject
18 | lateinit var activityDispatchingAndroidInjector: DispatchingAndroidInjector
19 |
20 |
21 |
22 |
23 | override fun activityInjector(): AndroidInjector {
24 | return activityDispatchingAndroidInjector
25 | }
26 |
27 | override fun onCreate() {
28 | super.onCreate()
29 | DaggerAppComponent.builder().application(this).build().inject(this)
30 | }
31 |
32 |
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/domain/MovieSourceFactory.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.domain
2 |
3 | import ahmed.atwa.popularmovies.movies.data.Movie
4 | import ahmed.atwa.popularmovies.movies.data.MovieFilterSource
5 | import ahmed.atwa.popularmovies.movies.data.MoviePagingSource
6 | import ahmed.atwa.popularmovies.movies.data.MovieRepo
7 | import ahmed.atwa.popularmovies.movies.presentation.MovieAdapter
8 | import androidx.paging.PagingSource
9 | import javax.inject.Inject
10 |
11 | class MovieSourceFactory @Inject constructor() {
12 |
13 | @Inject
14 | lateinit var movieFilterSource: MovieFilterSource
15 |
16 | @Inject
17 | lateinit var moviePagingSource: MoviePagingSource
18 |
19 | fun getSource( filterText: String): PagingSource {
20 | return if (filterText.isBlank() || filterText.isEmpty()) moviePagingSource
21 | else movieFilterSource.apply { this.filterText = filterText }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | #
2 | # /**
3 | # * Created by Ahmed Atwa on 19/10/2018
4 | # * Copyright (c) 19/10/2018 . All rights reserved.
5 | # **/
6 | #
7 |
8 | # Project-wide Gradle settings.
9 | # IDE (e.g. Android Studio) users:
10 | # Gradle settings configured through the IDE *will override*
11 | # any settings specified in this file.
12 | # For more details on how to configure your build environment visit
13 | # http://www.gradle.org/docs/current/userguide/build_environment.html
14 | # Specifies the JVM arguments used for the daemon process.
15 | # The setting is particularly useful for tweaking memory settings.
16 | android.enableJetifier=true
17 | android.useAndroidX=true
18 | org.gradle.jvmargs=-Xmx1536m
19 | # When configured, Gradle will run in incubating parallel mode.
20 | # This option should only be used with decoupled projects. More details, visit
21 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
22 | # org.gradle.parallel=true
23 |
--------------------------------------------------------------------------------
/app/src/main/res/navigation/movies_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
16 |
17 |
18 |
23 |
24 |
--------------------------------------------------------------------------------
/app/src/test/java/ahmed/atwa/popularmovies/CoroutineTestingRule.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies
2 |
3 | import kotlinx.coroutines.CoroutineDispatcher
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.ExperimentalCoroutinesApi
6 | import kotlinx.coroutines.test.TestCoroutineScope
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.setMain
9 | import org.junit.rules.TestWatcher
10 | import org.junit.runner.Description
11 | import kotlin.coroutines.ContinuationInterceptor
12 |
13 | @ExperimentalCoroutinesApi
14 | class CoroutineTestingRule : TestWatcher(), TestCoroutineScope by TestCoroutineScope() {
15 |
16 | override fun starting(description: Description?) {
17 | super.starting(description)
18 | Dispatchers.setMain(this.coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)
19 | }
20 |
21 | override fun finished(description: Description?) {
22 | super.finished(description)
23 | Dispatchers.resetMain()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/di/module/RepoModule.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * *
3 | * Created by Ahmed Atwa on 19/10/2018.
4 | * /
5 | */
6 |
7 | package ahmed.atwa.popularmovies.utils.di.module
8 |
9 | import ahmed.atwa.popularmovies.detail.data.TrailerApi
10 | import ahmed.atwa.popularmovies.movies.data.MovieApi
11 | import ahmed.atwa.popularmovies.movies.data.MovieRepo
12 | import ahmed.atwa.popularmovies.movies.data.MovieRepoImp
13 | import ahmed.atwa.popularmovies.utils.database.MovieDao
14 | import dagger.Module
15 | import dagger.Provides
16 | import javax.inject.Singleton
17 |
18 | /**
19 | * Created by Ahmed Atwa on 10/17/2019.
20 | */
21 |
22 | @Module
23 | class RepoModule {
24 |
25 | @Provides
26 | @Singleton
27 | internal fun provideMovieRepository(movieDao: MovieDao,
28 | movieApi: MovieApi,
29 | trailerApi: TrailerApi): MovieRepo {
30 | return MovieRepoImp(movieDao, movieApi, trailerApi)
31 | }
32 |
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/main/di/MainActivityModule.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.main.di
2 |
3 | import ahmed.atwa.popularmovies.utils.commons.ViewModelProviderFactory
4 | import ahmed.atwa.popularmovies.movies.presentation.MoviesViewModel
5 | import ahmed.atwa.popularmovies.movies.data.MovieRepoImp
6 | import ahmed.atwa.popularmovies.movies.domain.MovieSourceFactory
7 | import androidx.lifecycle.ViewModelProvider
8 | import dagger.Module
9 | import dagger.Provides
10 |
11 | /**
12 | * Created by Ahmed Atwa on 10/19/18.
13 | */
14 |
15 | @Module
16 | class MainActivityModule {
17 |
18 | @Provides
19 | internal fun provideMoviesViewModel(movieRepoImp: MovieRepoImp,sourceFactory:MovieSourceFactory): MoviesViewModel {
20 | return MoviesViewModel(movieRepoImp,sourceFactory)
21 | }
22 |
23 | @Provides
24 | internal fun provideMoviesViewModelFactory(moviesViewModel: MoviesViewModel): ViewModelProvider.Factory {
25 | return ViewModelProviderFactory(moviesViewModel)
26 | }
27 |
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/database/DbModule.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * *
3 | * Created by Ahmed Atwa on 19/10/2018.
4 | * /
5 | */
6 |
7 | package ahmed.atwa.popularmovies.utils.database
8 |
9 | import ahmed.atwa.popularmovies.utils.commons.AppConstants
10 | import android.content.Context
11 | import androidx.room.Room
12 | import dagger.Module
13 | import dagger.Provides
14 | import javax.inject.Named
15 | import javax.inject.Singleton
16 |
17 | /**
18 | * Created by Ahmed Atwa on 11/8/2018.
19 | */
20 |
21 | @Module
22 | class DbModule{
23 |
24 | @Provides
25 | @Singleton
26 | @Named(AppConstants.DB_NAME_KEY)
27 | internal fun provideMovieDb(context: Context): MovieDb {
28 | return Room.databaseBuilder(context, MovieDb::class.java, AppConstants.DB_NAME).fallbackToDestructiveMigration()
29 | .build()
30 | }
31 |
32 | @Provides
33 | @Singleton
34 | internal fun provideMovieDao(context: Context): MovieDao {
35 | return provideMovieDb(context).movieDao()
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/di/MovieSourceModule.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.di
2 |
3 | import ahmed.atwa.popularmovies.movies.data.MovieFilterSource
4 | import ahmed.atwa.popularmovies.movies.data.MoviePagingSource
5 | import ahmed.atwa.popularmovies.movies.data.MovieRepoImp
6 | import ahmed.atwa.popularmovies.movies.domain.MovieSourceFactory
7 | import dagger.Module
8 | import dagger.Provides
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | class MovieSourceModule {
13 | @Provides
14 | internal fun provideMoviesSource(): MovieSourceFactory {
15 | return MovieSourceFactory()
16 | }
17 |
18 | @Provides
19 | @Singleton
20 | internal fun provideMoviesPagingSource(movieRepoImp: MovieRepoImp): MoviePagingSource {
21 | return MoviePagingSource(movieRepoImp)
22 | }
23 |
24 | @Provides
25 | @Singleton
26 | internal fun provideMoviesFilterSource(movieRepoImp: MovieRepoImp): MovieFilterSource {
27 | return MovieFilterSource(movieRepoImp)
28 | }
29 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/utils/di/MockDbModule.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.di
2 |
3 | import ahmed.atwa.popularmovies.utils.commons.AppConstants
4 | import ahmed.atwa.popularmovies.utils.database.MovieDao
5 | import ahmed.atwa.popularmovies.utils.database.MovieDb
6 | import android.content.Context
7 | import androidx.room.Room
8 | import dagger.Module
9 | import dagger.Provides
10 | import javax.inject.Named
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | class MockDbModule {
15 | @Provides
16 | @Singleton
17 | @Named(AppConstants.DB_NAME_KEY)
18 | internal fun provideMovieDb(context: Context): MovieDb {
19 | return Room.databaseBuilder(context, MovieDb::class.java, AppConstants.DB_MOCK_NAME).fallbackToDestructiveMigration()
20 | .build()
21 | }
22 |
23 | @Provides
24 | @Singleton
25 | internal fun provideMovieDao(context: Context): MovieDao {
26 | return provideMovieDb(context).movieDao()
27 | }
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/data/MovieFilterSource.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.data
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import javax.inject.Inject
6 |
7 | class MovieFilterSource @Inject constructor(private val repo: MovieRepo) : PagingSource() {
8 |
9 | var filterText:String? = null
10 |
11 | override suspend fun load(params: LoadParams): LoadResult {
12 | return if (filterText == null) LoadResult.Error(Exception(""))
13 | else try {
14 | val movieListResponse = repo.getFilteredPopularMovies(filterText!!)!!
15 | LoadResult.Page(
16 | data = movieListResponse.results,
17 | prevKey = null,
18 | nextKey = null
19 | )
20 | } catch (e: Exception) {
21 | LoadResult.Error(e)
22 | }
23 | }
24 |
25 | override fun getRefreshKey(state: PagingState): Int? {
26 | return null
27 | }
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/NetworkRouter.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.network
2 |
3 | import retrofit2.Response
4 | import java.io.IOException
5 |
6 |
7 | /**
8 | * Created by Ahmed Atwa on 11/7/2019.
9 | */
10 | object NetworkRouter {
11 |
12 | private val networkErrorHandler: INetworkErrorHandler = NetworkErrorHandler()
13 |
14 | suspend fun invokeCall(call: suspend () -> Response): ResultType {
15 | return try {
16 | val response = call.invoke()
17 | if (isSuccessResponse(response)) ResultType.Success(response.body()!!)
18 | else networkErrorHandler.resolveErrorMessage(response)
19 | } catch (exception: IOException) {
20 | if (exception is NoConnectionException) ResultType.Error(exception)
21 | else ResultType.Error(exception)
22 | }
23 | }
24 |
25 | private fun isSuccessResponse(response: Response?): Boolean {
26 | return response != null && response.isSuccessful && response.body() != null
27 | }
28 |
29 |
30 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/NetworkConnectionInterceptor.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.network
2 |
3 | import android.content.Context
4 | import android.net.ConnectivityManager
5 | import okhttp3.Interceptor
6 | import okhttp3.Response
7 | import java.io.IOException
8 | import javax.inject.Inject
9 | import javax.inject.Singleton
10 |
11 |
12 | /**
13 | * Created by Ahmed Atwa on 11/7/2019.
14 | */
15 | @Singleton
16 | class NetworkConnectionInterceptor @Inject constructor(private val mContext: Context) : Interceptor {
17 |
18 | private val isConnected: Boolean
19 | get() {
20 | val connectivityManager = mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
21 | val netInfo = connectivityManager.activeNetworkInfo
22 | return netInfo != null && netInfo.isConnected
23 | }
24 |
25 | @Throws(IOException::class)
26 | override fun intercept(chain: Interceptor.Chain): Response {
27 | if (!isConnected) throw NoConnectionException()
28 | val builder = chain.request().newBuilder()
29 | return chain.proceed(builder.build())
30 | }
31 |
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/di/MoviesFragmentModule.kt:
--------------------------------------------------------------------------------
1 |
2 |
3 | package ahmed.atwa.popularmovies.movies.di
4 |
5 | import ahmed.atwa.popularmovies.movies.data.MovieRepoImp
6 | import ahmed.atwa.popularmovies.movies.domain.MovieSourceFactory
7 | import ahmed.atwa.popularmovies.utils.commons.GridSpacingItemDecoration
8 | import ahmed.atwa.popularmovies.movies.presentation.MovieAdapter
9 | import ahmed.atwa.popularmovies.movies.presentation.MoviesFragment
10 | import androidx.recyclerview.widget.GridLayoutManager
11 | import dagger.Module
12 | import dagger.Provides
13 |
14 | /**
15 | * Created by Ahmed Atwa on 10/19/18.
16 | */
17 |
18 | @Module
19 | class MoviesFragmentModule {
20 |
21 | @Provides
22 | internal fun provideGridLayoutManager(fragment: MoviesFragment): GridLayoutManager {
23 | return GridLayoutManager(fragment.requireContext(), 2)
24 | }
25 |
26 | @Provides
27 | internal fun provideGridSpacingItemDecoration(): GridSpacingItemDecoration {
28 | return GridSpacingItemDecoration(2, 5, true)
29 | }
30 |
31 | @Provides
32 | internal fun provideMovieAdapter(): MovieAdapter {
33 | return MovieAdapter()
34 | }
35 |
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/data/MoviePagingSource.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.data
2 |
3 | import ahmed.atwa.popularmovies.utils.network.ResultType
4 | import androidx.paging.PagingSource
5 | import androidx.paging.PagingState
6 | import javax.inject.Inject
7 |
8 | class MoviePagingSource @Inject constructor(private val repo: MovieRepo) : PagingSource() {
9 |
10 | override suspend fun load(params: LoadParams): LoadResult {
11 | return try {
12 | val nextPage = params.key ?: 1
13 | val movieListResponse = (repo.getPopularMovies(nextPage) as ResultType.Success).data
14 | LoadResult.Page(
15 | data = movieListResponse.results,
16 | prevKey = if (nextPage == 1) null else nextPage - 1,
17 | nextKey = if (nextPage < movieListResponse.total_pages)
18 | movieListResponse.page?.plus(1) else null
19 | )
20 | } catch (e: Exception) {
21 | LoadResult.Error(e)
22 | }
23 | }
24 |
25 | override fun getRefreshKey(state: PagingState): Int? {
26 | return null
27 | }
28 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/utils/di/MockUrlModule.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.di
2 |
3 | import ahmed.atwa.popularmovies.utils.commons.AppConstants.Companion.BASE_URL_KEY
4 | import dagger.Module
5 | import dagger.Provides
6 | import okhttp3.mockwebserver.MockWebServer
7 | import javax.inject.Named
8 | import javax.inject.Singleton
9 |
10 | @Module
11 | class MockUrlModule {
12 |
13 | @Provides
14 | @Singleton
15 | fun provideMockServer (): MockWebServer {
16 |
17 | var mockWebServer:MockWebServer? = null
18 |
19 | val thread = Thread(Runnable {
20 | mockWebServer = MockWebServer()
21 | mockWebServer?.start()
22 | })
23 |
24 | thread.start()
25 | thread.join()
26 |
27 | return mockWebServer ?: throw NullPointerException()
28 | }
29 |
30 |
31 | @Provides
32 | @Singleton
33 | @Named(BASE_URL_KEY)
34 | fun provideBaseUrl (mockWebServer:MockWebServer): String {
35 |
36 | var url = ""
37 |
38 | val t = Thread(Runnable {
39 | url = mockWebServer.url("/").toString()
40 | })
41 | t.start()
42 | t.join()
43 |
44 | return url
45 | }
46 | }
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/data/Movie.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.data
2 |
3 | import android.os.Parcelable
4 | import androidx.room.ColumnInfo
5 | import androidx.room.Entity
6 | import androidx.room.PrimaryKey
7 | import kotlinx.android.parcel.Parcelize
8 |
9 | @Parcelize
10 | @Entity(tableName = "movies")
11 | data class Movie(
12 | @PrimaryKey
13 | @ColumnInfo(name = "id")
14 | var id: Int,
15 | @ColumnInfo(name = "vote_count")
16 | var vote_count: Int,
17 | @ColumnInfo(name = "video")
18 | var video: Boolean,
19 | @ColumnInfo(name = "vote_average")
20 | val vote_average: Double,
21 | @ColumnInfo(name = "title")
22 | val title: String?,
23 | @ColumnInfo(name = "popularity")
24 | val popularity: Double,
25 | @ColumnInfo(name = "poster_path")
26 | val poster_path: String?,
27 | @ColumnInfo(name = "original_language")
28 | val original_language: String?,
29 | @ColumnInfo(name = "original_title")
30 | val original_title: String?,
31 | @ColumnInfo(name = "backdrop_path")
32 | val backdrop_path: String? = "null",
33 | @ColumnInfo(name = "adult")
34 | var adult: Boolean = false,
35 | @ColumnInfo(name = "overview")
36 | val overview: String?,
37 | @ColumnInfo(name = "release_date")
38 | val release_date: String?
39 | ) : Parcelable
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/di/component/AppComponent.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.di.component
2 |
3 | import ahmed.atwa.popularmovies.PopMovApp
4 | import ahmed.atwa.popularmovies.utils.database.DbModule
5 | import ahmed.atwa.popularmovies.utils.di.module.AppModule
6 | import ahmed.atwa.popularmovies.utils.di.module.RepoModule
7 | import ahmed.atwa.popularmovies.utils.network.UrlModule
8 | import ahmed.atwa.popularmovies.utils.network.NetworkModule
9 | import ahmed.atwa.popularmovies.utils.di.builder.ActivityBuilder
10 | import android.app.Application
11 | import dagger.BindsInstance
12 | import dagger.Component
13 | import dagger.android.AndroidInjectionModule
14 | import dagger.android.AndroidInjector
15 | import dagger.android.DaggerApplication
16 | import javax.inject.Singleton
17 |
18 | /**
19 | * Created by Ahmed Atwa on 10/19/18.
20 | */
21 |
22 | @Singleton
23 | @Component(modules = [(AndroidInjectionModule::class), (AppModule::class), (DbModule::class),
24 | (NetworkModule::class), (UrlModule::class),(RepoModule::class), (ActivityBuilder::class)])
25 |
26 | interface AppComponent : AndroidInjector {
27 |
28 | fun inject(app: PopMovApp)
29 |
30 | override fun inject(instance: DaggerApplication)
31 |
32 | @Component.Builder
33 | interface Builder {
34 |
35 | @BindsInstance
36 | fun application(application: Application): Builder
37 |
38 | fun build(): AppComponent
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/commons/AppUtils.kt:
--------------------------------------------------------------------------------
1 |
2 |
3 | package ahmed.atwa.popularmovies.utils.commons
4 |
5 | import ahmed.atwa.popularmovies.R
6 | import android.app.ProgressDialog
7 | import android.content.Context
8 | import android.graphics.Color
9 | import android.graphics.drawable.ColorDrawable
10 | import android.net.ConnectivityManager
11 |
12 | /**
13 | * Created by Ahmed Atwa on 10/19/18.
14 | */
15 |
16 | class AppUtils {
17 |
18 | companion object {
19 | fun showLoadingDialog(context: Context): ProgressDialog {
20 | val progressDialog = ProgressDialog(context)
21 | progressDialog.show()
22 | if (progressDialog.window != null) {
23 | progressDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
24 | }
25 | progressDialog.setContentView(R.layout.progress_dialog)
26 | progressDialog.isIndeterminate = true
27 | progressDialog.setCancelable(false)
28 | progressDialog.setCanceledOnTouchOutside(false)
29 | return progressDialog
30 | }
31 |
32 | fun isNetworkConnected(context: Context): Boolean {
33 | val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
34 | if (cm != null) {
35 | val activeNetwork = cm.activeNetworkInfo
36 | return activeNetwork != null && activeNetwork.isConnectedOrConnecting
37 | }
38 | return false
39 | }
40 | }
41 |
42 | }
--------------------------------------------------------------------------------
/app/src/test/java/ahmed/atwa/popularmovies/movies/domain/MovieMapperImpTest.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.domain
2 |
3 | import ahmed.atwa.popularmovies.movies.data.Movie
4 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
5 | import org.junit.Assert
6 | import org.junit.Before
7 | import org.junit.Rule
8 | import org.junit.Test
9 | import org.mockito.MockitoAnnotations
10 |
11 | class MovieMapperImpTest {
12 |
13 | @get:Rule
14 | var instantExecutorRule = InstantTaskExecutorRule()
15 |
16 | private lateinit var movieMapper: MovieMapper
17 | private val mockMovieEntity = MovieEntity(1, true, "", 3.4, 12, true, 2.3, "test", "", "", "")
18 | private val mockMovieLocal = MovieLocal(1, 1, "", 3.4, 12, true, 2.3, "test", "", "", "")
19 | private val mockMovieRemote = Movie(12, 1, true, 2.3, "test", 3.4, "", "", "", "", false, "", "")
20 |
21 |
22 | @Before
23 | @Throws(Exception::class)
24 | fun setup() {
25 | MockitoAnnotations.initMocks(this)
26 | movieMapper = MovieMapperImp()
27 | }
28 |
29 |
30 | @Test
31 | fun test_mapFromLocal(){
32 | val expected = mockMovieEntity
33 | val actual = movieMapper.mapFromLocalToEntity(mockMovieLocal)
34 | Assert.assertEquals(expected, actual)
35 | }
36 |
37 | @Test
38 | fun test_mapFromRemoteToLocal(){
39 | val expected = mockMovieLocal
40 | val actual = movieMapper.mapFromRemoteToLocal(mockMovieRemote,1)
41 | Assert.assertEquals(expected, actual)
42 | }
43 |
44 |
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/commons/GridSpacingItemDecoration.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.commons
2 |
3 | import android.graphics.Rect
4 | import android.view.View
5 | import androidx.recyclerview.widget.RecyclerView
6 |
7 | /**
8 | * Created by Ahmed Atwa on 10/19/18.
9 | */
10 |
11 | class GridSpacingItemDecoration(private val spanCount: Int, private val spacing: Int, private val includeEdge: Boolean) : RecyclerView.ItemDecoration() {
12 |
13 |
14 | override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
15 | val position = parent.getChildAdapterPosition(view) // item position
16 | val column = position % spanCount // item column
17 |
18 | if (includeEdge) {
19 | outRect.left = spacing - column * spacing / spanCount // spacing - column * ((1f / spanCount) * spacing)
20 | outRect.right = (column + 1) * spacing / spanCount // (column + 1) * ((1f / spanCount) * spacing)
21 |
22 | if (position < spanCount) { // top edge
23 | outRect.top = spacing
24 | }
25 | outRect.bottom = spacing // item bottom
26 | } else {
27 | outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing)
28 | outRect.right = spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing)
29 | if (position >= spanCount) {
30 | outRect.top = spacing // item top
31 | }
32 | }
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/utils/di/TestAppComponent.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.di
2 |
3 | import ahmed.atwa.popularmovies.PopMovApp
4 | import ahmed.atwa.popularmovies.utils.di.builder.ActivityBuilder
5 | import ahmed.atwa.popularmovies.utils.di.component.AppComponent
6 | import ahmed.atwa.popularmovies.utils.di.module.AppModule
7 | import ahmed.atwa.popularmovies.utils.network.NetworkModule
8 | import ahmed.atwa.popularmovies.utils.di.module.RepoModule
9 | import android.app.Application
10 | import dagger.BindsInstance
11 | import dagger.Component
12 | import dagger.android.AndroidInjectionModule
13 | import dagger.android.DaggerApplication
14 | import okhttp3.mockwebserver.MockWebServer
15 | import javax.inject.Singleton
16 |
17 |
18 | @Singleton
19 | @Component(modules = [
20 | AndroidInjectionModule::class,
21 | AppModule::class,
22 | MockDbModule::class,
23 | NetworkModule::class,
24 | RepoModule::class,
25 | ActivityBuilder::class,
26 | MockUrlModule::class
27 | ]
28 | )
29 | interface TestAppComponent : AppComponent {
30 |
31 | override fun inject(app: PopMovApp)
32 |
33 | override fun inject(instance: DaggerApplication)
34 |
35 | fun getMockWebServer(): MockWebServer
36 |
37 | @Component.Builder
38 | interface Builder {
39 |
40 | /**
41 | * [BindsInstance] annotation is used for, if you want to bind particular object or instance
42 | * of an object through the time of component construction
43 | */
44 | @BindsInstance
45 | fun application(application: Application): Builder
46 |
47 | fun build(): TestAppComponent
48 | }
49 |
50 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/load_state_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
19 |
20 |
27 |
28 |
36 |
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PopularMovies
2 | Android Sample app to show user latest movies implementing MVVM + Clean Architecture using kotlin following solid and clean code principles .
3 |
4 | 
5 | 
6 | 
7 | 
8 | 
9 |
10 |
11 | **The goal of this app is to show how to implement MVVM design pattern with architecture components & clean architecture in Kotlin .**
12 |
13 | ## Tech stack:
14 |
15 | Kotlin, MVVM, Retrofit, Room, Coroutines, Dagger2, Navigation-component, Unit-Testing(Mockito), Ui-testing(Espresso), Package by feature.
16 |
17 | ## Source Code representation :
18 |
19 | - base : Base level classes (Activity, Fragment & Viewmodel).
20 | - main : MainActivity handling navigation between fragment using navigation component.
21 | - movies : Main Screen with grid displaying all movies list.
22 | - detail : Detail screen displaying selected movie details & trailer list.
23 | - utils : Configuration for Network layer, Database & Dagger alongside helper classes.
24 |
25 | **Each package is divided into multiple packages demonstrated as following.**
26 |
27 | - data : Contains data layer which has local & remote data sources with repository implementation.
28 | - domain : Contains the domain layer wiht factory logic & mapper before.
29 | - di : Contains all dagger depndencies mapping.
30 | - presentation : Contains the view layer that consists of views & viewModels.
31 |
32 | **Please note that each developer has his own style in coding ,The goal is to implement the concepts of the design and architecting in the right way .**
33 |
34 | ### Contribution
35 |
36 | Please feel free to make a pull request or fork.
37 |
38 | ### Rate
39 |
40 | If you find this repository useful please give it a star .
41 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_movies.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
18 |
19 |
29 |
30 |
31 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/presentation/MovieStateAdapter.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.presentation
2 |
3 | import ahmed.atwa.popularmovies.R
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import android.widget.Button
8 | import android.widget.ImageView
9 | import android.widget.ProgressBar
10 | import android.widget.TextView
11 | import androidx.core.view.isVisible
12 | import androidx.paging.LoadState
13 | import androidx.paging.LoadStateAdapter
14 | import androidx.recyclerview.widget.RecyclerView
15 |
16 | class MovieStateAdapter(private val retry: () -> Unit) :
17 | LoadStateAdapter() {
18 |
19 | override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) {
20 | holder.apply {
21 | btnRetry.isVisible = loadState !is LoadState.Loading
22 | tvErrorMessage.isVisible = loadState !is LoadState.Loading
23 | loadProgress.isVisible = loadState is LoadState.Loading
24 |
25 | if (loadState is LoadState.Error){
26 | tvErrorMessage.text = loadState.error.localizedMessage
27 | }
28 |
29 | btnRetry.setOnClickListener {
30 | retry.invoke()
31 | }
32 | }
33 | }
34 |
35 | override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder {
36 | val view = LayoutInflater.from(parent.context).inflate(R.layout.load_state_layout, parent, false)
37 | return LoadStateViewHolder(view)
38 | }
39 |
40 | class LoadStateViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){
41 | var btnRetry = itemView.findViewById(R.id.load_state_retry) as Button
42 | var loadProgress = itemView.findViewById(R.id.load_state_progress) as ProgressBar
43 | var tvErrorMessage = itemView.findViewById(R.id.load_state_errorMessage) as TextView
44 |
45 | }
46 | }
--------------------------------------------------------------------------------
/.idea/navEditor.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/main/presentation/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.main.presentation
2 |
3 | import ahmed.atwa.popularmovies.R
4 | import ahmed.atwa.popularmovies.base.BaseActivity
5 | import ahmed.atwa.popularmovies.movies.presentation.MoviesViewModel
6 | import android.os.Bundle
7 | import androidx.fragment.app.Fragment
8 | import androidx.lifecycle.ViewModelProvider
9 | import androidx.lifecycle.ViewModelProviders
10 | import androidx.navigation.findNavController
11 | import androidx.navigation.fragment.NavHostFragment
12 | import dagger.android.AndroidInjector
13 | import dagger.android.DispatchingAndroidInjector
14 | import dagger.android.support.HasSupportFragmentInjector
15 | import kotlinx.android.synthetic.main.activity_main.*
16 | import javax.inject.Inject
17 |
18 | /**
19 | * Created by Ahmed Atwa on 10/19/18.
20 | */
21 |
22 | class MainActivity : BaseActivity(), HasSupportFragmentInjector {
23 |
24 | @Inject
25 | internal lateinit var fragmentDispatchingAndroidInjector: DispatchingAndroidInjector
26 |
27 | @Inject
28 | internal lateinit var mViewModelFactory: ViewModelProvider.Factory
29 |
30 | override fun getLayoutId(): Int = R.layout.activity_main
31 | override fun getViewModel(): MoviesViewModel = ViewModelProviders.of(this, mViewModelFactory).get(MoviesViewModel::class.java)
32 | override fun supportFragmentInjector(): AndroidInjector = fragmentDispatchingAndroidInjector
33 |
34 | override fun onCreate(savedInstanceState: Bundle?) {
35 | super.onCreate(savedInstanceState)
36 | val navController = findNavController(R.id.nav_movies_host_fragment)
37 | navController.setGraph(R.navigation.movies_graph, intent.extras)
38 | }
39 |
40 | override fun onBackPressed() {
41 | val findNavController = NavHostFragment.findNavController(nav_movies_host_fragment)
42 | when (findNavController.currentDestination?.id) {
43 | R.id.details -> findNavController.popBackStack()
44 | R.id.movies -> finish()
45 | else -> super.onBackPressed()
46 | }
47 | }
48 |
49 | }
50 |
51 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/util/CustomMatcher.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.util
2 |
3 | import android.view.View
4 | import android.widget.TextView
5 | import androidx.recyclerview.widget.RecyclerView
6 | import androidx.test.espresso.UiController
7 | import androidx.test.espresso.ViewAction
8 | import androidx.test.espresso.matcher.BoundedMatcher
9 | import org.hamcrest.Description
10 | import org.hamcrest.Matcher
11 | import org.hamcrest.TypeSafeMatcher
12 |
13 |
14 | object CustomMatcher {
15 | fun clickItemWithId(id: Int): ViewAction {
16 | return object : ViewAction {
17 | override fun getConstraints(): Matcher? {
18 | return null
19 | }
20 |
21 | override fun getDescription(): String {
22 | return "Click on a child view with specified id."
23 | }
24 |
25 | override fun perform(uiController: UiController, view: View) {
26 | val v = view.findViewById(id) as View
27 | v.performClick()
28 | }
29 | }
30 | }
31 |
32 | class TextViewValueMatcher(private val textValue:String) : TypeSafeMatcher() {
33 |
34 | override fun describeTo(description: Description?) {
35 | }
36 |
37 | override fun matchesSafely(item: View?): Boolean {
38 | val textView = item as? TextView
39 | return textView?.text.toString() == textValue
40 | }
41 | }
42 |
43 | fun hasItemAtPosition(position: Int, matcher: Matcher) : Matcher {
44 | return object : BoundedMatcher(RecyclerView::class.java) {
45 |
46 | override fun describeTo(description: Description?) {
47 | description?.appendText("has item at position $position : ")
48 | matcher.describeTo(description)
49 | }
50 |
51 | override fun matchesSafely(item: RecyclerView?): Boolean {
52 | val viewHolder = item?.findViewHolderForAdapterPosition(position)
53 | return matcher.matches(viewHolder?.itemView)
54 | }
55 | }
56 | }
57 |
58 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
14 |
19 |
20 |
26 |
29 |
32 |
33 |
34 |
35 |
41 |
42 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/presentation/TrailerAdapter.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.presentation
2 |
3 | import ahmed.atwa.popularmovies.R
4 | import ahmed.atwa.popularmovies.detail.data.TrailerRemote
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import android.widget.ImageView
9 | import android.widget.TextView
10 | import androidx.recyclerview.widget.RecyclerView
11 |
12 | /**
13 | * Created by Ahmed Atwa on 10/19/18.
14 | */
15 |
16 | class TrailerAdapter(var mTrailerRemoteList: MutableList) : RecyclerView.Adapter() {
17 |
18 | private lateinit var mListener: TrailerAdapterListener
19 |
20 | override fun getItemCount(): Int {
21 | return if (mTrailerRemoteList.size > 0) mTrailerRemoteList.size else 0
22 | }
23 |
24 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrailerViewHolder {
25 | val view = LayoutInflater.from(parent.context).inflate(R.layout.item_trailer_view, parent, false)
26 | return TrailerViewHolder(view)
27 | }
28 |
29 | fun addItems(mList: List) {
30 | mTrailerRemoteList.addAll(mList)
31 | notifyDataSetChanged()
32 | }
33 |
34 | fun clearItems() {
35 | mTrailerRemoteList.clear()
36 | }
37 |
38 |
39 | interface TrailerAdapterListener {
40 | fun onTrailerClicked(trailerRemote: TrailerRemote)
41 | }
42 |
43 | inner class TrailerViewHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
44 | var tvTrailerName: TextView = itemView.findViewById(R.id.trailer_tv) as TextView
45 | var playBtn: ImageView = itemView.findViewById(R.id.play_btn) as ImageView
46 |
47 | }
48 |
49 | override fun onBindViewHolder(holder: TrailerViewHolder, position: Int) {
50 | val trailer = mTrailerRemoteList[position]
51 | holder.tvTrailerName.text = trailer.name
52 | holder.playBtn.setOnClickListener { mListener.onTrailerClicked(trailer) }
53 | }
54 |
55 | fun setListener(listener: TrailerAdapterListener) {
56 | mListener = listener
57 | }
58 |
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/commons/LiveEvent.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.utils.commons
2 |
3 | import androidx.annotation.MainThread
4 | import androidx.collection.ArraySet
5 | import androidx.lifecycle.LifecycleOwner
6 | import androidx.lifecycle.LiveData
7 | import androidx.lifecycle.MediatorLiveData
8 | import androidx.lifecycle.Observer
9 |
10 | class LiveEvent : MediatorLiveData() {
11 |
12 | private val observers = ArraySet>()
13 |
14 | @MainThread
15 | override fun observe(owner: LifecycleOwner, observer: Observer) {
16 | val wrapper = ObserverWrapper(observer)
17 | observers.add(wrapper)
18 | super.observe(owner, wrapper)
19 | }
20 |
21 | @MainThread
22 | override fun observeForever(observer: Observer) {
23 | val wrapper = ObserverWrapper(observer)
24 | observers.add(wrapper)
25 | super.observeForever(wrapper)
26 | }
27 |
28 | @MainThread
29 | override fun removeObserver(observer: Observer) {
30 | if (observers.remove(observer)) {
31 | super.removeObserver(observer)
32 | return
33 | }
34 | val iterator = observers.iterator()
35 | while (iterator.hasNext()) {
36 | val wrapper = iterator.next()
37 | if (wrapper.observer == observer) {
38 | iterator.remove()
39 | super.removeObserver(wrapper)
40 | break
41 | }
42 | }
43 | }
44 |
45 | @MainThread
46 | override fun setValue(t: T?) {
47 | observers.forEach { it.newValue() }
48 | super.setValue(t)
49 | }
50 |
51 | private class ObserverWrapper(val observer: Observer) : Observer {
52 |
53 | private var pending = false
54 |
55 | override fun onChanged(t: T?) {
56 | if (pending) {
57 | pending = false
58 | observer.onChanged(t)
59 | }
60 | }
61 |
62 | fun newValue() {
63 | pending = true
64 | }
65 | }
66 | }
67 |
68 | /**
69 | * Extension Function to make liveData only single event to avoid duplicate triggers
70 | */
71 | fun LiveData.toSingleEvent(): LiveData {
72 | val result = LiveEvent()
73 | result.addSource(this) {
74 | result.value = it
75 | }
76 | return result
77 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/data/MovieRepoImp.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.data
2 |
3 | import ahmed.atwa.popularmovies.BuildConfig
4 | import ahmed.atwa.popularmovies.detail.data.TrailerApi
5 | import ahmed.atwa.popularmovies.detail.data.TrailerResponse
6 | import ahmed.atwa.popularmovies.utils.database.MovieDao
7 | import ahmed.atwa.popularmovies.utils.network.NetworkRouter
8 | import ahmed.atwa.popularmovies.utils.network.ResultType
9 | import javax.inject.Inject
10 | import javax.inject.Singleton
11 |
12 | /**
13 | * Created by Ahmed Atwa on 10/17/2019.
14 | */
15 |
16 | @Singleton
17 | class MovieRepoImp @Inject constructor(
18 | private val movieDao: MovieDao,
19 | private val movieApi: MovieApi,
20 | private val trailerApi: TrailerApi) : MovieRepo {
21 |
22 | private var moviesResponse: MovieResponse? =null
23 |
24 | override suspend fun getPopularMovies(page: Int): ResultType {
25 | val response = NetworkRouter.invokeCall { movieApi.getMostPopular(BuildConfig.API_KEY, page) }
26 | if (response is ResultType.Success) {
27 | if (moviesResponse == null) moviesResponse = response.data
28 | else
29 | moviesResponse?.results.also {
30 | it?.addAll(response.data.results)
31 | }?.let {
32 | moviesResponse = response.data.copy()
33 | moviesResponse!!.results = it
34 | }
35 | }
36 | return response
37 | }
38 |
39 | override suspend fun getFilteredPopularMovies(filterText: String): MovieResponse? {
40 | val result = moviesResponse?.results?.filter { movie -> movie.title?.contains(filterText, true) == true }?.toList()
41 | result?.let {
42 | val response = moviesResponse?.copy()
43 | response?.results = ArrayList(it)
44 | return response
45 | }
46 | return null
47 | }
48 |
49 | override suspend fun fetchMovieTrailers(movieId: Int): ResultType {
50 | return NetworkRouter.invokeCall { trailerApi.getMovieTrailer(movieId, BuildConfig.API_KEY) }
51 | }
52 |
53 | override fun isMovieLiked(id: Int): Boolean {
54 | return movieDao.fetchFavouriteMovies().contains(id)
55 | }
56 |
57 | override fun changeLikeState(movie: Movie, newLikeState: Boolean) {
58 | if (newLikeState) movieDao.insertMovie(movie)
59 | else movieDao.removeMovie(movie)
60 | }
61 |
62 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_trailer_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
27 |
28 |
44 |
45 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/utils/network/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * *
3 | * Created by Ahmed Atwa on 19/10/2018.
4 | * /
5 | */
6 |
7 | package ahmed.atwa.popularmovies.utils.network
8 |
9 | import ahmed.atwa.popularmovies.movies.data.MovieApi
10 | import ahmed.atwa.popularmovies.detail.data.TrailerApi
11 | import ahmed.atwa.popularmovies.utils.commons.AppConstants.Companion.BASE_URL_KEY
12 | import android.content.Context
13 | import dagger.Module
14 | import dagger.Provides
15 | import okhttp3.OkHttpClient
16 | import okhttp3.logging.HttpLoggingInterceptor
17 | import retrofit2.Retrofit
18 | import retrofit2.converter.gson.GsonConverterFactory
19 | import javax.inject.Named
20 | import javax.inject.Singleton
21 |
22 | /**
23 | * Created by Ahmed Atwa on 11/8/2018.
24 | */
25 |
26 | @Module
27 | class NetworkModule {
28 |
29 | @Provides
30 | @Singleton
31 | internal fun provideNetworkConnectionInterceptor(mContext: Context): NetworkConnectionInterceptor {
32 | return NetworkConnectionInterceptor(mContext)
33 | }
34 |
35 | @Provides
36 | @Singleton
37 | internal fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
38 | val httpLoggingInterceptor = HttpLoggingInterceptor()
39 | httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
40 | return httpLoggingInterceptor
41 | }
42 |
43 | @Provides
44 | @Singleton
45 | internal fun provideOkHttpClient(networkConnectionInterceptor: NetworkConnectionInterceptor
46 | , httpLoggingInterceptor: HttpLoggingInterceptor): OkHttpClient.Builder {
47 | return OkHttpClient.Builder()
48 | .addInterceptor(httpLoggingInterceptor)
49 | .addInterceptor(networkConnectionInterceptor)
50 | }
51 |
52 | @Provides
53 | @Singleton
54 | internal fun provideRetrofit(@Named(BASE_URL_KEY) baseUrl: String,okHttpClient: OkHttpClient.Builder): Retrofit {
55 | return Retrofit.Builder()
56 | .addConverterFactory(GsonConverterFactory.create())
57 | .client(okHttpClient.build())
58 | .baseUrl(baseUrl)
59 | .build()
60 | }
61 |
62 |
63 | @Provides
64 | @Singleton
65 | internal fun provideMovieApi(retrofit: Retrofit): MovieApi {
66 | return retrofit.create(MovieApi::class.java)
67 | }
68 |
69 | @Provides
70 | @Singleton
71 | internal fun provideTrailerApi(retrofit: Retrofit): TrailerApi {
72 | return retrofit.create(TrailerApi::class.java)
73 | }
74 |
75 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/presentation/MovieAdapter.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.presentation
2 |
3 | import ahmed.atwa.popularmovies.BuildConfig
4 | import ahmed.atwa.popularmovies.R
5 | import ahmed.atwa.popularmovies.movies.data.Movie
6 | import android.annotation.SuppressLint
7 | import android.net.Uri
8 | import android.view.LayoutInflater
9 | import android.view.View
10 | import android.view.ViewGroup
11 | import android.widget.ImageView
12 | import androidx.paging.PagingDataAdapter
13 | import androidx.recyclerview.widget.DiffUtil
14 | import androidx.recyclerview.widget.RecyclerView
15 | import com.bumptech.glide.Glide
16 |
17 |
18 | /**
19 | * Created by Ahmed Atwa on 10/19/18.
20 | */
21 |
22 | class MovieAdapter : PagingDataAdapter(MovieModelComparator) {
23 |
24 | private lateinit var listener: OnItemClick
25 |
26 | fun setListener(mOnItemClick: OnItemClick) {
27 | listener = mOnItemClick
28 | }
29 |
30 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
31 | val view = LayoutInflater.from(parent.context).inflate(R.layout.item_movie_view, parent, false)
32 | return MovieViewHolder(view)
33 | }
34 |
35 | @SuppressLint("SetTextI18n")
36 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
37 | val viewHolder = holder as MovieViewHolder
38 | val movie = getItem(position)
39 | movie?.poster_path?.let {
40 | Glide.with(viewHolder.itemView.context)
41 | .load(Uri.parse("${BuildConfig.IMAGE_URL}$it"))
42 | .into(viewHolder.ivPoster)
43 | viewHolder.ivPoster.setOnClickListener { listener.onMovieClicked(movie) }
44 | }
45 | }
46 |
47 |
48 | inner class MovieViewHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
49 | var ivPoster: ImageView = itemView.findViewById(R.id.movieImg) as ImageView
50 | }
51 |
52 | interface OnItemClick {
53 | fun onMovieClicked(movieEntity: Movie)
54 | }
55 |
56 |
57 | companion object {
58 | private val MovieModelComparator = object : DiffUtil.ItemCallback() {
59 | override fun areItemsTheSame(oldItem: Movie, newItem: Movie): Boolean {
60 | return oldItem.id == newItem.id
61 | }
62 |
63 | override fun areContentsTheSame(oldItem: Movie, newItem: Movie): Boolean {
64 | return oldItem == newItem
65 | }
66 | }
67 | }
68 |
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/base/BaseFragment.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.base
2 |
3 | import android.content.Context
4 | import android.os.Bundle
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import androidx.lifecycle.LifecycleOwner
9 | import androidx.lifecycle.Observer
10 | import dagger.android.support.AndroidSupportInjection
11 |
12 | /**
13 | * Created by Ahmed Atwa on 10/19/18.
14 | */
15 |
16 | abstract class BaseFragment< V : BaseViewModel> : androidx.fragment.app.Fragment() {
17 |
18 | var mActivity: BaseActivity? = null
19 | abstract val viewModel: V
20 |
21 | abstract fun getLayoutId(): Int
22 | abstract fun getLifeCycleOwner(): LifecycleOwner
23 |
24 | /**
25 | * Called in case of some data emitted from the liveData in viewModel
26 | */
27 | open fun renderViewState(data: Any) {}
28 |
29 | override fun onAttach(context: Context) {
30 | super.onAttach(context)
31 | if (context is BaseActivity<*>) {
32 | mActivity = context as BaseActivity?
33 | mActivity?.onFragmentAttached()
34 | }
35 | }
36 |
37 | override fun onDetach() {
38 | mActivity = null
39 | super.onDetach()
40 | }
41 |
42 | override fun onCreate(savedInstanceState: Bundle?) {
43 | performDependencyInjection()
44 | super.onCreate(savedInstanceState)
45 | setHasOptionsMenu(false)
46 | }
47 |
48 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
49 | super.onViewCreated(view, savedInstanceState)
50 | configureObserver()
51 | }
52 |
53 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
54 | return inflater.inflate(getLayoutId(), container, false)
55 | }
56 |
57 | private fun configureObserver() {
58 | viewModel.uiState.observe(viewLifecycleOwner, Observer { viewState ->
59 | hideLoading()
60 | viewState?.let { renderViewState(it) }
61 | })
62 | }
63 |
64 | private fun performDependencyInjection() {
65 | AndroidSupportInjection.inject(this)
66 | }
67 |
68 | fun getBaseActivity(): BaseActivity? = mActivity
69 |
70 | fun showLoading() = mActivity?.showLoading()
71 |
72 | fun hideLoading() = mActivity?.hideLoading()
73 |
74 | fun hideKeyboard() = mActivity?.hideKeyboard()
75 |
76 | fun isNetworkConnected(): Boolean = mActivity != null && mActivity!!.isNetworkConnected()
77 |
78 | interface Callback {
79 | fun onFragmentAttached()
80 | fun onFragmentDetached(tag: String)
81 | }
82 |
83 | fun showMessage(message: String) {
84 | mActivity?.showMessage(message)
85 | }
86 |
87 | fun onError(message: String?) {
88 | mActivity?.onError(message)
89 | }
90 |
91 |
92 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/presentation/MoviesViewModel.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.presentation
2 |
3 | import ahmed.atwa.popularmovies.base.BaseViewModel
4 | import ahmed.atwa.popularmovies.detail.presentation.DetailViewState
5 | import ahmed.atwa.popularmovies.movies.data.Movie
6 | import ahmed.atwa.popularmovies.movies.data.MovieRepo
7 | import ahmed.atwa.popularmovies.movies.domain.MovieSourceFactory
8 | import ahmed.atwa.popularmovies.utils.commons.CoroutineDispatcher
9 | import ahmed.atwa.popularmovies.utils.network.ResultType
10 | import androidx.lifecycle.*
11 | import androidx.paging.*
12 | import kotlinx.coroutines.async
13 | import kotlinx.coroutines.launch
14 | import kotlinx.coroutines.runBlocking
15 | import kotlinx.coroutines.withContext
16 | import javax.inject.Inject
17 |
18 |
19 | /**
20 | * Created by Ahmed Atwa on 10/19/18.
21 | */
22 |
23 | class MoviesViewModel @Inject constructor(private val movieRepo: MovieRepo,
24 | private val sourceFactory: MovieSourceFactory,
25 | private val dispatcher: CoroutineDispatcher = CoroutineDispatcher()) : BaseViewModel() {
26 |
27 | companion object {
28 | private const val PAGE_SIZE = 20
29 | const val YOUTUBE_APP_URI = "vnd.youtube:"
30 | const val YOUTUBE_WEB_URI = "http://www.youtube.com/watch?v="
31 | const val POSTER_BASE_URL = "http://image.tmdb.org/t/p/w185"
32 | }
33 |
34 | private var movie: Movie? = null
35 | private val searchTextLiveData: MutableLiveData = MutableLiveData("")
36 | var movies: LiveData> = MediatorLiveData()
37 |
38 | init {
39 | movies = Transformations.switchMap(searchTextLiveData) { input: String ->
40 | return@switchMap getMoviesStream(input)
41 | }
42 | }
43 |
44 | private fun getMoviesStream(input: String): LiveData> {
45 | val result = viewModelScope.async { Pager(PagingConfig(PAGE_SIZE)) { sourceFactory.getSource(input) } }
46 | return runBlocking { result.await().liveData.cachedIn(viewModelScope)}
47 | }
48 |
49 | fun getLikeState(movieId: Int) {
50 | viewModelScope.launch(dispatcher.IO) {
51 | val likeState = movieRepo.isMovieLiked(movieId)
52 | withContext(dispatcher.Main) { updateViewState(DetailViewState.LikeState(likeState)) }
53 | }
54 | }
55 |
56 | fun fetchMovieTrailers(movieId: Int) {
57 | viewModelScope.launch(dispatcher.IO) {
58 | val trailerList = movieRepo.fetchMovieTrailers(movieId)
59 | withContext(dispatcher.Main) {
60 | if (trailerList is ResultType.Success) updateViewState(DetailViewState.TrailersFetchedSuccess(trailerList.data.results))
61 | else updateViewState(DetailViewState.TrailersFetchedError)
62 | }
63 | }
64 | }
65 |
66 | fun updateLikeStatus(movie: Movie) {
67 | viewModelScope.launch(dispatcher.IO) {
68 | val newLikeState = movieRepo.isMovieLiked(movie.id).not()
69 | movieRepo.changeLikeState(movie, newLikeState)
70 | withContext(dispatcher.Main) {
71 | updateViewState(DetailViewState.LikeState(newLikeState))
72 | }
73 | }
74 | }
75 |
76 | fun setSelectedMovie(movie: Movie) {
77 | this.movie = movie
78 | }
79 |
80 | fun getSelectedMovie(): Movie? {
81 | return movie
82 | }
83 |
84 | fun getSearchLiveData(): MutableLiveData {
85 | return searchTextLiveData
86 | }
87 |
88 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/base/BaseActivity.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.base
2 |
3 | import ahmed.atwa.popularmovies.R
4 | import ahmed.atwa.popularmovies.utils.commons.AppUtils
5 | import android.annotation.TargetApi
6 | import android.app.ProgressDialog
7 | import android.content.Context
8 | import android.content.pm.PackageManager
9 | import android.os.Build
10 | import android.os.Bundle
11 | import android.view.View
12 | import android.view.inputmethod.InputMethodManager
13 | import android.widget.TextView
14 | import android.widget.Toast
15 | import androidx.appcompat.app.AppCompatActivity
16 | import androidx.core.content.ContextCompat
17 | import com.google.android.material.snackbar.Snackbar
18 | import dagger.android.AndroidInjection
19 |
20 | /**
21 | * Created by Ahmed Atwa on 10/19/18.
22 | */
23 |
24 | abstract class BaseActivity : AppCompatActivity(), BaseFragment.Callback {
25 |
26 | var mProgressDialog: ProgressDialog? = null
27 | private var mViewModel: V? = null
28 |
29 |
30 | abstract fun getLayoutId(): Int
31 | abstract fun getViewModel(): V
32 |
33 | override fun onFragmentAttached() {}
34 | override fun onFragmentDetached(tag: String) {}
35 |
36 |
37 |
38 |
39 | override fun onCreate(savedInstanceState: Bundle?) {
40 | performDependencyInjection()
41 | super.onCreate(savedInstanceState)
42 | setContentView(getLayoutId())
43 | }
44 |
45 | private fun performDependencyInjection() {
46 | AndroidInjection.inject(this)
47 | }
48 |
49 |
50 | @TargetApi(Build.VERSION_CODES.M)
51 | fun hasPermission(permission: String): Boolean {
52 | return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
53 | }
54 |
55 | @TargetApi(Build.VERSION_CODES.M)
56 | fun requestPermissionsSafely(permissions: Array, requestCode: Int) {
57 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
58 | requestPermissions(permissions, requestCode)
59 | }
60 | }
61 |
62 | fun hideKeyboard() {
63 | val view = this.currentFocus
64 | if (view != null) {
65 | val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
66 | imm?.hideSoftInputFromWindow(view.windowToken, 0)
67 | }
68 | }
69 |
70 | fun hideLoading() {
71 | if (mProgressDialog != null)
72 | if (mProgressDialog!!.isShowing)
73 | mProgressDialog?.cancel()
74 |
75 | }
76 |
77 | fun showLoading() {
78 | hideLoading()
79 | mProgressDialog = AppUtils.showLoadingDialog(this)
80 | }
81 |
82 | fun isNetworkConnected(): Boolean {
83 | return AppUtils.isNetworkConnected(applicationContext)
84 | }
85 |
86 | fun showMessage(message: String) {
87 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
88 | }
89 |
90 | fun onError(message: String?) {
91 | if (message != null) {
92 | showSnackBar(message)
93 | } else {
94 | showSnackBar(getString(R.string.some_error))
95 | }
96 | }
97 |
98 | private fun showSnackBar(message: String) {
99 | val snackbar = Snackbar.make(findViewById(android.R.id.content),
100 | message, Snackbar.LENGTH_SHORT)
101 | val sbView = snackbar.view
102 | val textView = sbView
103 | .findViewById(R.id.snackbar_text) as TextView
104 | textView.setTextColor(ContextCompat.getColor(this, R.color.white))
105 | snackbar.show()
106 | }
107 |
108 |
109 | }
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | xmlns:android
14 |
15 | ^$
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | xmlns:.*
25 |
26 | ^$
27 |
28 |
29 | BY_NAME
30 |
31 |
32 |
33 |
34 |
35 |
36 | .*:id
37 |
38 | http://schemas.android.com/apk/res/android
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | .*:name
48 |
49 | http://schemas.android.com/apk/res/android
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | name
59 |
60 | ^$
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | style
70 |
71 | ^$
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | .*
81 |
82 | ^$
83 |
84 |
85 | BY_NAME
86 |
87 |
88 |
89 |
90 |
91 |
92 | .*
93 |
94 | http://schemas.android.com/apk/res/android
95 |
96 |
97 | ANDROID_ATTRIBUTE_ORDER
98 |
99 |
100 |
101 |
102 |
103 |
104 | .*
105 |
106 | .*
107 |
108 |
109 | BY_NAME
110 |
111 |
112 |
113 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/app/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | xmlns:android
14 |
15 | ^$
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | xmlns:.*
25 |
26 | ^$
27 |
28 |
29 | BY_NAME
30 |
31 |
32 |
33 |
34 |
35 |
36 | .*:id
37 |
38 | http://schemas.android.com/apk/res/android
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | .*:name
48 |
49 | http://schemas.android.com/apk/res/android
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | name
59 |
60 | ^$
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | style
70 |
71 | ^$
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | .*
81 |
82 | ^$
83 |
84 |
85 | BY_NAME
86 |
87 |
88 |
89 |
90 |
91 |
92 | .*
93 |
94 | http://schemas.android.com/apk/res/android
95 |
96 |
97 | ANDROID_ATTRIBUTE_ORDER
98 |
99 |
100 |
101 |
102 |
103 |
104 | .*
105 |
106 | .*
107 |
108 |
109 | BY_NAME
110 |
111 |
112 |
113 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/movie/presentation/MoviesFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movie.presentation
2 |
3 |
4 | import ahmed.atwa.popularmovies.PopMovApp
5 | import ahmed.atwa.popularmovies.R
6 | import ahmed.atwa.popularmovies.utils.di.DaggerTestAppComponent
7 | import ahmed.atwa.popularmovies.main.presentation.MainActivity
8 | import ahmed.atwa.popularmovies.movies.presentation.MovieAdapter
9 | import android.content.Intent
10 | import android.os.SystemClock
11 | import androidx.test.InstrumentationRegistry
12 | import androidx.test.espresso.Espresso.onView
13 | import androidx.test.espresso.action.ViewActions.click
14 | import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
15 | import androidx.test.espresso.assertion.ViewAssertions.matches
16 | import androidx.test.espresso.contrib.RecyclerViewActions
17 | import androidx.test.espresso.matcher.ViewMatchers.*
18 | import androidx.test.rule.ActivityTestRule
19 | import androidx.test.runner.AndroidJUnit4
20 | import okhttp3.mockwebserver.MockResponse
21 | import okhttp3.mockwebserver.MockWebServer
22 | import org.junit.Before
23 | import org.junit.Rule
24 | import org.junit.Test
25 | import org.junit.runner.RunWith
26 |
27 |
28 | @RunWith(AndroidJUnit4::class)
29 | class MoviesFragmentTest {
30 |
31 | @get:Rule
32 | val activityTestRule: ActivityTestRule = ActivityTestRule(MainActivity::class.java, false, false)
33 |
34 | private lateinit var mockServer: MockWebServer
35 |
36 | private lateinit var app: PopMovApp
37 |
38 | private lateinit var intent: Intent
39 |
40 |
41 | @Before
42 | fun setup() {
43 |
44 | val instrumentation = InstrumentationRegistry.getInstrumentation()
45 | app = instrumentation.targetContext.applicationContext as PopMovApp
46 |
47 | val appInjector = DaggerTestAppComponent.builder()
48 | .application(app)
49 | .build()
50 | appInjector.inject(app)
51 | mockServer = appInjector.getMockWebServer()
52 |
53 | intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, MainActivity::class.java)
54 | mockServer.enqueue(MockResponse().setResponseCode(200).setBody(
55 | "{\"page\":1,\"total_results\":10000,\"total_pages\":500,\"results\":[" +
56 | "{\"popularity\":2257.847,\"vote_count\":171,\"video\":false,\"poster_path\":\"\\/7D430eqZj8y3oVkLFfsWXGRcpEG.jpg\",\"id\":528085,\"adult\":false,\"backdrop_path\":\"\\/5UkzNSOK561c2QRy2Zr4AkADzLT.jpg\",\"original_language\":\"en\",\"original_title\":\"2067\",\"genre_ids\":[18,878,53],\"title\":\"2067\",\"vote_average\":5.6,\"overview\":\"A lowly utility worker is called to the future by a mysterious radio signal, he must leave his dying wife to embark on a journey that will force him to face his deepest fears in an attempt to change the fabric of reality and save humankind from its greatest environmental crisis yet.\",\"release_date\":\"2020-10-01\"}," +
57 | "{\"popularity\":2439.455,\"vote_count\":15,\"video\":false,\"poster_path\":\"\\/ugZW8ocsrfgI95pnQ7wrmKDxIe.jpg\",\"id\":724989,\"adult\":false,\"backdrop_path\":\"\\/86L8wqGMDbwURPni2t7FQ0nDjsH.jpg\",\"original_language\":\"en\",\"original_title\":\"Hard Kill\",\"genre_ids\":[28,53],\"title\":\"Hard Kill\",\"vote_average\":3.7,\"overview\":\"The work of billionaire tech CEO Donovan Chalmers is so valuable that he hires mercenaries to protect it, and a terrorist group kidnaps his daughter just to get it.\",\"release_date\":\"2020-10-23\"}" +
58 | "]}"
59 | ))
60 | activityTestRule.launchActivity(intent)
61 | SystemClock.sleep(2000)
62 | }
63 |
64 |
65 | @Test
66 | fun initUI() {
67 | onView(withId(R.id.moviesRecycler)).check(matches(isDisplayed()))
68 | }
69 |
70 | @Test
71 | fun onSuccess() {
72 | onView(withId(R.id.pb_loading)).check(doesNotExist());
73 | onView(withId(R.id.moviesRecycler))
74 | .perform(RecyclerViewActions.actionOnItemAtPosition(0, click()))
75 | }
76 |
77 | @Test
78 | fun onFailure() {
79 | mockServer.enqueue(MockResponse().setResponseCode(500))
80 | onView(withId(R.id.pb_loading)).check(doesNotExist())
81 | }
82 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/main/presentation/MainActivityTest.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.main.presentation
2 |
3 | import ahmed.atwa.popularmovies.PopMovApp
4 | import ahmed.atwa.popularmovies.R
5 | import ahmed.atwa.popularmovies.util.CustomMatcher.clickItemWithId
6 | import ahmed.atwa.popularmovies.utils.di.DaggerTestAppComponent
7 | import ahmed.atwa.popularmovies.detail.presentation.TrailerAdapter
8 | import ahmed.atwa.popularmovies.movies.presentation.MovieAdapter
9 | import android.app.Instrumentation
10 | import android.content.Intent
11 | import android.os.SystemClock
12 | import androidx.test.InstrumentationRegistry
13 | import androidx.test.espresso.Espresso.onView
14 | import androidx.test.espresso.action.ViewActions.click
15 | import androidx.test.espresso.assertion.ViewAssertions.matches
16 | import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
17 | import androidx.test.espresso.intent.Intents
18 | import androidx.test.espresso.intent.Intents.intended
19 | import androidx.test.espresso.intent.Intents.intending
20 | import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
21 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
22 | import androidx.test.espresso.matcher.ViewMatchers.withId
23 | import androidx.test.rule.ActivityTestRule
24 | import androidx.test.runner.AndroidJUnit4
25 | import org.hamcrest.Matcher
26 | import okhttp3.mockwebserver.MockResponse
27 | import okhttp3.mockwebserver.MockWebServer
28 | import org.junit.Before
29 | import org.junit.Rule
30 | import org.junit.Test
31 | import org.junit.runner.RunWith
32 |
33 |
34 | @RunWith(AndroidJUnit4::class)
35 | class MainActivityTest {
36 |
37 | @get:Rule
38 | val activityTestRule: ActivityTestRule = ActivityTestRule(MainActivity::class.java)
39 |
40 | private lateinit var mockServer: MockWebServer
41 |
42 | private lateinit var app: PopMovApp
43 |
44 | private lateinit var intent: Intent
45 |
46 | @Before
47 | fun setup() {
48 |
49 | val instrumentation = InstrumentationRegistry.getInstrumentation()
50 | app = instrumentation.targetContext.applicationContext as PopMovApp
51 |
52 | val appInjector = DaggerTestAppComponent.builder()
53 | .application(app)
54 | .build()
55 | appInjector.inject(app)
56 | mockServer = appInjector.getMockWebServer()
57 |
58 | intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, MainActivity::class.java)
59 | mockServer.enqueue(MockResponse().setResponseCode(200).setBody(
60 | "{\"page\":1,\"total_results\":10000,\"total_pages\":500,\"results\":[" +
61 | "{\"popularity\":2257.847,\"vote_count\":171,\"video\":false,\"poster_path\":\"\\/7D430eqZj8y3oVkLFfsWXGRcpEG.jpg\",\"id\":528085,\"adult\":false,\"backdrop_path\":\"\\/5UkzNSOK561c2QRy2Zr4AkADzLT.jpg\",\"original_language\":\"en\",\"original_title\":\"2067\",\"genre_ids\":[18,878,53],\"title\":\"2067\",\"vote_average\":5.6,\"overview\":\"A lowly utility worker is called to the future by a mysterious radio signal, he must leave his dying wife to embark on a journey that will force him to face his deepest fears in an attempt to change the fabric of reality and save humankind from its greatest environmental crisis yet.\",\"release_date\":\"2020-10-01\"}," +
62 | "{\"popularity\":2439.455,\"vote_count\":15,\"video\":false,\"poster_path\":\"\\/ugZW8ocsrfgI95pnQ7wrmKDxIe.jpg\",\"id\":724989,\"adult\":false,\"backdrop_path\":\"\\/86L8wqGMDbwURPni2t7FQ0nDjsH.jpg\",\"original_language\":\"en\",\"original_title\":\"Hard Kill\",\"genre_ids\":[28,53],\"title\":\"Hard Kill\",\"vote_average\":3.7,\"overview\":\"The work of billionaire tech CEO Donovan Chalmers is so valuable that he hires mercenaries to protect it, and a terrorist group kidnaps his daughter just to get it.\",\"release_date\":\"2020-10-23\"}" +
63 | "]}"
64 | ))
65 | activityTestRule.launchActivity(intent)
66 | SystemClock.sleep(1000)
67 | }
68 |
69 | @Test
70 | fun onCreate() {
71 | onView(withId(R.id.moviesRecycler)).check(matches(isDisplayed()))
72 | }
73 |
74 | @Test
75 | fun onMovieSelected() {
76 | onView(withId(R.id.moviesRecycler))
77 | .perform(actionOnItemAtPosition(0, click()))
78 | onView(withId(R.id.tv_title)).check(matches(isDisplayed()))
79 | onView(withId(R.id.recycler_trailer)).check(matches(isDisplayed()))
80 | }
81 |
82 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/detail/presentation/DetailFragment.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.presentation
2 |
3 | import ahmed.atwa.popularmovies.R
4 | import ahmed.atwa.popularmovies.base.BaseFragment
5 | import ahmed.atwa.popularmovies.detail.data.TrailerRemote
6 | import ahmed.atwa.popularmovies.movies.presentation.MoviesViewModel
7 | import ahmed.atwa.popularmovies.movies.presentation.MoviesViewModel.Companion.POSTER_BASE_URL
8 | import ahmed.atwa.popularmovies.movies.presentation.MoviesViewModel.Companion.YOUTUBE_APP_URI
9 | import ahmed.atwa.popularmovies.movies.presentation.MoviesViewModel.Companion.YOUTUBE_WEB_URI
10 | import android.content.ActivityNotFoundException
11 | import android.content.Intent
12 | import android.net.Uri
13 | import android.os.Bundle
14 | import android.view.View
15 | import androidx.lifecycle.LifecycleOwner
16 | import androidx.lifecycle.ViewModelProvider
17 | import androidx.recyclerview.widget.DefaultItemAnimator
18 | import androidx.recyclerview.widget.LinearLayoutManager
19 | import com.bumptech.glide.Glide
20 | import kotlinx.android.synthetic.main.fragment_detail.*
21 | import javax.inject.Inject
22 |
23 | /**
24 | * Created by Ahmed Atwa on 10/19/18.
25 | */
26 | class DetailFragment : BaseFragment(), TrailerAdapter.TrailerAdapterListener {
27 |
28 | @Inject
29 | internal lateinit var mViewModelFactory: ViewModelProvider.Factory
30 |
31 | @Inject
32 | lateinit var mLinearLayoutManager: LinearLayoutManager
33 |
34 | @Inject
35 | lateinit var mTrailerAdapter: TrailerAdapter
36 |
37 | override fun getLayoutId(): Int = R.layout.fragment_detail
38 | override fun getLifeCycleOwner(): LifecycleOwner = this
39 |
40 | override val viewModel by lazy {
41 | ViewModelProvider(requireActivity(), mViewModelFactory).get(MoviesViewModel::class.java)
42 | }
43 |
44 | override fun onCreate(savedInstanceState: Bundle?) {
45 | super.onCreate(savedInstanceState)
46 | mTrailerAdapter.setListener(this)
47 | }
48 |
49 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
50 | super.onViewCreated(view, savedInstanceState)
51 | initUI()
52 | }
53 |
54 | fun initUI() {
55 | recycler_trailer.setHasFixedSize(true)
56 | recycler_trailer.layoutManager = mLinearLayoutManager
57 | recycler_trailer.itemAnimator = DefaultItemAnimator()
58 | recycler_trailer.adapter = mTrailerAdapter
59 | renderMovieDetails()
60 | }
61 |
62 | private fun renderMovieDetails() {
63 | viewModel.getSelectedMovie()?.apply {
64 | tv_title.text = title
65 | tv_plot.text = overview
66 | tv_rating.text = vote_average.toString()
67 | tv_release_date.text = String.format(getString(R.string.released_in), release_date)
68 | tv_votes_count.text = String.format(getString(R.string.votes_count), vote_count.toString())
69 | rating_bar.rating = (vote_average / 2).toFloat()
70 | Glide.with(requireActivity())
71 | .load("$POSTER_BASE_URL${poster_path}")
72 | .into(img_poster)
73 | img_like.setOnClickListener { viewModel.updateLikeStatus(this) }
74 | viewModel.getLikeState(id)
75 | viewModel.fetchMovieTrailers(id)
76 | }
77 | }
78 |
79 | override fun renderViewState(data: Any) {
80 | when (data) {
81 | is DetailViewState.MessageRes -> showMessage(getString(data.resId))
82 | is DetailViewState.LikeState -> renderLikeState(data.isLiked)
83 | is DetailViewState.TrailersFetchedSuccess -> renderTrailers(data.trailers)
84 | is DetailViewState.TrailersFetchedError -> renderFetchingTrailerError()
85 | }
86 | }
87 |
88 | private fun renderFetchingTrailerError() {
89 | trailers_loading.visibility = View.GONE
90 | showMessage(getString(R.string.fetch_trailers_error))
91 | }
92 |
93 | private fun renderTrailers(trailers: List) {
94 | trailers_loading.visibility = View.GONE
95 | mTrailerAdapter.addItems(trailers)
96 | }
97 |
98 | private fun renderLikeState(isLiked: Boolean) {
99 | if (isLiked) R.string.movie_liked else R.string.movie_disliked
100 | img_like.setImageResource(if (isLiked) R.drawable.like else R.drawable.dislike)
101 | }
102 |
103 |
104 | override fun onTrailerClicked(trailerRemote: TrailerRemote) {
105 | try {
106 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$YOUTUBE_APP_URI${trailerRemote.key}"))
107 | startActivity(intent)
108 | } catch (ex: ActivityNotFoundException) {
109 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$YOUTUBE_WEB_URI${trailerRemote.key}"))
110 | startActivity(intent)
111 | }
112 | }
113 |
114 |
115 | }
--------------------------------------------------------------------------------
/app/src/main/java/ahmed/atwa/popularmovies/movies/presentation/MoviesFragment.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.presentation
2 |
3 | import ahmed.atwa.popularmovies.R
4 | import ahmed.atwa.popularmovies.base.BaseFragment
5 | import ahmed.atwa.popularmovies.movies.data.Movie
6 | import ahmed.atwa.popularmovies.utils.commons.GridSpacingItemDecoration
7 | import android.os.Bundle
8 | import android.view.Menu
9 | import android.view.MenuInflater
10 | import android.view.MenuItem
11 | import android.view.View
12 | import android.widget.SearchView
13 | import androidx.lifecycle.LifecycleOwner
14 | import androidx.lifecycle.ViewModelProvider
15 | import androidx.lifecycle.lifecycleScope
16 | import androidx.navigation.fragment.findNavController
17 | import androidx.paging.CombinedLoadStates
18 | import androidx.paging.LoadState
19 | import androidx.recyclerview.widget.DefaultItemAnimator
20 | import androidx.recyclerview.widget.GridLayoutManager
21 | import kotlinx.android.synthetic.main.fragment_movies.*
22 | import kotlinx.coroutines.launch
23 | import javax.inject.Inject
24 | import javax.inject.Provider
25 |
26 |
27 | /**
28 | * Created by Ahmed Atwa on 10/19/18.
29 | */
30 |
31 | class MoviesFragment : BaseFragment(), MovieAdapter.OnItemClick, (CombinedLoadStates) -> Unit, SearchView.OnQueryTextListener {
32 |
33 | @Inject
34 | internal lateinit var mViewModelFactory: ViewModelProvider.Factory
35 |
36 | @Inject
37 | lateinit var mGridLayoutManager: Provider
38 |
39 | @Inject
40 | lateinit var mGridSpacingItemDecoration: GridSpacingItemDecoration
41 |
42 | @Inject
43 | lateinit var mMovieAdapter: MovieAdapter
44 |
45 | override fun getLayoutId(): Int = R.layout.fragment_movies
46 | override fun getLifeCycleOwner(): LifecycleOwner = this
47 |
48 | override val viewModel by lazy {
49 | ViewModelProvider(requireActivity(), mViewModelFactory).get(MoviesViewModel::class.java)
50 | }
51 |
52 | override fun onCreate(savedInstanceState: Bundle?) {
53 | super.onCreate(savedInstanceState)
54 | setHasOptionsMenu(true)
55 | }
56 |
57 | override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
58 | menu.clear()
59 | inflater.inflate(R.menu.search_menu, menu);
60 | val mSearchMenuItem: MenuItem = menu.findItem(R.id.action_search)
61 | val searchView: SearchView = mSearchMenuItem.actionView as SearchView
62 | searchView.setOnQueryTextListener(this)
63 | searchView.queryHint = resources.getString(R.string.search_placeholder)
64 | super.onCreateOptionsMenu(menu, inflater)
65 | }
66 |
67 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
68 | super.onViewCreated(view, savedInstanceState)
69 | initUI()
70 | }
71 |
72 | private fun initUI() {
73 | mGridLayoutManager.get()?.let {
74 | it.reverseLayout = false
75 | it.isItemPrefetchEnabled = false
76 | moviesRecycler.layoutManager = it
77 | }
78 | moviesRecycler.apply {
79 | setHasFixedSize(true)
80 | addItemDecoration(mGridSpacingItemDecoration)
81 | itemAnimator = DefaultItemAnimator()
82 | mMovieAdapter.setListener(this@MoviesFragment)
83 | adapter = mMovieAdapter.withLoadStateFooter(
84 | footer = MovieStateAdapter { mMovieAdapter.retry() }
85 | )
86 | }
87 | listenForAdapterStates()
88 | }
89 |
90 | private fun listenForAdapterStates() {
91 | viewModel.movies.observe(viewLifecycleOwner,
92 | { paging -> lifecycleScope.launch { mMovieAdapter.submitData(paging) } })
93 | btn_retry.setOnClickListener { mMovieAdapter.retry() }
94 | mMovieAdapter.addLoadStateListener(this)
95 | }
96 |
97 | override fun onMovieClicked(movieEntity: Movie) {
98 | viewModel.setSelectedMovie(movieEntity)
99 | activity?.let { findNavController().navigate(R.id.details, Bundle()) }
100 | }
101 |
102 | override fun invoke(loadState: CombinedLoadStates) {
103 | if (loadState.refresh is LoadState.Loading) {
104 | btn_retry.visibility = View.GONE
105 | loadingView.visibility = View.VISIBLE
106 | } else {
107 | loadingView.visibility = View.GONE
108 | val errorState = when {
109 | loadState.append is LoadState.Error -> loadState.append as LoadState.Error
110 | loadState.prepend is LoadState.Error -> loadState.prepend as LoadState.Error
111 | loadState.refresh is LoadState.Error -> {
112 | btn_retry.visibility = View.VISIBLE
113 | loadState.refresh as LoadState.Error
114 | }
115 | else -> null
116 | }
117 | errorState?.error?.localizedMessage?.let { showMessage(it) }
118 | }
119 | }
120 |
121 | override fun onQueryTextChange(newText: String?): Boolean {
122 | newText?.let { viewModel.getSearchLiveData().postValue(it) }
123 | return true
124 | }
125 |
126 | override fun onQueryTextSubmit(query: String?): Boolean {
127 | return false
128 | }
129 | }
130 |
131 |
--------------------------------------------------------------------------------
/app/.idea/workspace.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | 1577109865001
88 |
89 |
90 | 1577109865001
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | /*
2 | * *
3 | * * Created by Ahmed Atwa on 19/10/2018
4 | * * Copyright (c) 19/10/2018 . All rights reserved.
5 | * *
6 | */
7 |
8 | apply plugin: 'com.android.application'
9 |
10 | apply plugin: 'kotlin-android'
11 |
12 | apply plugin: 'kotlin-android-extensions'
13 |
14 | apply plugin: 'kotlin-kapt'
15 |
16 |
17 |
18 |
19 | android {
20 | compileSdkVersion 29
21 | defaultConfig {
22 | applicationId "ahmed.atwa.popularmovies"
23 | minSdkVersion 21
24 | targetSdkVersion 29
25 | versionCode 1
26 | versionName "1.0"
27 | testInstrumentationRunner "ahmed.atwa.popularmovies.util.UiRunner"
28 | buildConfigField("String", "BASE_URL", "\"https://api.themoviedb.org/3/\"")
29 | buildConfigField("String", "API_KEY", "\"63f11c373de66cb025f1e247fe88ed93\"")
30 | buildConfigField("String", "IMAGE_URL", "\"http://image.tmdb.org/t/p/w185\"")
31 | }
32 | buildTypes {
33 | release {
34 | minifyEnabled false
35 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
36 | manifestPlaceholders = [usesCleartextTraffic:"false"]
37 | }
38 | debug{
39 | minifyEnabled false
40 | manifestPlaceholders = [usesCleartextTraffic:"true"]
41 | }
42 | }
43 | compileOptions {
44 | sourceCompatibility JavaVersion.VERSION_1_8
45 | targetCompatibility JavaVersion.VERSION_1_8
46 | }
47 | }
48 |
49 | dependencies {
50 | implementation fileTree(dir: 'libs', include: ['*.jar'])
51 | implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
52 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3'
53 | implementation 'androidx.appcompat:appcompat:1.3.0'
54 | implementation 'androidx.recyclerview:recyclerview:1.2.1'
55 | implementation 'com.google.android.material:material:1.3.0'
56 | implementation 'androidx.core:core-ktx:1.5.0'
57 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
58 |
59 |
60 | // network
61 | implementation "com.squareup.retrofit2:retrofit:2.9.0"
62 | //implementation "com.squareup.retrofit2:adapter-rxjava2:2.3.0"
63 | implementation 'com.squareup.okhttp3:okhttp:4.9.0'
64 | implementation "com.squareup.okhttp3:okhttp-urlconnection:4.8.0"
65 | implementation "com.squareup.retrofit2:converter-gson:2.9.0"
66 | implementation"com.squareup.okhttp3:logging-interceptor:4.9.0"
67 | implementation 'org.conscrypt:conscrypt-android:2.2.1'
68 |
69 |
70 |
71 |
72 | // database
73 | implementation "androidx.room:room-runtime:2.3.0"
74 | kapt 'androidx.room:room-compiler:2.3.0'
75 |
76 | // navigation
77 | implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
78 | implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
79 |
80 | // pagination
81 | implementation "androidx.paging:paging-runtime-ktx:3.1.0-alpha01"
82 |
83 | // image
84 | implementation "com.github.bumptech.glide:glide:$rootProject.glideVersion"
85 |
86 | // custom view
87 | implementation 'com.github.ivbaranov:materialfavoritebutton:0.1.4'
88 |
89 | // parser
90 | implementation "com.google.code.gson:gson:$rootProject.gsonVersion"
91 |
92 | // dependency injection
93 | implementation "com.google.dagger:dagger:$rootProject.dagger2Version"
94 | kapt "com.google.dagger:dagger-compiler:$rootProject.dagger2Version"
95 | kapt "com.google.dagger:dagger-android-processor:$rootProject.dagger2Version"
96 | implementation "com.google.dagger:dagger-android-support:$rootProject.dagger2Version"
97 |
98 | // Assisted Inject
99 | compileOnly "com.squareup.inject:assisted-inject-annotations-dagger2:0.3.2"
100 | kapt "com.squareup.inject:assisted-inject-processor-dagger2:0.3.2"
101 |
102 | // view model
103 | implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
104 | kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
105 | api 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0-alpha01'
106 |
107 |
108 | // Unit-Test
109 | testImplementation "androidx.arch.core:core-testing:2.1.0"
110 | testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0"
111 | testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.3"
112 | testImplementation 'org.hamcrest:hamcrest:2.2'
113 | testImplementation 'pl.pojo:pojo-tester:0.7.6'
114 | testImplementation 'androidx.test:core:1.3.0'
115 | testImplementation 'junit:junit:4.13'
116 |
117 | // UI-Test
118 | androidTestImplementation 'androidx.test:runner:1.3.0'
119 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
120 | androidTestImplementation 'androidx.test:rules:1.3.0'
121 | androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2'
122 | androidTestImplementation 'com.android.support.test.espresso:espresso-intents:3.0.2'
123 | androidTestImplementation 'com.android.support.test.espresso.idling:idling-concurrent:3.0.2'
124 | androidTestImplementation 'com.android.support.test.espresso:espresso-idling-resource:3.0.2'
125 |
126 | androidTestImplementation 'com.google.dexmaker:dexmaker:1.2'
127 | androidTestImplementation 'com.google.dexmaker:dexmaker-mockito:1.2'
128 | androidTestImplementation 'com.android.support:support-annotations:28.0.0'
129 |
130 | testImplementation 'org.robolectric:robolectric:4.2.1'
131 | testImplementation "org.mockito:mockito-core:3.6.28"
132 | testImplementation 'org.mockito:mockito-inline:2.24.5'
133 | testImplementation 'org.mockito:mockito-android:2.24.5'
134 |
135 | androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.8.0'
136 |
137 | kaptAndroidTest "com.google.dagger:dagger-compiler:$rootProject.dagger2Version"
138 | kaptAndroidTest "com.google.dagger:dagger-android-processor: $rootProject.dagger2Version"
139 |
140 |
141 | }
142 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/app/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/ahmed/atwa/popularmovies/detail/presentation/DetailFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.detail.presentation
2 |
3 | import ahmed.atwa.popularmovies.PopMovApp
4 | import ahmed.atwa.popularmovies.R
5 | import ahmed.atwa.popularmovies.util.CustomMatcher
6 | import ahmed.atwa.popularmovies.util.CustomMatcher.hasItemAtPosition
7 | import ahmed.atwa.popularmovies.utils.di.DaggerTestAppComponent
8 | import ahmed.atwa.popularmovies.main.presentation.MainActivity
9 | import ahmed.atwa.popularmovies.movies.presentation.MovieAdapter
10 | import android.app.Instrumentation
11 | import android.content.Intent
12 | import android.os.SystemClock
13 | import androidx.test.InstrumentationRegistry
14 | import androidx.test.espresso.Espresso
15 | import androidx.test.espresso.Espresso.onView
16 | import androidx.test.espresso.action.ViewActions
17 | import androidx.test.espresso.assertion.ViewAssertions
18 | import androidx.test.espresso.assertion.ViewAssertions.matches
19 | import androidx.test.espresso.contrib.RecyclerViewActions
20 | import androidx.test.espresso.intent.Intents
21 | import androidx.test.espresso.intent.matcher.IntentMatchers
22 | import androidx.test.espresso.matcher.RootMatchers
23 | import androidx.test.espresso.matcher.ViewMatchers
24 | import androidx.test.espresso.matcher.ViewMatchers.*
25 | import androidx.test.rule.ActivityTestRule
26 | import androidx.test.runner.AndroidJUnit4
27 | import okhttp3.mockwebserver.MockResponse
28 | import okhttp3.mockwebserver.MockWebServer
29 | import org.hamcrest.Matcher
30 | import org.hamcrest.Matchers.not
31 | import org.hamcrest.core.Is
32 | import org.junit.Before
33 | import org.junit.Rule
34 | import org.junit.Test
35 | import org.junit.runner.RunWith
36 |
37 | @RunWith(AndroidJUnit4::class)
38 | class DetailFragmentTest {
39 |
40 | @get:Rule
41 | val activityTestRule: ActivityTestRule = ActivityTestRule(MainActivity::class.java, false, false)
42 |
43 | private lateinit var expectedIntent: Matcher
44 |
45 | private lateinit var mockServer: MockWebServer
46 |
47 | private lateinit var app: PopMovApp
48 |
49 | private lateinit var intent: Intent
50 |
51 |
52 | @Before
53 | fun setup() {
54 |
55 | val instrumentation = InstrumentationRegistry.getInstrumentation()
56 | app = instrumentation.targetContext.applicationContext as PopMovApp
57 |
58 | val appInjector = DaggerTestAppComponent.builder()
59 | .application(app)
60 | .build()
61 | appInjector.inject(app)
62 | mockServer = appInjector.getMockWebServer()
63 |
64 | intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, MainActivity::class.java)
65 | mockServer.enqueue(MockResponse().setResponseCode(200).setBody(
66 | "{\"page\":1,\"total_results\":10000,\"total_pages\":500,\"results\":[" +
67 | "{\"popularity\":2257.847,\"vote_count\":171,\"video\":false,\"poster_path\":\"\\/7D430eqZj8y3oVkLFfsWXGRcpEG.jpg\",\"id\":528085,\"adult\":false,\"backdrop_path\":\"\\/5UkzNSOK561c2QRy2Zr4AkADzLT.jpg\",\"original_language\":\"en\",\"original_title\":\"2067\",\"genre_ids\":[18,878,53],\"title\":\"2067\",\"vote_average\":5.6,\"overview\":\"A lowly utility worker is called to the future by a mysterious radio signal, he must leave his dying wife to embark on a journey that will force him to face his deepest fears in an attempt to change the fabric of reality and save humankind from its greatest environmental crisis yet.\",\"release_date\":\"2020-10-01\"}]}"
68 | ))
69 | activityTestRule.launchActivity(intent)
70 | SystemClock.sleep(2000)
71 | Espresso.onView(withId(R.id.moviesRecycler))
72 | .perform(RecyclerViewActions.actionOnItemAtPosition(0, ViewActions.click()))
73 | }
74 |
75 |
76 | @Test
77 | fun initUI() {
78 | onView(withId(R.id.tv_title)).check(matches(ViewMatchers.isDisplayed()))
79 | onView(withId(R.id.tv_title)).check(matches(CustomMatcher.TextViewValueMatcher("2067")))
80 | }
81 |
82 | @Test
83 | fun onSuccess_trailers_fetched() {
84 | mockServer.enqueue(MockResponse().setResponseCode(200).setBody(
85 | "{\"id\":528085,\"results\":[" +
86 | "{\"id\":\"1\",\"iso_639_1\":\"en\",\"iso_3166_1\":\"US\",\"key\":\"cU5875rHQ8k\",\"name\":\"2067 (2020) Trailer\",\"site\":\"YouTube\",\"size\":1080,\"type\":\"Trailer\"}," +
87 | "{\"id\":\"2\",\"iso_639_1\":\"en\",\"iso_3166_1\":\"US\",\"key\":\"cU5875rHQ8k\",\"name\":\"2067 (2020) Trailer2\",\"site\":\"YouTube\",\"size\":1080,\"type\":\"Trailer\"}" +
88 | "]}"
89 | ))
90 | SystemClock.sleep(2000)
91 | onView(withId(R.id.recycler_trailer)).check(matches(isDisplayed()))
92 | }
93 |
94 | /* @Test
95 | fun onTrailerSelected() {
96 | Intents.init()
97 | expectedIntent = IntentMatchers.hasAction(Intent.ACTION_VIEW)
98 | Intents.intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null))
99 |
100 | mockServer.enqueue(MockResponse().setResponseCode(200).setBody(
101 | "{\"id\":528085,\"results\":[" +
102 | "{\"id\":\"5f6ec227ea394900383ed28d\",\"iso_639_1\":\"en\",\"iso_3166_1\":\"US\",\"key\":\"cU5875rHQ8k\",\"name\":\"2067 (2020) Trailer\",\"site\":\"YouTube\",\"size\":1080,\"type\":\"Trailer\"}," +
103 | "{\"id\":\"5f6ec227ea394900383ed28d\",\"iso_639_1\":\"en\",\"iso_3166_1\":\"US\",\"key\":\"cU5875rHQ8k\",\"name\":\"2067 (2020) Trailer2\",\"site\":\"YouTube\",\"size\":1080,\"type\":\"Trailer\"}" +
104 | "]}"
105 | ))
106 | SystemClock.sleep(2000)
107 |
108 | onView(withId(R.id.recycler_trailer))
109 | .perform(RecyclerViewActions.actionOnItemAtPosition(0, CustomMatcher.clickItemWithId(R.id.play_btn)))
110 |
111 | Intents.intended(expectedIntent)
112 | Intents.release()
113 | }*/
114 |
115 | @Test
116 | fun onFailure() {
117 | mockServer.enqueue(MockResponse().setResponseCode(500))
118 | SystemClock.sleep(2000)
119 | onView(withText("No trailers found")).inRoot(RootMatchers.withDecorView(not(Is.`is`(activityTestRule.activity.window.decorView)))).check(matches(ViewMatchers.isDisplayed()))
120 | }
121 |
122 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
14 |
17 |
22 |
27 |
32 |
37 |
42 |
47 |
52 |
57 |
62 |
67 |
72 |
77 |
82 |
87 |
92 |
97 |
102 |
107 |
112 |
117 |
122 |
127 |
132 |
137 |
142 |
147 |
152 |
157 |
162 |
167 |
172 |
177 |
178 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_detail.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
14 |
20 |
21 |
31 |
32 |
42 |
43 |
52 |
53 |
62 |
63 |
71 |
72 |
84 |
85 |
93 |
94 |
95 |
106 |
107 |
116 |
117 |
118 |
130 |
131 |
132 |
133 |
--------------------------------------------------------------------------------
/app/src/test/java/ahmed/atwa/popularmovies/movies/presentation/MoviesViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package ahmed.atwa.popularmovies.movies.presentation
2 |
3 | import ahmed.atwa.popularmovies.CoroutineTestingRule
4 | import ahmed.atwa.popularmovies.detail.data.TrailerRemote
5 | import ahmed.atwa.popularmovies.detail.data.TrailerResponse
6 | import ahmed.atwa.popularmovies.detail.presentation.DetailViewState
7 | import ahmed.atwa.popularmovies.movies.data.Movie
8 | import ahmed.atwa.popularmovies.movies.data.MovieRepo
9 | import ahmed.atwa.popularmovies.movies.data.MovieResponse
10 | import ahmed.atwa.popularmovies.movies.domain.MovieEntity
11 | import ahmed.atwa.popularmovies.utils.commons.TestDispatcher
12 | import ahmed.atwa.popularmovies.utils.network.ResultType
13 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
14 | import com.nhaarman.mockitokotlin2.*
15 | import kotlinx.coroutines.ExperimentalCoroutinesApi
16 | import kotlinx.coroutines.test.runBlockingTest
17 | import org.junit.Before
18 | import org.junit.Rule
19 | import org.junit.Test
20 | import org.mockito.MockitoAnnotations
21 | import java.io.IOException
22 |
23 | @ExperimentalCoroutinesApi
24 | class MoviesViewModelTest {
25 |
26 |
27 | @ExperimentalCoroutinesApi
28 | @get:Rule
29 | var mainCoroutineRule = CoroutineTestingRule()
30 |
31 | @get:Rule
32 | var instantExecutorRule = InstantTaskExecutorRule()
33 |
34 | private lateinit var moviesRepo: MovieRepo
35 | private lateinit var moviesViewModel: MoviesViewModel
36 |
37 | private val mockMovieEntity = MovieEntity(1, true, "", 3.4, 12, true, 2.3, "test", "", "", "")
38 | private val mockMovieRemote = Movie(10, 1, true, 7.8, "", 3.4,
39 | "test", "", "", "", true, "", "")
40 | private val mockMovieEntityList = listOf(mockMovieEntity, mockMovieEntity, mockMovieEntity)
41 | private val mockMovieResponse = MovieResponse(page = 1,
42 | total_pages = 1,
43 | total_results = 3,
44 | results = arrayListOf(mockMovieRemote, mockMovieRemote, mockMovieRemote))
45 |
46 | private val mockTrailer = TrailerRemote("1", "", "", "", "test", "youtube", 23, "trailer")
47 | private val mockTrailerResponse = TrailerResponse(id = 1, results = arrayListOf(mockTrailer, mockTrailer, mockTrailer))
48 |
49 | @Before
50 | @Throws(Exception::class)
51 | fun setup() {
52 | MockitoAnnotations.initMocks(this)
53 | moviesRepo = mock()
54 | moviesViewModel = MoviesViewModel(
55 | moviesRepo,
56 | TestDispatcher()
57 | )
58 | }
59 |
60 | @Test
61 | fun testingGetMoviesLocalSuccess_butFailureRemote_thenHasError() {
62 | runBlockingTest {
63 | whenever(moviesRepo.fetchMoviesLocal()).thenReturn(mockMovieEntityList)
64 | }
65 | moviesViewModel.apply {
66 | getMovies()
67 | uiState.observeForever {
68 | assert(it is MoviesViewState.FetchingMoviesError)
69 | }
70 | }
71 | }
72 |
73 | @Test
74 | fun testingGetMoviesRemote_success() {
75 | val expected = mockMovieEntityList
76 | runBlockingTest {
77 | whenever(moviesRepo.getPopularMovies()).thenReturn(ResultType.Success(mockMovieResponse))
78 | whenever(moviesRepo.syncFavWithDb(mockMovieResponse.results)).thenReturn(mockMovieEntityList)
79 | }
80 | moviesViewModel.apply {
81 | getMovies()
82 | uiState.observeForever {
83 | val actual = (it as MoviesViewState.FetchingMoviesSuccess).movies
84 | assert(expected == actual)
85 | }
86 | }
87 | }
88 |
89 |
90 | @Test
91 | fun testingGetMoviesRemote_failure() {
92 | runBlockingTest {
93 | whenever(moviesRepo.getPopularMovies()).thenReturn(ResultType.Error(IOException()))
94 | moviesViewModel.apply {
95 | getMovies()
96 | uiState.observeForever {
97 | assert(it is MoviesViewState.FetchingMoviesError)
98 | }
99 | verify(moviesRepo, times(1)).getPopularMovies()
100 | }
101 | }
102 | }
103 |
104 | @Test
105 | fun test_GetTrailers_success() {
106 | val expected = mockTrailerResponse
107 | runBlockingTest {
108 | whenever(moviesRepo.fetchMovieTrailers(any())).thenReturn(ResultType.Success(mockTrailerResponse))
109 | moviesViewModel.apply {
110 | fetchMovieTrailers(1)
111 | uiState.observeForever {
112 | val actual = ((it as DetailViewState.TrailersFetchedSuccess).trailers)
113 | assert(expected.results == actual)
114 | }
115 | verify(moviesRepo, times(1)).fetchMovieTrailers(any())
116 | }
117 | }
118 | }
119 |
120 |
121 | @Test
122 | fun test_GetTrailers_failure() {
123 | runBlockingTest {
124 | whenever(moviesRepo.fetchMovieTrailers(any())).thenReturn(ResultType.Error(IOException()))
125 | moviesViewModel.apply {
126 | fetchMovieTrailers(1)
127 | uiState.observeForever { assert(it is DetailViewState.TrailersFetchedError) }
128 | verify(moviesRepo, times(1)).fetchMovieTrailers(any())
129 | }
130 | }
131 | }
132 |
133 | @Test
134 | fun test_GetMovieLikeState() {
135 | val expected = true
136 | runBlockingTest {
137 | whenever(moviesRepo.isMovieLiked(any())).thenReturn(true)
138 | moviesViewModel.apply {
139 | getLikeState(1)
140 | uiState.observeForever {
141 | val actual = ((it as DetailViewState.LikeState).isLiked)
142 | assert(expected == actual)
143 | }
144 | verify(moviesRepo, times(1)).isMovieLiked(any())
145 | }
146 | }
147 | }
148 |
149 | @Test
150 | fun test_updateLikeStatus_false() {
151 | val mockMovieEntity = MovieEntity(1, true, "", 3.4, 12, true, 2.3, "test", "", "", "")
152 | val expected = false
153 | runBlockingTest {
154 | whenever(moviesRepo.changeLikeState(1, false)).thenReturn(1)
155 | moviesViewModel.apply {
156 | updateLikeStatus(mockMovieEntity)
157 | uiState.observeForever {
158 | val actual = ((it as DetailViewState.LikeState).isLiked)
159 | assert(expected == actual)
160 | }
161 | verify(moviesRepo, times(1)).changeLikeState(1, false)
162 | }
163 | }
164 | }
165 |
166 | }
--------------------------------------------------------------------------------