├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── api_key.xml
│ │ │ │ ├── attrs.xml
│ │ │ │ ├── strings.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── themes.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── ic_arrow_back.xml
│ │ │ │ ├── ic_favorite_on.xml
│ │ │ │ ├── ic_favorite_off.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── layout
│ │ │ │ ├── activity_nav_host.xml
│ │ │ │ ├── view_movie.xml
│ │ │ │ ├── fragment_main.xml
│ │ │ │ └── fragment_detail.xml
│ │ │ ├── values-night
│ │ │ │ └── themes.xml
│ │ │ ├── navigation
│ │ │ │ └── nav_graph.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── devexperto
│ │ │ │ └── architectcoders
│ │ │ │ ├── di
│ │ │ │ ├── ApiKey.kt
│ │ │ │ ├── ApiUrl.kt
│ │ │ │ ├── MovieId.kt
│ │ │ │ └── AppModule.kt
│ │ │ │ ├── App.kt
│ │ │ │ ├── data
│ │ │ │ ├── database
│ │ │ │ │ ├── MovieDatabase.kt
│ │ │ │ │ ├── Movie.kt
│ │ │ │ │ ├── MovieDao.kt
│ │ │ │ │ └── MovieRoomDataSource.kt
│ │ │ │ ├── server
│ │ │ │ │ ├── RemoteService.kt
│ │ │ │ │ ├── RemoteResult.kt
│ │ │ │ │ └── MovieServerDataSource.kt
│ │ │ │ ├── extensions.kt
│ │ │ │ ├── AndroidPermissionChecker.kt
│ │ │ │ └── PlayServicesLocationDataSource.kt
│ │ │ │ └── ui
│ │ │ │ ├── detail
│ │ │ │ ├── DetailBindingAdapters.kt
│ │ │ │ ├── di.kt
│ │ │ │ ├── MovieDetailInfoView.kt
│ │ │ │ ├── DetailFragment.kt
│ │ │ │ └── DetailViewModel.kt
│ │ │ │ ├── main
│ │ │ │ ├── MainBindingAdapters.kt
│ │ │ │ ├── MoviesAdapter.kt
│ │ │ │ ├── MainFragment.kt
│ │ │ │ ├── MainViewModel.kt
│ │ │ │ └── MainState.kt
│ │ │ │ ├── common
│ │ │ │ ├── BindingAdapters.kt
│ │ │ │ ├── PermissionRequester.kt
│ │ │ │ ├── AspectRatioImageView.kt
│ │ │ │ └── extensions.kt
│ │ │ │ └── NavHostActivity.kt
│ │ └── AndroidManifest.xml
│ ├── debug
│ │ ├── res
│ │ │ └── xml
│ │ │ │ └── network_security_config.xml
│ │ └── AndroidManifest.xml
│ ├── androidTest
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── devexperto
│ │ │ │ └── architectcoders
│ │ │ │ ├── data
│ │ │ │ └── server
│ │ │ │ │ ├── MockWebServerRule.kt
│ │ │ │ │ └── mockResponseExtensions.kt
│ │ │ │ ├── di
│ │ │ │ ├── HiltTestRunner.kt
│ │ │ │ └── TestAppModule.kt
│ │ │ │ └── ui
│ │ │ │ ├── OkHttp3IdlingResource.kt
│ │ │ │ └── MainInstrumentationTest.kt
│ │ └── assets
│ │ │ └── popular_movies.json
│ └── test
│ │ └── java
│ │ └── com
│ │ └── devexperto
│ │ └── architectcoders
│ │ ├── testrules
│ │ └── CoroutinesTestRule.kt
│ │ └── ui
│ │ ├── detail
│ │ ├── DetailViewModelTest.kt
│ │ └── DetailIntegrationTests.kt
│ │ └── main
│ │ ├── MainViewModelTest.kt
│ │ └── MainIntegrationTests.kt
├── proguard-rules.pro
└── build.gradle
├── data
├── .gitignore
├── src
│ ├── main
│ │ └── java
│ │ │ └── com
│ │ │ └── devexperto
│ │ │ └── architectcoders
│ │ │ └── data
│ │ │ ├── datasource
│ │ │ ├── LocationDataSource.kt
│ │ │ ├── MovieRemoteDataSource.kt
│ │ │ └── MovieLocalDataSource.kt
│ │ │ ├── PermissionChecker.kt
│ │ │ ├── RegionRepository.kt
│ │ │ └── MoviesRepository.kt
│ └── test
│ │ └── java
│ │ └── com
│ │ └── devexperto
│ │ └── architectcoders
│ │ └── data
│ │ ├── RegionRepositoryTest.kt
│ │ └── MoviesRepositoryTest.kt
└── build.gradle
├── domain
├── .gitignore
├── build.gradle
└── src
│ └── main
│ └── java
│ └── com
│ └── devexperto
│ └── architectcoders
│ └── domain
│ ├── Error.kt
│ └── Movie.kt
├── testShared
├── .gitignore
├── build.gradle
└── src
│ └── main
│ └── java
│ └── com
│ └── devexperto
│ └── architectcoders
│ └── testshared
│ └── samples.kt
├── usecases
├── .gitignore
├── src
│ ├── main
│ │ └── java
│ │ │ └── com
│ │ │ └── devexperto
│ │ │ └── architectcoders
│ │ │ └── usecases
│ │ │ ├── GetPopularMoviesUseCase.kt
│ │ │ ├── SwitchMovieFavoriteUseCase.kt
│ │ │ ├── FindMovieUseCase.kt
│ │ │ └── RequestPopularMoviesUseCase.kt
│ └── test
│ │ └── java
│ │ └── com
│ │ └── devexperto
│ │ └── architectcoders
│ │ └── usecases
│ │ ├── RequestPopularMoviesUseCaseTest.kt
│ │ ├── FindMovieUseCaseTest.kt
│ │ ├── GetPopularMoviesUseCaseTest.kt
│ │ └── SwitchMovieFavoriteUseCaseTest.kt
└── build.gradle
├── appTestShared
├── .gitignore
├── consumer-rules.pro
├── src
│ └── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── com
│ │ └── devexperto
│ │ └── architectcoders
│ │ └── appTestShared
│ │ ├── helpers.kt
│ │ └── fakes.kt
├── proguard-rules.pro
└── build.gradle
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── settings.gradle
├── gradle.properties
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/data/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/domain/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/testShared/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/usecases/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/appTestShared/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/appTestShared/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/main/res/values/api_key.xml:
--------------------------------------------------------------------------------
1 |
2 | d30e1f350220f9aad6c4110df385d380
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | local.properties
11 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/appTestShared/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/domain/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'java-library'
3 | id 'org.jetbrains.kotlin.jvm'
4 | }
5 |
6 | java {
7 | sourceCompatibility = JavaVersion.VERSION_17
8 | targetCompatibility = JavaVersion.VERSION_17
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/di/ApiKey.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.di
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Retention(AnnotationRetention.BINARY)
6 | @Qualifier
7 | annotation class ApiKey
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/di/ApiUrl.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.di
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Retention(AnnotationRetention.BINARY)
6 | @Qualifier
7 | annotation class ApiUrl
--------------------------------------------------------------------------------
/data/src/main/java/com/devexperto/architectcoders/data/datasource/LocationDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.datasource
2 |
3 | interface LocationDataSource {
4 | suspend fun findLastRegion(): String?
5 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/App.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class App : Application()
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/di/MovieId.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.di
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Retention(AnnotationRetention.BINARY)
6 | @Qualifier
7 | annotation class MovieId
--------------------------------------------------------------------------------
/app/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/devexperto/architectcoders/domain/Error.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.domain
2 |
3 | sealed interface Error {
4 | class Server(val code: Int) : Error
5 | object Connectivity : Error
6 | class Unknown(val message: String) : Error
7 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/devexperto/architectcoders/data/PermissionChecker.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | interface PermissionChecker {
4 |
5 | enum class Permission { COARSE_LOCATION }
6 |
7 | fun check(permission: Permission): Boolean
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Jan 27 13:36:34 CET 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/testShared/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'java-library'
3 | id 'org.jetbrains.kotlin.jvm'
4 | }
5 |
6 | dependencies {
7 | implementation project(":domain")
8 | }
9 |
10 | java {
11 | sourceCompatibility = JavaVersion.VERSION_17
12 | targetCompatibility = JavaVersion.VERSION_17
13 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Architect Coders
3 | Connectivity Error
4 | \"Server Error: \"
5 | \"Unknown Error: \"
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/debug/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | localhost
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/database/MovieDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.database
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 |
6 | @Database(entities = [Movie::class], version = 1, exportSchema = false)
7 | abstract class MovieDatabase : RoomDatabase() {
8 |
9 | abstract fun movieDao(): MovieDao
10 | }
--------------------------------------------------------------------------------
/testShared/src/main/java/com/devexperto/architectcoders/testshared/samples.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.testshared
2 |
3 | import com.devexperto.architectcoders.domain.Movie
4 |
5 | val sampleMovie = Movie(
6 | 0,
7 | "Title",
8 | "Overview",
9 | "01/01/2025",
10 | "",
11 | "",
12 | "EN",
13 | "Title",
14 | 5.0,
15 | 5.1,
16 | false
17 | )
--------------------------------------------------------------------------------
/usecases/src/main/java/com/devexperto/architectcoders/usecases/GetPopularMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.data.MoviesRepository
4 | import javax.inject.Inject
5 |
6 | class GetPopularMoviesUseCase @Inject constructor(private val repository: MoviesRepository) {
7 |
8 | operator fun invoke() = repository.popularMovies
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/detail/DetailBindingAdapters.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.detail
2 |
3 | import androidx.databinding.BindingAdapter
4 | import com.devexperto.architectcoders.domain.Movie
5 |
6 | @BindingAdapter("movie")
7 | fun MovieDetailInfoView.updateMovieDetails(movie: Movie?) {
8 | if (movie != null) {
9 | setMovie(movie)
10 | }
11 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_arrow_back.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_favorite_on.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
--------------------------------------------------------------------------------
/data/src/main/java/com/devexperto/architectcoders/data/datasource/MovieRemoteDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.datasource
2 |
3 | import arrow.core.Either
4 | import com.devexperto.architectcoders.domain.Error
5 | import com.devexperto.architectcoders.domain.Movie
6 |
7 | interface MovieRemoteDataSource {
8 | suspend fun findPopularMovies(region: String): Either>
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/server/RemoteService.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.server
2 |
3 | import retrofit2.http.GET
4 | import retrofit2.http.Query
5 |
6 | interface RemoteService {
7 |
8 | @GET("discover/movie?sort_by=popularity.desc")
9 | suspend fun listPopularMovies(
10 | @Query("api_key") apiKey: String,
11 | @Query("region") region: String
12 | ): RemoteResult
13 |
14 | }
--------------------------------------------------------------------------------
/app/src/debug/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/main/MainBindingAdapters.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.main
2 |
3 | import androidx.databinding.BindingAdapter
4 | import androidx.recyclerview.widget.RecyclerView
5 | import com.devexperto.architectcoders.domain.Movie
6 |
7 | @BindingAdapter("items")
8 | fun RecyclerView.setItems(movies: List?) {
9 | if (movies != null) {
10 | (adapter as? MoviesAdapter)?.submitList(movies)
11 | }
12 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 | #E0E0E0
11 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/devexperto/architectcoders/domain/Movie.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.domain
2 |
3 | data class Movie(
4 | val id: Int,
5 | val title: String,
6 | val overview: String,
7 | val releaseDate: String,
8 | val posterPath: String,
9 | val backdropPath: String,
10 | val originalLanguage: String,
11 | val originalTitle: String,
12 | val popularity: Double,
13 | val voteAverage: Double,
14 | val favorite: Boolean
15 | )
--------------------------------------------------------------------------------
/usecases/src/main/java/com/devexperto/architectcoders/usecases/SwitchMovieFavoriteUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.data.MoviesRepository
4 | import com.devexperto.architectcoders.domain.Movie
5 | import javax.inject.Inject
6 |
7 | class SwitchMovieFavoriteUseCase @Inject constructor(private val repository: MoviesRepository) {
8 |
9 | suspend operator fun invoke(movie: Movie) = repository.switchFavorite(movie)
10 | }
--------------------------------------------------------------------------------
/usecases/src/main/java/com/devexperto/architectcoders/usecases/FindMovieUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.data.MoviesRepository
4 | import com.devexperto.architectcoders.domain.Movie
5 | import kotlinx.coroutines.flow.Flow
6 | import javax.inject.Inject
7 |
8 | class FindMovieUseCase @Inject constructor(private val repository: MoviesRepository) {
9 |
10 | operator fun invoke(id: Int): Flow = repository.findById(id)
11 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/common/BindingAdapters.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.common
2 |
3 | import android.view.View
4 | import android.widget.ImageView
5 | import androidx.databinding.BindingAdapter
6 |
7 | @BindingAdapter("url")
8 | fun ImageView.bindUrl(url: String?) {
9 | if (url != null) loadUrl(url)
10 | }
11 |
12 | @BindingAdapter("visible")
13 | fun View.setVisible(visible: Boolean?) {
14 | visibility = if (visible == true) View.VISIBLE else View.GONE
15 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/devexperto/architectcoders/data/datasource/MovieLocalDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.datasource
2 |
3 | import com.devexperto.architectcoders.domain.Error
4 | import com.devexperto.architectcoders.domain.Movie
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | interface MovieLocalDataSource {
8 | val movies: Flow>
9 |
10 | suspend fun isEmpty(): Boolean
11 | fun findById(id: Int): Flow
12 | suspend fun save(movies: List): Error?
13 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_favorite_off.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/usecases/src/main/java/com/devexperto/architectcoders/usecases/RequestPopularMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.data.MoviesRepository
4 | import com.devexperto.architectcoders.domain.Error
5 | import javax.inject.Inject
6 |
7 | class RequestPopularMoviesUseCase @Inject constructor(private val moviesRepository: MoviesRepository) {
8 |
9 | suspend operator fun invoke(): Error? {
10 | return moviesRepository.requestPopularMovies()
11 | }
12 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_nav_host.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "Architect Coders"
16 | include ':app'
17 | include ':data'
18 | include ':domain'
19 | include ':usecases'
20 | include ':testShared'
21 | include ':appTestShared'
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/NavHostActivity.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui
2 |
3 | import android.os.Bundle
4 | import androidx.appcompat.app.AppCompatActivity
5 | import com.devexperto.architectcoders.R
6 | import dagger.hilt.android.AndroidEntryPoint
7 |
8 | @AndroidEntryPoint
9 | class NavHostActivity : AppCompatActivity() {
10 | override fun onCreate(savedInstanceState: Bundle?) {
11 | super.onCreate(savedInstanceState)
12 | setContentView(R.layout.activity_nav_host)
13 | }
14 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/devexperto/architectcoders/data/server/MockWebServerRule.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.server
2 |
3 | import okhttp3.mockwebserver.MockWebServer
4 | import org.junit.rules.TestWatcher
5 | import org.junit.runner.Description
6 |
7 | class MockWebServerRule : TestWatcher() {
8 |
9 | lateinit var server: MockWebServer
10 |
11 | override fun starting(description: Description) {
12 | server = MockWebServer()
13 | server.start(8080)
14 | }
15 |
16 | override fun finished(description: Description) {
17 | server.shutdown()
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/database/Movie.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.database
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity
7 | data class Movie(
8 | @PrimaryKey(autoGenerate = true) val id: Int,
9 | val title: String,
10 | val overview: String,
11 | val releaseDate: String,
12 | val posterPath: String,
13 | val backdropPath: String,
14 | val originalLanguage: String,
15 | val originalTitle: String,
16 | val popularity: Double,
17 | val voteAverage: Double,
18 | val favorite: Boolean
19 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/database/MovieDao.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.database
2 |
3 | import androidx.room.*
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | @Dao
7 | interface MovieDao {
8 |
9 | @Query("SELECT * FROM Movie")
10 | fun getAll(): Flow>
11 |
12 | @Query("SELECT * FROM Movie WHERE id = :id")
13 | fun findById(id: Int): Flow
14 |
15 | @Query("SELECT COUNT(id) FROM Movie")
16 | suspend fun movieCount(): Int
17 |
18 | @Insert(onConflict = OnConflictStrategy.REPLACE)
19 | suspend fun insertMovies(movies: List)
20 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/devexperto/architectcoders/di/HiltTestRunner.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.di
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import androidx.test.runner.AndroidJUnitRunner
6 | import dagger.hilt.android.testing.HiltTestApplication
7 |
8 | // A custom runner to set up the instrumented application class for tests.
9 | @Suppress("unused")
10 | class HiltTestRunner : AndroidJUnitRunner() {
11 |
12 | override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
13 | return super.newApplication(cl, HiltTestApplication::class.java.name, context)
14 | }
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/extensions.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | import arrow.core.Either
4 | import arrow.core.left
5 | import arrow.core.right
6 | import com.devexperto.architectcoders.domain.Error
7 | import retrofit2.HttpException
8 | import java.io.IOException
9 |
10 | fun Throwable.toError(): Error = when (this) {
11 | is IOException -> Error.Connectivity
12 | is HttpException -> Error.Server(code())
13 | else -> Error.Unknown(message ?: "")
14 | }
15 |
16 | suspend fun tryCall(action: suspend () -> T): Either = try {
17 | action().right()
18 | } catch (e: Exception) {
19 | e.toError().left()
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/detail/di.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.detail
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.devexperto.architectcoders.di.MovieId
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.android.components.ViewModelComponent
9 | import dagger.hilt.android.scopes.ViewModelScoped
10 |
11 | @Module
12 | @InstallIn(ViewModelComponent::class)
13 | class DetailViewModelModule {
14 |
15 | @Provides
16 | @ViewModelScoped
17 | @MovieId
18 | fun provideMovieId(savedStateHandle: SavedStateHandle) =
19 | DetailFragmentArgs.fromSavedStateHandle(savedStateHandle).movieId
20 |
21 | }
--------------------------------------------------------------------------------
/data/build.gradle:
--------------------------------------------------------------------------------
1 | import com.devexperto.architectcoders.buildsrc.Libs
2 |
3 | plugins {
4 | id 'java-library'
5 | id 'org.jetbrains.kotlin.jvm'
6 | id 'org.jetbrains.kotlin.kapt'
7 | }
8 |
9 | dependencies {
10 | implementation project(":domain")
11 |
12 | implementation Libs.Kotlin.Coroutines.core
13 | implementation Libs.JavaX.inject
14 | implementation Libs.Arrow.core
15 |
16 | testImplementation project(":testShared")
17 | testImplementation Libs.JUnit.junit
18 | testImplementation Libs.Mockito.kotlin
19 | testImplementation Libs.Mockito.inline
20 | }
21 |
22 | java {
23 | sourceCompatibility = JavaVersion.VERSION_17
24 | targetCompatibility = JavaVersion.VERSION_17
25 | }
--------------------------------------------------------------------------------
/usecases/build.gradle:
--------------------------------------------------------------------------------
1 | import com.devexperto.architectcoders.buildsrc.Libs
2 |
3 | plugins {
4 | id 'java-library'
5 | id 'org.jetbrains.kotlin.jvm'
6 | id 'org.jetbrains.kotlin.kapt'
7 | }
8 |
9 | dependencies {
10 | implementation project(":domain")
11 | implementation project(":data")
12 |
13 | implementation Libs.Kotlin.Coroutines.core
14 | implementation Libs.JavaX.inject
15 |
16 | testImplementation project(":testShared")
17 | testImplementation Libs.JUnit.junit
18 | testImplementation Libs.Mockito.kotlin
19 | testImplementation Libs.Mockito.inline
20 | }
21 |
22 | java {
23 | sourceCompatibility = JavaVersion.VERSION_17
24 | targetCompatibility = JavaVersion.VERSION_17
25 | }
--------------------------------------------------------------------------------
/usecases/src/test/java/com/devexperto/architectcoders/usecases/RequestPopularMoviesUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.data.MoviesRepository
4 | import kotlinx.coroutines.runBlocking
5 | import org.junit.Test
6 | import org.mockito.kotlin.mock
7 | import org.mockito.kotlin.verify
8 |
9 | class RequestPopularMoviesUseCaseTest {
10 |
11 | @Test
12 | fun `Invoke calls movies repository`(): Unit = runBlocking {
13 | val moviesRepository = mock()
14 | val requestPopularMoviesUseCase = RequestPopularMoviesUseCase(moviesRepository)
15 |
16 | requestPopularMoviesUseCase()
17 |
18 | verify(moviesRepository).requestPopularMovies()
19 | }
20 | }
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/usecases/src/test/java/com/devexperto/architectcoders/usecases/FindMovieUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.testshared.sampleMovie
4 | import kotlinx.coroutines.flow.flowOf
5 | import kotlinx.coroutines.runBlocking
6 | import org.junit.Assert.assertEquals
7 | import org.junit.Test
8 | import org.mockito.kotlin.doReturn
9 | import org.mockito.kotlin.mock
10 |
11 | class FindMovieUseCaseTest {
12 |
13 | @Test
14 | fun `Invoke calls movies repository`(): Unit = runBlocking {
15 | val movie = flowOf(sampleMovie.copy(id = 1))
16 | val findMovieUseCase = FindMovieUseCase(mock() {
17 | on { findById(1) } doReturn (movie)
18 | })
19 |
20 | val result = findMovieUseCase(1)
21 |
22 | assertEquals(movie, result)
23 | }
24 | }
--------------------------------------------------------------------------------
/appTestShared/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
--------------------------------------------------------------------------------
/data/src/main/java/com/devexperto/architectcoders/data/RegionRepository.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | import com.devexperto.architectcoders.data.PermissionChecker.Permission.COARSE_LOCATION
4 | import com.devexperto.architectcoders.data.datasource.LocationDataSource
5 | import javax.inject.Inject
6 |
7 | class RegionRepository @Inject constructor(
8 | private val locationDataSource: LocationDataSource,
9 | private val permissionChecker: PermissionChecker
10 | ) {
11 |
12 | companion object {
13 | const val DEFAULT_REGION = "US"
14 | }
15 |
16 | suspend fun findLastRegion(): String {
17 | return if (permissionChecker.check(COARSE_LOCATION)) {
18 | locationDataSource.findLastRegion() ?: DEFAULT_REGION
19 | } else {
20 | DEFAULT_REGION
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/AndroidPermissionChecker.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | import android.Manifest
4 | import android.app.Application
5 | import android.content.pm.PackageManager
6 | import androidx.core.content.ContextCompat
7 | import javax.inject.Inject
8 |
9 | class AndroidPermissionChecker @Inject constructor(private val application: Application) :
10 | PermissionChecker {
11 |
12 | override fun check(permission: PermissionChecker.Permission): Boolean =
13 | ContextCompat.checkSelfPermission(
14 | application,
15 | permission.toAndroidId()
16 | ) == PackageManager.PERMISSION_GRANTED
17 | }
18 |
19 | private fun PermissionChecker.Permission.toAndroidId() = when (this) {
20 | PermissionChecker.Permission.COARSE_LOCATION -> Manifest.permission.ACCESS_COARSE_LOCATION
21 | }
--------------------------------------------------------------------------------
/usecases/src/test/java/com/devexperto/architectcoders/usecases/GetPopularMoviesUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.testshared.sampleMovie
4 | import kotlinx.coroutines.flow.flowOf
5 | import kotlinx.coroutines.runBlocking
6 | import org.junit.Assert
7 | import org.junit.Test
8 | import org.mockito.kotlin.doReturn
9 | import org.mockito.kotlin.mock
10 |
11 | class GetPopularMoviesUseCaseTest {
12 |
13 | @Test
14 | fun `Invoke calls movies repository`(): Unit = runBlocking {
15 | val movies = flowOf(listOf(sampleMovie.copy(id = 1)))
16 | val getPopularMoviesUseCase = GetPopularMoviesUseCase(mock {
17 | on { popularMovies } doReturn movies
18 | })
19 |
20 | val result = getPopularMoviesUseCase()
21 |
22 | Assert.assertEquals(movies, result)
23 | }
24 | }
--------------------------------------------------------------------------------
/usecases/src/test/java/com/devexperto/architectcoders/usecases/SwitchMovieFavoriteUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.usecases
2 |
3 | import com.devexperto.architectcoders.data.MoviesRepository
4 | import com.devexperto.architectcoders.testshared.sampleMovie
5 | import kotlinx.coroutines.runBlocking
6 | import org.junit.Test
7 | import org.mockito.kotlin.mock
8 | import org.mockito.kotlin.verify
9 |
10 | class SwitchMovieFavoriteUseCaseTest {
11 |
12 | @Test
13 | fun `Invoke calls movies repository`(): Unit = runBlocking {
14 | val movie = sampleMovie.copy(id = 1)
15 | val moviesRepository = mock()
16 | val switchMovieFavoriteUseCase = SwitchMovieFavoriteUseCase(moviesRepository)
17 |
18 | switchMovieFavoriteUseCase(movie)
19 |
20 | verify(moviesRepository).switchFavorite(movie)
21 | }
22 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/devexperto/architectcoders/testrules/CoroutinesTestRule.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.testrules
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.StandardTestDispatcher
6 | import kotlinx.coroutines.test.resetMain
7 | import kotlinx.coroutines.test.setMain
8 | import org.junit.rules.TestWatcher
9 | import org.junit.runner.Description
10 |
11 | @ExperimentalCoroutinesApi
12 | class CoroutinesTestRule : TestWatcher() {
13 |
14 | val testDispatcher = StandardTestDispatcher()
15 |
16 | override fun starting(description: Description) {
17 | super.starting(description)
18 | Dispatchers.setMain(testDispatcher)
19 | }
20 |
21 | override fun finished(description: Description) {
22 | super.finished(description)
23 | Dispatchers.resetMain()
24 | }
25 | }
--------------------------------------------------------------------------------
/appTestShared/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.library'
3 | id 'org.jetbrains.kotlin.android'
4 | }
5 |
6 | android {
7 | namespace 'com.devexperto.architectcoders.appTestShared'
8 | compileSdk 31
9 |
10 | defaultConfig {
11 | minSdk 23
12 | targetSdk 31
13 | consumerProguardFiles "consumer-rules.pro"
14 | }
15 |
16 | buildTypes {
17 | release {
18 | minifyEnabled false
19 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
20 | }
21 | }
22 | compileOptions {
23 | sourceCompatibility JavaVersion.VERSION_1_8
24 | targetCompatibility JavaVersion.VERSION_1_8
25 | }
26 | kotlinOptions {
27 | jvmTarget = '1.8'
28 | }
29 | }
30 |
31 | dependencies {
32 | implementation project(":app")
33 | implementation project(":data")
34 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/common/PermissionRequester.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.common
2 |
3 | import androidx.activity.result.contract.ActivityResultContracts
4 | import androidx.fragment.app.Fragment
5 | import kotlinx.coroutines.suspendCancellableCoroutine
6 | import kotlin.coroutines.resume
7 |
8 | class PermissionRequester(fragment: Fragment, private val permission: String) {
9 |
10 | private var onRequest: (Boolean) -> Unit = {}
11 | private val launcher =
12 | fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
13 | onRequest(isGranted)
14 | }
15 |
16 | suspend fun request(): Boolean =
17 | suspendCancellableCoroutine { continuation ->
18 | onRequest = {
19 | continuation.resume(it)
20 | }
21 | launcher.launch(permission)
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
15 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/server/RemoteResult.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.server
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | data class RemoteResult(
6 | val page: Int,
7 | val results: List,
8 | @SerializedName("total_pages") val totalPages: Int,
9 | @SerializedName("total_results") val totalResults: Int
10 | )
11 |
12 | data class RemoteMovie(
13 | val adult: Boolean,
14 | @SerializedName("backdrop_path") val backdropPath: String?,
15 | @SerializedName("genre_ids") val genreIds: List,
16 | val id: Int,
17 | @SerializedName("original_language") val originalLanguage: String,
18 | @SerializedName("original_title") val originalTitle: String,
19 | val overview: String,
20 | val popularity: Double,
21 | @SerializedName("poster_path") val posterPath: String,
22 | @SerializedName("release_date") val releaseDate: String,
23 | val title: String,
24 | val video: Boolean,
25 | @SerializedName("vote_average") val voteAverage: Double,
26 | @SerializedName("vote_count") val voteCount: Int
27 | )
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/devexperto/architectcoders/data/server/mockResponseExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import okhttp3.mockwebserver.MockResponse
5 | import java.io.BufferedReader
6 | import java.io.InputStreamReader
7 | import java.nio.charset.StandardCharsets
8 |
9 | fun MockResponse.fromJson(jsonFile: String): MockResponse =
10 | setBody(readJsonFile(jsonFile))
11 |
12 | private fun readJsonFile(jsonFilePath: String): String {
13 | val context = InstrumentationRegistry.getInstrumentation().context
14 |
15 | var br: BufferedReader? = null
16 |
17 | try {
18 | br = BufferedReader(
19 | InputStreamReader(
20 | context.assets.open(
21 | jsonFilePath
22 | ), StandardCharsets.UTF_8
23 | )
24 | )
25 | var line: String?
26 | val text = StringBuilder()
27 |
28 | do {
29 | line = br.readLine()
30 | line?.let { text.append(line) }
31 | } while (line != null)
32 | br.close()
33 | return text.toString()
34 | } finally {
35 | br?.close()
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/detail/MovieDetailInfoView.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.detail
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import androidx.appcompat.widget.AppCompatTextView
6 | import androidx.core.text.bold
7 | import androidx.core.text.buildSpannedString
8 | import com.devexperto.architectcoders.domain.Movie
9 |
10 | class MovieDetailInfoView @JvmOverloads constructor(
11 | context: Context,
12 | attrs: AttributeSet? = null,
13 | defStyleAttr: Int = 0
14 | ) : AppCompatTextView(context, attrs, defStyleAttr) {
15 |
16 | fun setMovie(movie: Movie) = movie.apply {
17 | text = buildSpannedString {
18 |
19 | bold { append("Original language: ") }
20 | appendLine(originalLanguage)
21 |
22 | bold { append("Original title: ") }
23 | appendLine(originalTitle)
24 |
25 | bold { append("Release date: ") }
26 | appendLine(releaseDate)
27 |
28 | bold { append("Popularity: ") }
29 | appendLine(popularity.toString())
30 |
31 | bold { append("Vote Average: ") }
32 | append(voteAverage.toString())
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
17 |
18 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/detail/DetailFragment.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.detail
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.fragment.app.Fragment
6 | import androidx.fragment.app.viewModels
7 | import com.devexperto.architectcoders.R
8 | import com.devexperto.architectcoders.databinding.FragmentDetailBinding
9 | import com.devexperto.architectcoders.ui.common.launchAndCollect
10 | import dagger.hilt.android.AndroidEntryPoint
11 |
12 | @AndroidEntryPoint
13 | class DetailFragment : Fragment(R.layout.fragment_detail) {
14 |
15 | private val viewModel: DetailViewModel by viewModels()
16 |
17 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
18 | super.onViewCreated(view, savedInstanceState)
19 | val binding = FragmentDetailBinding.bind(view)
20 |
21 | binding.movieDetailToolbar.setNavigationOnClickListener {
22 | requireActivity().onBackPressedDispatcher.onBackPressed()
23 | }
24 | binding.movieDetailFavorite.setOnClickListener { viewModel.onFavoriteClicked() }
25 |
26 | viewLifecycleOwner.launchAndCollect(viewModel.state) { state ->
27 | if (state.movie != null) {
28 | binding.movie = state.movie
29 | }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/devexperto/architectcoders/ui/OkHttp3IdlingResource.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui
2 |
3 | import androidx.annotation.CheckResult
4 | import androidx.test.espresso.IdlingResource
5 | import okhttp3.Dispatcher
6 | import okhttp3.OkHttpClient
7 |
8 |
9 | class OkHttp3IdlingResource private constructor(
10 | private val name: String,
11 | private val dispatcher: Dispatcher
12 | ) :
13 | IdlingResource {
14 |
15 | @Volatile
16 | var callback: IdlingResource.ResourceCallback? = null
17 |
18 | override fun getName(): String = name
19 |
20 | override fun isIdleNow(): Boolean = dispatcher.runningCallsCount() == 0
21 |
22 | override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
23 | this.callback = callback
24 | }
25 |
26 | companion object {
27 | /**
28 | * Create a new [IdlingResource] from `client` as `name`. You must register
29 | * this instance using `Espresso.registerIdlingResources`.
30 | */
31 | @CheckResult
32 | fun create(name: String, client: OkHttpClient): OkHttp3IdlingResource {
33 | return OkHttp3IdlingResource(name, client.dispatcher)
34 | }
35 | }
36 |
37 | init {
38 | dispatcher.idleCallback = Runnable { callback?.onTransitionToIdle() }
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/res/navigation/nav_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
20 |
21 |
26 |
30 |
31 |
--------------------------------------------------------------------------------
/data/src/main/java/com/devexperto/architectcoders/data/MoviesRepository.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | import com.devexperto.architectcoders.data.datasource.MovieLocalDataSource
4 | import com.devexperto.architectcoders.data.datasource.MovieRemoteDataSource
5 | import com.devexperto.architectcoders.domain.Error
6 | import com.devexperto.architectcoders.domain.Movie
7 | import kotlinx.coroutines.flow.Flow
8 | import javax.inject.Inject
9 |
10 | class MoviesRepository @Inject constructor(
11 | private val regionRepository: RegionRepository,
12 | private val localDataSource: MovieLocalDataSource,
13 | private val remoteDataSource: MovieRemoteDataSource
14 | ) {
15 | val popularMovies get() = localDataSource.movies
16 |
17 | fun findById(id: Int): Flow = localDataSource.findById(id)
18 |
19 | suspend fun requestPopularMovies(): Error? {
20 | if (localDataSource.isEmpty()) {
21 | val movies = remoteDataSource.findPopularMovies(regionRepository.findLastRegion())
22 | movies.fold(ifLeft = { return it }) {
23 | localDataSource.save(it)
24 | }
25 | }
26 | return null
27 | }
28 |
29 | suspend fun switchFavorite(movie: Movie): Error? {
30 | val updatedMovie = movie.copy(favorite = !movie.favorite)
31 | return localDataSource.save(listOf(updatedMovie))
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/main/MoviesAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.main
2 |
3 | import android.view.View
4 | import android.view.ViewGroup
5 | import androidx.recyclerview.widget.ListAdapter
6 | import androidx.recyclerview.widget.RecyclerView
7 | import com.devexperto.architectcoders.R
8 | import com.devexperto.architectcoders.databinding.ViewMovieBinding
9 | import com.devexperto.architectcoders.domain.Movie
10 | import com.devexperto.architectcoders.ui.common.basicDiffUtil
11 | import com.devexperto.architectcoders.ui.common.inflate
12 |
13 | class MoviesAdapter(private val listener: (Movie) -> Unit) :
14 | ListAdapter(basicDiffUtil { old, new -> old.id == new.id }) {
15 |
16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
17 | val view = parent.inflate(R.layout.view_movie, false)
18 | return ViewHolder(view)
19 | }
20 |
21 | override fun onBindViewHolder(holder: ViewHolder, position: Int) {
22 | val movie = getItem(position)
23 | holder.bind(movie)
24 | holder.itemView.setOnClickListener { listener(movie) }
25 | }
26 |
27 | class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
28 | private val binding = ViewMovieBinding.bind(view)
29 | fun bind(movie: Movie) {
30 | binding.movie = movie
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/server/MovieServerDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.server
2 |
3 | import arrow.core.Either
4 | import com.devexperto.architectcoders.data.datasource.MovieRemoteDataSource
5 | import com.devexperto.architectcoders.data.tryCall
6 | import com.devexperto.architectcoders.di.ApiKey
7 | import com.devexperto.architectcoders.domain.Error
8 | import com.devexperto.architectcoders.domain.Movie
9 | import javax.inject.Inject
10 |
11 | class MovieServerDataSource @Inject constructor(
12 | @ApiKey private val apiKey: String,
13 | private val remoteService: RemoteService
14 | ) :
15 | MovieRemoteDataSource {
16 |
17 | override suspend fun findPopularMovies(region: String): Either> = tryCall {
18 | remoteService
19 | .listPopularMovies(apiKey, region)
20 | .results
21 | .toDomainModel()
22 | }
23 | }
24 |
25 | private fun List.toDomainModel(): List = map { it.toDomainModel() }
26 |
27 | private fun RemoteMovie.toDomainModel(): Movie =
28 | Movie(
29 | id,
30 | title,
31 | overview,
32 | releaseDate,
33 | "https://image.tmdb.org/t/p/w185/$posterPath",
34 | backdropPath?.let { "https://image.tmdb.org/t/p/w780/$it" } ?: "",
35 | originalLanguage,
36 | originalTitle,
37 | popularity,
38 | voteAverage,
39 | false
40 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/main/MainFragment.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.main
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.fragment.app.Fragment
6 | import androidx.fragment.app.viewModels
7 | import com.devexperto.architectcoders.R
8 | import com.devexperto.architectcoders.databinding.FragmentMainBinding
9 | import com.devexperto.architectcoders.ui.common.launchAndCollect
10 | import dagger.hilt.android.AndroidEntryPoint
11 |
12 | @AndroidEntryPoint
13 | class MainFragment : Fragment(R.layout.fragment_main) {
14 |
15 | private val viewModel: MainViewModel by viewModels()
16 |
17 | private lateinit var mainState: MainState
18 |
19 | private val adapter = MoviesAdapter { mainState.onMovieClicked(it) }
20 |
21 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
22 | super.onViewCreated(view, savedInstanceState)
23 |
24 | mainState = buildMainState()
25 |
26 | val binding = FragmentMainBinding.bind(view).apply {
27 | recycler.adapter = adapter
28 | }
29 |
30 | viewLifecycleOwner.launchAndCollect(viewModel.state) {
31 | binding.loading = it.loading
32 | binding.movies = it.movies
33 | binding.error = it.error?.let(mainState::errorToString)
34 | }
35 |
36 | mainState.requestLocationPermission {
37 | viewModel.onUiReady()
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | android.defaults.buildfeatures.buildconfig=true
25 | android.nonFinalResIds=false
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/common/AspectRatioImageView.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.common
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import androidx.appcompat.widget.AppCompatImageView
6 | import com.devexperto.architectcoders.R
7 |
8 | class AspectRatioImageView @JvmOverloads constructor(
9 | context: Context,
10 | attrs: AttributeSet? = null,
11 | defStyleAttr: Int = 0
12 | ) : AppCompatImageView(context, attrs, defStyleAttr) {
13 |
14 | private var ratio: Float = DEFAULT_RATIO
15 |
16 | init {
17 | attrs?.let {
18 | val a = context.obtainStyledAttributes(attrs, R.styleable.AspectRatioImageView)
19 | with(a) {
20 | ratio = getFloat(R.styleable.AspectRatioImageView_ratio, DEFAULT_RATIO)
21 | recycle()
22 | }
23 | }
24 | }
25 |
26 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
27 | super.onMeasure(widthMeasureSpec, heightMeasureSpec)
28 | var width = measuredWidth
29 | var height = measuredHeight
30 |
31 | if (width == 0 && height == 0) {
32 | return
33 | }
34 |
35 | if (width > 0) {
36 | height = (width * ratio).toInt()
37 | } else {
38 | width = (height / ratio).toInt()
39 | }
40 |
41 | setMeasuredDimension(width, height)
42 | }
43 |
44 | companion object {
45 | const val DEFAULT_RATIO = 1F
46 | }
47 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/PlayServicesLocationDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Application
5 | import android.location.Geocoder
6 | import android.location.Location
7 | import com.devexperto.architectcoders.data.datasource.LocationDataSource
8 | import com.devexperto.architectcoders.ui.common.getFromLocationCompat
9 | import com.google.android.gms.location.LocationServices
10 | import kotlinx.coroutines.suspendCancellableCoroutine
11 | import javax.inject.Inject
12 | import kotlin.coroutines.resume
13 |
14 | class PlayServicesLocationDataSource @Inject constructor(application: Application) : LocationDataSource {
15 | private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(application)
16 | private val geocoder = Geocoder(application)
17 |
18 | override suspend fun findLastRegion(): String? = findLastLocation().toRegion()
19 |
20 | @SuppressLint("MissingPermission")
21 | private suspend fun findLastLocation(): Location =
22 | suspendCancellableCoroutine { continuation ->
23 | fusedLocationClient.lastLocation
24 | .addOnCompleteListener {
25 | continuation.resume(it.result)
26 | }
27 | }
28 |
29 | private suspend fun Location?.toRegion(): String? {
30 | val addresses = this?.let {
31 | geocoder.getFromLocationCompat(latitude, longitude, 1)
32 | }
33 | return addresses?.firstOrNull()?.countryCode
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/data/src/test/java/com/devexperto/architectcoders/data/RegionRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | import com.devexperto.architectcoders.data.PermissionChecker.Permission.COARSE_LOCATION
4 | import com.devexperto.architectcoders.data.datasource.LocationDataSource
5 | import kotlinx.coroutines.runBlocking
6 | import org.junit.Assert.assertEquals
7 | import org.junit.Test
8 | import org.junit.runner.RunWith
9 | import org.mockito.junit.MockitoJUnitRunner
10 | import org.mockito.kotlin.doReturn
11 | import org.mockito.kotlin.mock
12 |
13 | @RunWith(MockitoJUnitRunner::class)
14 | class RegionRepositoryTest {
15 |
16 | @Test
17 | fun `Returns default region when coarse permission not granted`(): Unit = runBlocking {
18 | val regionRepository = buildRegionRepository(
19 | permissionChecker = mock { on { check(COARSE_LOCATION) } doReturn false }
20 | )
21 |
22 | val region = regionRepository.findLastRegion()
23 |
24 | assertEquals(RegionRepository.DEFAULT_REGION, region)
25 | }
26 |
27 | @Test
28 | fun `Returns region from location data source when permission granted`(): Unit = runBlocking {
29 | val regionRepository = buildRegionRepository(
30 | locationDataSource = mock { onBlocking { findLastRegion() } doReturn "ES" },
31 | permissionChecker = mock { on { check(COARSE_LOCATION) } doReturn true }
32 | )
33 |
34 | val region = regionRepository.findLastRegion()
35 |
36 | assertEquals("ES", region)
37 | }
38 | }
39 |
40 | private fun buildRegionRepository(
41 | locationDataSource: LocationDataSource = mock(),
42 | permissionChecker: PermissionChecker = mock()
43 | ) = RegionRepository(locationDataSource, permissionChecker)
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/detail/DetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.detail
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.devexperto.architectcoders.data.toError
6 | import com.devexperto.architectcoders.di.MovieId
7 | import com.devexperto.architectcoders.domain.Error
8 | import com.devexperto.architectcoders.domain.Movie
9 | import com.devexperto.architectcoders.usecases.FindMovieUseCase
10 | import com.devexperto.architectcoders.usecases.SwitchMovieFavoriteUseCase
11 | import dagger.hilt.android.lifecycle.HiltViewModel
12 | import kotlinx.coroutines.flow.MutableStateFlow
13 | import kotlinx.coroutines.flow.StateFlow
14 | import kotlinx.coroutines.flow.asStateFlow
15 | import kotlinx.coroutines.flow.catch
16 | import kotlinx.coroutines.flow.update
17 | import kotlinx.coroutines.launch
18 | import javax.inject.Inject
19 |
20 |
21 | @HiltViewModel
22 | class DetailViewModel @Inject constructor(
23 | @MovieId private val movieId: Int,
24 | findMovieUseCase: FindMovieUseCase,
25 | private val switchMovieFavoriteUseCase: SwitchMovieFavoriteUseCase
26 | ) : ViewModel() {
27 |
28 | private val _state = MutableStateFlow(UiState())
29 | val state: StateFlow = _state.asStateFlow()
30 |
31 | init {
32 | viewModelScope.launch {
33 | findMovieUseCase(movieId)
34 | .catch { cause -> _state.update { it.copy(error = cause.toError()) } }
35 | .collect { movie -> _state.update { UiState(movie = movie) } }
36 | }
37 | }
38 |
39 | fun onFavoriteClicked() {
40 | viewModelScope.launch {
41 | _state.value.movie?.let { movie ->
42 | val error = switchMovieFavoriteUseCase(movie)
43 | _state.update { it.copy(error = error) }
44 | }
45 | }
46 | }
47 |
48 | data class UiState(val movie: Movie? = null, val error: Error? = null)
49 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/main/MainViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.main
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.devexperto.architectcoders.data.toError
6 | import com.devexperto.architectcoders.domain.Error
7 | import com.devexperto.architectcoders.domain.Movie
8 | import com.devexperto.architectcoders.usecases.GetPopularMoviesUseCase
9 | import com.devexperto.architectcoders.usecases.RequestPopularMoviesUseCase
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.StateFlow
13 | import kotlinx.coroutines.flow.asStateFlow
14 | import kotlinx.coroutines.flow.catch
15 | import kotlinx.coroutines.flow.update
16 | import kotlinx.coroutines.launch
17 | import javax.inject.Inject
18 |
19 | @HiltViewModel
20 | class MainViewModel @Inject constructor(
21 | getPopularMoviesUseCase: GetPopularMoviesUseCase,
22 | private val requestPopularMoviesUseCase: RequestPopularMoviesUseCase
23 | ) : ViewModel() {
24 |
25 | private val _state = MutableStateFlow(UiState())
26 | val state: StateFlow = _state.asStateFlow()
27 |
28 | init {
29 | viewModelScope.launch {
30 | getPopularMoviesUseCase()
31 | .catch { cause -> _state.update { it.copy(error = cause.toError()) } }
32 | .collect { movies -> _state.update { UiState(movies = movies) } }
33 | }
34 | }
35 |
36 | fun onUiReady() {
37 | viewModelScope.launch {
38 | _state.value = _state.value.copy(loading = true)
39 | val error = requestPopularMoviesUseCase()
40 | _state.value = _state.value.copy(loading = false, error = error)
41 | }
42 | }
43 |
44 | data class UiState(
45 | val loading: Boolean = false,
46 | val movies: List? = null,
47 | val error: Error? = null
48 | )
49 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/data/database/MovieRoomDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data.database
2 |
3 | import com.devexperto.architectcoders.data.datasource.MovieLocalDataSource
4 | import com.devexperto.architectcoders.data.tryCall
5 | import com.devexperto.architectcoders.domain.Error
6 | import com.devexperto.architectcoders.domain.Movie
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.map
9 | import javax.inject.Inject
10 | import com.devexperto.architectcoders.data.database.Movie as DbMovie
11 |
12 | class MovieRoomDataSource @Inject constructor(private val movieDao: MovieDao) : MovieLocalDataSource {
13 |
14 | override val movies: Flow> = movieDao.getAll().map { it.toDomainModel() }
15 |
16 | override suspend fun isEmpty(): Boolean = movieDao.movieCount() == 0
17 |
18 | override fun findById(id: Int): Flow = movieDao.findById(id).map { it.toDomainModel() }
19 |
20 | override suspend fun save(movies: List): Error? = tryCall {
21 | movieDao.insertMovies(movies.fromDomainModel())
22 | }.fold(
23 | ifLeft = { it },
24 | ifRight = { null }
25 | )
26 | }
27 |
28 | private fun List.toDomainModel(): List = map { it.toDomainModel() }
29 |
30 | private fun DbMovie.toDomainModel(): Movie =
31 | Movie(
32 | id,
33 | title,
34 | overview,
35 | releaseDate,
36 | posterPath,
37 | backdropPath,
38 | originalLanguage,
39 | originalTitle,
40 | popularity,
41 | voteAverage,
42 | favorite
43 | )
44 |
45 | private fun List.fromDomainModel(): List = map { it.fromDomainModel() }
46 |
47 | private fun Movie.fromDomainModel(): DbMovie = DbMovie(
48 | id,
49 | title,
50 | overview,
51 | releaseDate,
52 | posterPath,
53 | backdropPath,
54 | originalLanguage,
55 | originalTitle,
56 | popularity,
57 | voteAverage,
58 | favorite
59 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/main/MainState.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.main
2 |
3 | import android.Manifest
4 | import android.content.Context
5 | import androidx.fragment.app.Fragment
6 | import androidx.lifecycle.lifecycleScope
7 | import androidx.navigation.NavController
8 | import androidx.navigation.fragment.findNavController
9 | import com.devexperto.architectcoders.R
10 | import com.devexperto.architectcoders.domain.Error
11 | import com.devexperto.architectcoders.domain.Movie
12 | import com.devexperto.architectcoders.ui.common.PermissionRequester
13 | import kotlinx.coroutines.CoroutineScope
14 | import kotlinx.coroutines.launch
15 |
16 | fun Fragment.buildMainState(
17 | context: Context = requireContext(),
18 | scope: CoroutineScope = viewLifecycleOwner.lifecycleScope,
19 | navController: NavController = findNavController(),
20 | locationPermissionRequester: PermissionRequester = PermissionRequester(
21 | this,
22 | Manifest.permission.ACCESS_COARSE_LOCATION
23 | )
24 | ) = MainState(context, scope, navController, locationPermissionRequester)
25 |
26 | class MainState(
27 | private val context: Context,
28 | private val scope: CoroutineScope,
29 | private val navController: NavController,
30 | private val locationPermissionRequester: PermissionRequester
31 | ) {
32 | fun onMovieClicked(movie: Movie) {
33 | val action = MainFragmentDirections.actionMainToDetail(movie.id)
34 | navController.navigate(action)
35 | }
36 |
37 | fun requestLocationPermission(afterRequest: (Boolean) -> Unit) {
38 | scope.launch {
39 | val result = locationPermissionRequester.request()
40 | afterRequest(result)
41 | }
42 | }
43 |
44 | fun errorToString(error: Error) = when (error) {
45 | Error.Connectivity -> context.getString(R.string.connectivity_error)
46 | is Error.Server -> context.getString(R.string.server_error) + error.code
47 | is Error.Unknown -> context.getString(R.string.unknown_error) + error.message
48 | }
49 |
50 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/devexperto/architectcoders/di/TestAppModule.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.di
2 |
3 | import android.app.Application
4 | import androidx.room.Room
5 | import com.devexperto.architectcoders.R
6 | import com.devexperto.architectcoders.data.database.MovieDatabase
7 | import com.devexperto.architectcoders.data.server.RemoteService
8 | import dagger.Module
9 | import dagger.Provides
10 | import dagger.hilt.components.SingletonComponent
11 | import dagger.hilt.testing.TestInstallIn
12 | import okhttp3.OkHttpClient
13 | import okhttp3.logging.HttpLoggingInterceptor
14 | import retrofit2.Retrofit
15 | import retrofit2.converter.gson.GsonConverterFactory
16 | import retrofit2.create
17 | import javax.inject.Singleton
18 |
19 | @Module
20 | @TestInstallIn(
21 | components = [SingletonComponent::class],
22 | replaces = [AppModule::class]
23 | )
24 | object TestAppModule {
25 |
26 | @Provides
27 | @Singleton
28 | @ApiKey
29 | fun provideApiKey(app: Application): String = app.getString(R.string.api_key)
30 |
31 | @Provides
32 | @Singleton
33 | fun provideDatabase(app: Application) = Room.inMemoryDatabaseBuilder(
34 | app,
35 | MovieDatabase::class.java
36 | ).build()
37 |
38 | @Provides
39 | @Singleton
40 | fun provideMovieDao(db: MovieDatabase) = db.movieDao()
41 |
42 | @Provides
43 | @Singleton
44 | @ApiUrl
45 | fun provideApiUrl(): String = "http://localhost:8080"
46 |
47 | @Provides
48 | @Singleton
49 | fun provideOkHttpClient(): OkHttpClient = HttpLoggingInterceptor().run {
50 | level = HttpLoggingInterceptor.Level.BODY
51 | OkHttpClient.Builder().addInterceptor(this).build()
52 | }
53 |
54 | @Provides
55 | @Singleton
56 | fun provideRemoteService(@ApiUrl apiUrl: String, okHttpClient: OkHttpClient): RemoteService {
57 | return Retrofit.Builder()
58 | .baseUrl(apiUrl)
59 | .client(okHttpClient)
60 | .addConverterFactory(GsonConverterFactory.create())
61 | .build()
62 | .create()
63 | }
64 | }
--------------------------------------------------------------------------------
/appTestShared/src/main/java/com/devexperto/architectcoders/appTestShared/helpers.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.appTestShared
2 |
3 | import com.devexperto.architectcoders.data.MoviesRepository
4 | import com.devexperto.architectcoders.data.RegionRepository
5 | import com.devexperto.architectcoders.data.database.MovieRoomDataSource
6 | import com.devexperto.architectcoders.data.server.MovieServerDataSource
7 | import com.devexperto.architectcoders.data.server.RemoteMovie
8 | import com.devexperto.architectcoders.data.database.Movie as DatabaseMovie
9 |
10 | fun buildRepositoryWith(
11 | localData: List,
12 | remoteData: List
13 | ): MoviesRepository {
14 | val locationDataSource = FakeLocationDataSource()
15 | val permissionChecker = FakePermissionChecker()
16 | val regionRepository = RegionRepository(locationDataSource, permissionChecker)
17 | val localDataSource = MovieRoomDataSource(FakeMovieDao(localData))
18 | val remoteDataSource = MovieServerDataSource("1234", FakeRemoteService(remoteData))
19 | return MoviesRepository(regionRepository, localDataSource, remoteDataSource)
20 | }
21 |
22 | fun buildDatabaseMovies(vararg id: Int) = id.map {
23 | DatabaseMovie(
24 | id = it,
25 | title = "Title $it",
26 | overview = "Overview $it",
27 | releaseDate = "01/01/2025",
28 | posterPath = "",
29 | backdropPath = "",
30 | originalLanguage = "EN",
31 | originalTitle = "Original Title $it",
32 | popularity = 5.0,
33 | voteAverage = 5.1,
34 | favorite = false
35 | )
36 | }
37 |
38 | fun buildRemoteMovies(vararg id: Int) = id.map {
39 | RemoteMovie(
40 | adult = false,
41 | backdropPath = "",
42 | genreIds = emptyList(),
43 | id = it,
44 | originalLanguage = "EN",
45 | originalTitle = "Original Title $it",
46 | overview = "Overview $it",
47 | popularity = 5.0,
48 | posterPath = "",
49 | releaseDate = "01/01/2025",
50 | title = "Title $it",
51 | video = false,
52 | voteAverage = 5.1,
53 | voteCount = 10
54 | )
55 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/devexperto/architectcoders/ui/detail/DetailViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.detail
2 |
3 | import app.cash.turbine.test
4 | import com.devexperto.architectcoders.testrules.CoroutinesTestRule
5 | import com.devexperto.architectcoders.testshared.sampleMovie
6 | import com.devexperto.architectcoders.ui.detail.DetailViewModel.UiState
7 | import com.devexperto.architectcoders.usecases.FindMovieUseCase
8 | import com.devexperto.architectcoders.usecases.SwitchMovieFavoriteUseCase
9 | import kotlinx.coroutines.ExperimentalCoroutinesApi
10 | import kotlinx.coroutines.flow.flowOf
11 | import kotlinx.coroutines.test.runCurrent
12 | import kotlinx.coroutines.test.runTest
13 | import org.junit.Assert.assertEquals
14 | import org.junit.Rule
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 | import org.mockito.Mock
18 | import org.mockito.junit.MockitoJUnitRunner
19 | import org.mockito.kotlin.verify
20 | import org.mockito.kotlin.whenever
21 |
22 |
23 | @ExperimentalCoroutinesApi
24 | @RunWith(MockitoJUnitRunner::class)
25 | class DetailViewModelTest {
26 |
27 | @get:Rule
28 | val coroutinesTestRule = CoroutinesTestRule()
29 |
30 | @Mock
31 | lateinit var findMovieUseCase: FindMovieUseCase
32 |
33 | @Mock
34 | lateinit var switchMovieFavoriteUseCase: SwitchMovieFavoriteUseCase
35 |
36 | private lateinit var vm: DetailViewModel
37 |
38 | private val movie = sampleMovie.copy(id = 2)
39 |
40 | @Test
41 | fun `UI is updated with the movie on start`() = runTest {
42 | vm = buildViewMoel()
43 | vm.state.test {
44 | assertEquals(UiState(), awaitItem())
45 | assertEquals(UiState(movie = movie), awaitItem())
46 | cancel()
47 | }
48 | }
49 |
50 | @Test
51 | fun `Favorite action calls the corresponding use case`() = runTest {
52 | vm = buildViewMoel()
53 | vm.onFavoriteClicked()
54 | runCurrent()
55 |
56 | verify(switchMovieFavoriteUseCase).invoke(movie)
57 | }
58 |
59 | private fun buildViewMoel(): DetailViewModel {
60 | whenever(findMovieUseCase(2)).thenReturn(flowOf(movie))
61 | return DetailViewModel(2, findMovieUseCase, switchMovieFavoriteUseCase)
62 | }
63 | }
--------------------------------------------------------------------------------
/appTestShared/src/main/java/com/devexperto/architectcoders/appTestShared/fakes.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.appTestShared
2 |
3 | import com.devexperto.architectcoders.data.PermissionChecker
4 | import com.devexperto.architectcoders.data.database.MovieDao
5 | import com.devexperto.architectcoders.data.datasource.LocationDataSource
6 | import com.devexperto.architectcoders.data.server.RemoteMovie
7 | import com.devexperto.architectcoders.data.server.RemoteResult
8 | import com.devexperto.architectcoders.data.server.RemoteService
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlinx.coroutines.flow.MutableStateFlow
11 | import com.devexperto.architectcoders.data.database.Movie as DatabaseMovie
12 |
13 | class FakeMovieDao(movies: List = emptyList()) : MovieDao {
14 |
15 | private val inMemoryMovies = MutableStateFlow(movies)
16 | private lateinit var findMovieFlow: MutableStateFlow
17 |
18 | override fun getAll(): Flow> = inMemoryMovies
19 |
20 | override fun findById(id: Int): Flow {
21 | findMovieFlow = MutableStateFlow(inMemoryMovies.value.first { it.id == id })
22 | return findMovieFlow
23 | }
24 |
25 | override suspend fun movieCount(): Int = inMemoryMovies.value.size
26 |
27 | override suspend fun insertMovies(movies: List) {
28 | inMemoryMovies.value = movies
29 |
30 | if (::findMovieFlow.isInitialized) {
31 | movies.firstOrNull() { it.id == findMovieFlow.value.id }
32 | ?.let { findMovieFlow.value = it }
33 | }
34 |
35 | }
36 |
37 | }
38 |
39 | class FakeRemoteService(private val movies: List = emptyList()) : RemoteService {
40 |
41 | override suspend fun listPopularMovies(apiKey: String, region: String) = RemoteResult(
42 | 1,
43 | movies,
44 | 1,
45 | movies.size
46 | )
47 |
48 | }
49 |
50 | class FakeLocationDataSource : LocationDataSource {
51 | var location = "US"
52 |
53 | override suspend fun findLastRegion(): String = location
54 | }
55 |
56 | class FakePermissionChecker : PermissionChecker {
57 | var permissionGranted = true
58 |
59 | override fun check(permission: PermissionChecker.Permission) = permissionGranted
60 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/view_movie.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
10 |
13 |
14 |
15 |
21 |
22 |
25 |
26 |
34 |
35 |
42 |
43 |
44 |
45 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/devexperto/architectcoders/ui/MainInstrumentationTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui
2 |
3 | import androidx.recyclerview.widget.RecyclerView
4 | import androidx.test.espresso.Espresso.onView
5 | import androidx.test.espresso.IdlingRegistry
6 | import androidx.test.espresso.action.ViewActions.click
7 | import androidx.test.espresso.assertion.ViewAssertions.matches
8 | import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
9 | import androidx.test.espresso.matcher.ViewMatchers.*
10 | import androidx.test.ext.junit.rules.ActivityScenarioRule
11 | import androidx.test.rule.GrantPermissionRule
12 | import com.devexperto.architectcoders.R
13 | import com.devexperto.architectcoders.data.server.MockWebServerRule
14 | import com.devexperto.architectcoders.fromJson
15 | import dagger.hilt.android.testing.HiltAndroidRule
16 | import dagger.hilt.android.testing.HiltAndroidTest
17 | import kotlinx.coroutines.ExperimentalCoroutinesApi
18 | import okhttp3.OkHttpClient
19 | import okhttp3.mockwebserver.MockResponse
20 | import org.junit.Before
21 | import org.junit.Rule
22 | import org.junit.Test
23 | import javax.inject.Inject
24 |
25 | @ExperimentalCoroutinesApi
26 | @HiltAndroidTest
27 | class MainInstrumentationTest {
28 |
29 | @get:Rule(order = 0)
30 | val hiltRule = HiltAndroidRule(this)
31 |
32 | @get:Rule(order = 1)
33 | val mockWebServerRule = MockWebServerRule()
34 |
35 | @get:Rule(order = 2)
36 | val locationPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
37 | "android.permission.ACCESS_COARSE_LOCATION"
38 | )
39 |
40 | @get:Rule(order = 3)
41 | val activityRule = ActivityScenarioRule(NavHostActivity::class.java)
42 |
43 | @Inject
44 | lateinit var okHttpClient: OkHttpClient
45 |
46 | @Before
47 | fun setUp() {
48 | mockWebServerRule.server.enqueue(
49 | MockResponse().fromJson("popular_movies.json")
50 | )
51 |
52 | hiltRule.inject()
53 |
54 | val resource = OkHttp3IdlingResource.create("OkHttp", okHttpClient)
55 | IdlingRegistry.getInstance().register(resource)
56 | }
57 |
58 | @Test
59 | fun click_a_movie_navigates_to_detail() {
60 | onView(withId(R.id.recycler))
61 | .perform(
62 | actionOnItemAtPosition(4, click()
63 | )
64 | )
65 |
66 | onView(withId(R.id.movie_detail_toolbar))
67 | .check(matches(hasDescendant(withText("Turning Red"))))
68 |
69 | }
70 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/devexperto/architectcoders/ui/detail/DetailIntegrationTests.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.detail
2 |
3 | import app.cash.turbine.test
4 | import com.devexperto.architectcoders.data.server.RemoteMovie
5 | import com.devexperto.architectcoders.testrules.CoroutinesTestRule
6 | import com.devexperto.architectcoders.appTestShared.buildDatabaseMovies
7 | import com.devexperto.architectcoders.appTestShared.buildRepositoryWith
8 | import com.devexperto.architectcoders.ui.detail.DetailViewModel.UiState
9 | import com.devexperto.architectcoders.usecases.FindMovieUseCase
10 | import com.devexperto.architectcoders.usecases.SwitchMovieFavoriteUseCase
11 | import kotlinx.coroutines.ExperimentalCoroutinesApi
12 | import kotlinx.coroutines.test.runTest
13 | import org.junit.Assert
14 | import org.junit.Rule
15 | import org.junit.Test
16 | import com.devexperto.architectcoders.data.database.Movie as DatabaseMovie
17 |
18 | @ExperimentalCoroutinesApi
19 | class DetailIntegrationTests {
20 |
21 | @get:Rule
22 | val coroutinesTestRule = CoroutinesTestRule()
23 |
24 | @Test
25 | fun `UI is updated with the movie on start`() = runTest {
26 | val vm = buildViewModelWith(
27 | id = 2,
28 | localData = buildDatabaseMovies(1, 2, 3)
29 | )
30 |
31 | vm.state.test {
32 | Assert.assertEquals(UiState(), awaitItem())
33 | Assert.assertEquals(2, awaitItem().movie!!.id)
34 | cancel()
35 | }
36 | }
37 |
38 | @Test
39 | fun `Favorite is updated in local data source`() = runTest {
40 | val vm = buildViewModelWith(
41 | id = 2,
42 | localData = buildDatabaseMovies(1, 2, 3)
43 | )
44 |
45 | vm.onFavoriteClicked()
46 |
47 | vm.state.test {
48 | Assert.assertEquals(UiState(), awaitItem())
49 | Assert.assertEquals(false, awaitItem().movie!!.favorite)
50 | Assert.assertEquals(true, awaitItem().movie!!.favorite)
51 | cancel()
52 | }
53 | }
54 |
55 | private fun buildViewModelWith(
56 | id: Int,
57 | localData: List = emptyList(),
58 | remoteData: List = emptyList()
59 | ): DetailViewModel {
60 | val moviesRepository = buildRepositoryWith(localData, remoteData)
61 | val findMovieUseCase = FindMovieUseCase(moviesRepository)
62 | val switchMovieFavoriteUseCase = SwitchMovieFavoriteUseCase(moviesRepository)
63 | return DetailViewModel(id, findMovieUseCase, switchMovieFavoriteUseCase)
64 | }
65 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/ui/common/extensions.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.common
2 |
3 | import android.content.Context
4 | import android.location.Address
5 | import android.location.Geocoder
6 | import android.os.Build
7 | import android.view.LayoutInflater
8 | import android.view.View
9 | import android.view.ViewGroup
10 | import android.widget.ImageView
11 | import androidx.annotation.FloatRange
12 | import androidx.annotation.IntRange
13 | import androidx.annotation.LayoutRes
14 | import androidx.fragment.app.Fragment
15 | import androidx.recyclerview.widget.DiffUtil
16 | import androidx.lifecycle.Lifecycle
17 | import androidx.lifecycle.LifecycleOwner
18 | import androidx.lifecycle.lifecycleScope
19 | import androidx.lifecycle.repeatOnLifecycle
20 | import com.bumptech.glide.Glide
21 | import com.devexperto.architectcoders.App
22 | import kotlinx.coroutines.flow.Flow
23 | import kotlinx.coroutines.launch
24 | import kotlinx.coroutines.suspendCancellableCoroutine
25 | import kotlin.coroutines.resume
26 |
27 | fun ViewGroup.inflate(@LayoutRes layoutRes: Int, attachToRoot: Boolean = true): View =
28 | LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot)
29 |
30 | fun ImageView.loadUrl(url: String) {
31 | Glide.with(context).load(url).into(this)
32 | }
33 |
34 | inline fun basicDiffUtil(
35 | crossinline areItemsTheSame: (T, T) -> Boolean = { old, new -> old == new },
36 | crossinline areContentsTheSame: (T, T) -> Boolean = { old, new -> old == new }
37 | ) = object : DiffUtil.ItemCallback() {
38 | override fun areItemsTheSame(oldItem: T, newItem: T): Boolean =
39 | areItemsTheSame(oldItem, newItem)
40 |
41 | override fun areContentsTheSame(oldItem: T, newItem: T): Boolean =
42 | areContentsTheSame(oldItem, newItem)
43 | }
44 |
45 | @Suppress("DEPRECATION")
46 | suspend fun Geocoder.getFromLocationCompat(
47 | @FloatRange(from = -90.0, to = 90.0) latitude: Double,
48 | @FloatRange(from = -180.0, to = 180.0) longitude: Double,
49 | @IntRange maxResults: Int
50 | ): List = suspendCancellableCoroutine { continuation ->
51 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
52 | getFromLocation(latitude, longitude, maxResults) {
53 | continuation.resume(it)
54 | }
55 | } else {
56 | continuation.resume(getFromLocation(latitude, longitude, maxResults) ?: emptyList())
57 | }
58 | }
59 |
60 | fun LifecycleOwner.launchAndCollect(
61 | flow: Flow,
62 | state: Lifecycle.State = Lifecycle.State.STARTED,
63 | body: (T) -> Unit
64 | ) {
65 | lifecycleScope.launch {
66 | this@launchAndCollect.repeatOnLifecycle(state) {
67 | flow.collect(body)
68 | }
69 | }
70 | }
71 |
72 | val Context.app: App get() = applicationContext as App
73 |
74 | val Fragment.app: App get() = requireContext().app
--------------------------------------------------------------------------------
/app/src/test/java/com/devexperto/architectcoders/ui/main/MainViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.main
2 |
3 | import app.cash.turbine.test
4 | import com.devexperto.architectcoders.testrules.CoroutinesTestRule
5 | import com.devexperto.architectcoders.testshared.sampleMovie
6 | import com.devexperto.architectcoders.ui.main.MainViewModel.UiState
7 | import com.devexperto.architectcoders.usecases.GetPopularMoviesUseCase
8 | import com.devexperto.architectcoders.usecases.RequestPopularMoviesUseCase
9 | import kotlinx.coroutines.ExperimentalCoroutinesApi
10 | import kotlinx.coroutines.flow.flowOf
11 | import kotlinx.coroutines.test.runCurrent
12 | import kotlinx.coroutines.test.runTest
13 | import org.junit.Assert.assertEquals
14 | import org.junit.Rule
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 | import org.mockito.Mock
18 | import org.mockito.junit.MockitoJUnitRunner
19 | import org.mockito.kotlin.verify
20 | import org.mockito.kotlin.whenever
21 |
22 | @ExperimentalCoroutinesApi
23 | @RunWith(MockitoJUnitRunner::class)
24 | class MainViewModelTest {
25 |
26 | @get:Rule
27 | val coroutinesTestRule = CoroutinesTestRule()
28 |
29 | @Mock
30 | lateinit var getPopularMoviesUseCase: GetPopularMoviesUseCase
31 |
32 | @Mock
33 | lateinit var requestPopularMoviesUseCase: RequestPopularMoviesUseCase
34 |
35 | private lateinit var vm: MainViewModel
36 |
37 | private val movies = listOf(sampleMovie.copy(id = 1))
38 |
39 | @Test
40 | fun `State is updated with current cached content immediately`() = runTest {
41 | vm = buildViewModel()
42 |
43 | vm.state.test {
44 | assertEquals(UiState(), awaitItem())
45 | assertEquals(UiState(movies = movies), awaitItem())
46 | cancel()
47 | }
48 | }
49 |
50 | @Test
51 | fun `Progress is shown when screen starts and hidden when it finishes requesting movies`() =
52 | runTest {
53 | vm = buildViewModel()
54 | vm.onUiReady()
55 |
56 | vm.state.test {
57 | assertEquals(UiState(), awaitItem())
58 | assertEquals(UiState(movies = movies), awaitItem())
59 | assertEquals(UiState(movies = movies, loading = true), awaitItem())
60 | assertEquals(UiState(movies = movies, loading = false), awaitItem())
61 | cancel()
62 | }
63 | }
64 |
65 |
66 | @Test
67 | fun `Popular movies are requested when UI screen starts`() = runTest {
68 | vm = buildViewModel()
69 | vm.onUiReady()
70 | runCurrent()
71 |
72 | verify(requestPopularMoviesUseCase).invoke()
73 | }
74 |
75 | private fun buildViewModel(): MainViewModel {
76 | whenever(getPopularMoviesUseCase()).thenReturn(flowOf(movies))
77 | return MainViewModel(getPopularMoviesUseCase, requestPopularMoviesUseCase)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
15 |
16 |
19 |
20 |
23 |
24 |
25 |
30 |
31 |
37 |
38 |
43 |
44 |
45 |
46 |
55 |
56 |
63 |
64 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/java/com/devexperto/architectcoders/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.di
2 |
3 | import android.app.Application
4 | import androidx.room.Room
5 | import com.devexperto.architectcoders.R
6 | import com.devexperto.architectcoders.data.AndroidPermissionChecker
7 | import com.devexperto.architectcoders.data.PermissionChecker
8 | import com.devexperto.architectcoders.data.PlayServicesLocationDataSource
9 | import com.devexperto.architectcoders.data.database.MovieDatabase
10 | import com.devexperto.architectcoders.data.database.MovieRoomDataSource
11 | import com.devexperto.architectcoders.data.datasource.LocationDataSource
12 | import com.devexperto.architectcoders.data.datasource.MovieLocalDataSource
13 | import com.devexperto.architectcoders.data.datasource.MovieRemoteDataSource
14 | import com.devexperto.architectcoders.data.server.MovieServerDataSource
15 | import com.devexperto.architectcoders.data.server.RemoteService
16 | import dagger.Binds
17 | import dagger.Module
18 | import dagger.Provides
19 | import dagger.hilt.InstallIn
20 | import dagger.hilt.components.SingletonComponent
21 | import okhttp3.OkHttpClient
22 | import okhttp3.logging.HttpLoggingInterceptor
23 | import retrofit2.Retrofit
24 | import retrofit2.converter.gson.GsonConverterFactory
25 | import retrofit2.create
26 | import javax.inject.Singleton
27 |
28 | @Module
29 | @InstallIn(SingletonComponent::class)
30 | object AppModule {
31 |
32 | @Provides
33 | @Singleton
34 | @ApiKey
35 | fun provideApiKey(app: Application): String = app.getString(R.string.api_key)
36 |
37 | @Provides
38 | @Singleton
39 | fun provideDatabase(app: Application) = Room.databaseBuilder(
40 | app,
41 | MovieDatabase::class.java,
42 | "movie-db"
43 | ).build()
44 |
45 | @Provides
46 | @Singleton
47 | fun provideMovieDao(db: MovieDatabase) = db.movieDao()
48 |
49 | @Provides
50 | @Singleton
51 | @ApiUrl
52 | fun provideApiUrl(): String = "https://api.themoviedb.org/3/"
53 |
54 | @Provides
55 | @Singleton
56 | fun provideOkHttpClient(): OkHttpClient = HttpLoggingInterceptor().run {
57 | level = HttpLoggingInterceptor.Level.BODY
58 | OkHttpClient.Builder().addInterceptor(this).build()
59 | }
60 |
61 | @Provides
62 | @Singleton
63 | fun provideRemoteService(@ApiUrl apiUrl: String, okHttpClient: OkHttpClient): RemoteService {
64 |
65 | return Retrofit.Builder()
66 | .baseUrl(apiUrl)
67 | .client(okHttpClient)
68 | .addConverterFactory(GsonConverterFactory.create())
69 | .build()
70 | .create()
71 | }
72 |
73 | }
74 |
75 | @Module
76 | @InstallIn(SingletonComponent::class)
77 | abstract class AppDataModule {
78 |
79 | @Binds
80 | abstract fun bindLocalDataSource(localDataSource: MovieRoomDataSource): MovieLocalDataSource
81 |
82 | @Binds
83 | abstract fun bindRemoteDataSource(remoteDataSource: MovieServerDataSource): MovieRemoteDataSource
84 |
85 | @Binds
86 | abstract fun bindLocationDataSource(locationDataSource: PlayServicesLocationDataSource): LocationDataSource
87 |
88 | @Binds
89 | abstract fun bindPermissionChecker(permissionChecker: AndroidPermissionChecker): PermissionChecker
90 |
91 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/devexperto/architectcoders/ui/main/MainIntegrationTests.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.ui.main
2 |
3 | import app.cash.turbine.test
4 | import com.devexperto.architectcoders.data.server.RemoteMovie
5 | import com.devexperto.architectcoders.testrules.CoroutinesTestRule
6 | import com.devexperto.architectcoders.appTestShared.buildDatabaseMovies
7 | import com.devexperto.architectcoders.appTestShared.buildRemoteMovies
8 | import com.devexperto.architectcoders.appTestShared.buildRepositoryWith
9 | import com.devexperto.architectcoders.ui.main.MainViewModel.UiState
10 | import com.devexperto.architectcoders.usecases.GetPopularMoviesUseCase
11 | import com.devexperto.architectcoders.usecases.RequestPopularMoviesUseCase
12 | import kotlinx.coroutines.ExperimentalCoroutinesApi
13 | import kotlinx.coroutines.test.runTest
14 | import org.junit.Assert.assertEquals
15 | import org.junit.Rule
16 | import org.junit.Test
17 | import com.devexperto.architectcoders.data.database.Movie as DatabaseMovie
18 |
19 | @ExperimentalCoroutinesApi
20 | class MainIntegrationTests {
21 |
22 | @get:Rule
23 | val coroutinesTestRule = CoroutinesTestRule()
24 |
25 | @Test
26 | fun `data is loaded from server when local source is empty`() = runTest {
27 | val remoteData = buildRemoteMovies(4, 5, 6)
28 | val vm = buildViewModelWith(
29 | localData = emptyList(),
30 | remoteData = remoteData
31 | )
32 |
33 | vm.onUiReady()
34 |
35 | vm.state.test {
36 | assertEquals(UiState(), awaitItem())
37 | assertEquals(UiState(movies = emptyList()), awaitItem())
38 | assertEquals(UiState(movies = emptyList(), loading = true), awaitItem())
39 | assertEquals(UiState(movies = emptyList(), loading = false), awaitItem())
40 |
41 | val movies = awaitItem().movies!!
42 | assertEquals("Title 4", movies[0].title)
43 | assertEquals("Title 5", movies[1].title)
44 | assertEquals("Title 6", movies[2].title)
45 |
46 | cancel()
47 | }
48 | }
49 |
50 | @Test
51 | fun `data is loaded from local source when available`() = runTest {
52 | val localData = buildDatabaseMovies(1, 2, 3)
53 | val remoteData = buildRemoteMovies(4, 5, 6)
54 | val vm = buildViewModelWith(
55 | localData = localData,
56 | remoteData = remoteData
57 | )
58 |
59 | vm.state.test {
60 | assertEquals(UiState(), awaitItem())
61 |
62 | val movies = awaitItem().movies!!
63 | assertEquals("Title 1", movies[0].title)
64 | assertEquals("Title 2", movies[1].title)
65 | assertEquals("Title 3", movies[2].title)
66 |
67 | cancel()
68 | }
69 | }
70 |
71 | private fun buildViewModelWith(
72 | localData: List = emptyList(),
73 | remoteData: List = emptyList()
74 | ): MainViewModel {
75 | val moviesRepository = buildRepositoryWith(localData, remoteData)
76 | val getPopularMoviesUseCase = GetPopularMoviesUseCase(moviesRepository)
77 | val requestPopularMoviesUseCase = RequestPopularMoviesUseCase(moviesRepository)
78 | return MainViewModel(getPopularMoviesUseCase, requestPopularMoviesUseCase)
79 | }
80 | }
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | import com.devexperto.architectcoders.buildsrc.Libs
2 |
3 | plugins {
4 | id 'com.android.application'
5 | id 'org.jetbrains.kotlin.android'
6 | id 'org.jetbrains.kotlin.kapt'
7 | id 'kotlin-parcelize'
8 | id 'androidx.navigation.safeargs.kotlin'
9 | id 'dagger.hilt.android.plugin'
10 | }
11 |
12 | android {
13 | compileSdk 33
14 |
15 | defaultConfig {
16 | applicationId "com.devexperto.architectcoders"
17 | minSdk 24
18 | targetSdk 33
19 | versionCode 1
20 | versionName "1.0"
21 |
22 | testInstrumentationRunner "com.devexperto.architectcoders.di.HiltTestRunner"
23 | }
24 |
25 | buildTypes {
26 | release {
27 | minifyEnabled false
28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
29 | }
30 | }
31 | compileOptions {
32 | sourceCompatibility JavaVersion.VERSION_17
33 | targetCompatibility JavaVersion.VERSION_17
34 | }
35 | kotlinOptions {
36 | jvmTarget = '17'
37 | }
38 | buildFeatures {
39 | dataBinding true
40 | }
41 | namespace 'com.devexperto.architectcoders'
42 | }
43 |
44 | dependencies {
45 | implementation project(":data")
46 | implementation project(":domain")
47 | implementation project(":usecases")
48 |
49 | implementation Libs.AndroidX.coreKtx
50 | implementation Libs.AndroidX.appCompat
51 | implementation Libs.AndroidX.recyclerView
52 | implementation Libs.AndroidX.material
53 | implementation Libs.AndroidX.constraintLayout
54 |
55 | implementation Libs.AndroidX.Activity.ktx
56 |
57 | implementation Libs.AndroidX.Lifecycle.viewmodelKtx
58 | implementation Libs.AndroidX.Lifecycle.runtimeKtx
59 |
60 | implementation Libs.AndroidX.Navigation.fragmentKtx
61 | implementation Libs.AndroidX.Navigation.uiKtx
62 |
63 | implementation Libs.AndroidX.Room.runtime
64 | implementation Libs.AndroidX.Room.ktx
65 | kapt Libs.AndroidX.Room.compiler
66 |
67 | implementation Libs.playServicesLocation
68 |
69 | implementation Libs.Glide.glide
70 | kapt Libs.Glide.compiler
71 |
72 | implementation Libs.OkHttp3.loginInterceptor
73 | implementation Libs.Retrofit.retrofit
74 | implementation Libs.Retrofit.converterGson
75 |
76 | implementation Libs.Arrow.core
77 |
78 | implementation Libs.Hilt.android
79 | kapt Libs.Hilt.compiler
80 |
81 | testImplementation project(":testShared")
82 | testImplementation project(":appTestShared")
83 |
84 | testImplementation Libs.JUnit.junit
85 | testImplementation Libs.Mockito.kotlin
86 | testImplementation Libs.Mockito.inline
87 | testImplementation Libs.Kotlin.Coroutines.test
88 | testImplementation Libs.turbine
89 |
90 | androidTestImplementation project(":appTestShared")
91 | androidTestImplementation Libs.AndroidX.Test.Ext.junit
92 | androidTestImplementation Libs.AndroidX.Test.Espresso.contrib
93 | androidTestImplementation Libs.AndroidX.Test.runner
94 | androidTestImplementation Libs.AndroidX.Test.rules
95 | androidTestImplementation Libs.Hilt.test
96 | androidTestImplementation Libs.Kotlin.Coroutines.test
97 | kaptAndroidTest Libs.Hilt.compiler
98 |
99 | androidTestImplementation Libs.OkHttp3.mockWebServer
100 | }
101 |
102 | kapt {
103 | correctErrorTypes true
104 | }
--------------------------------------------------------------------------------
/data/src/test/java/com/devexperto/architectcoders/data/MoviesRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.devexperto.architectcoders.data
2 |
3 | import arrow.core.right
4 | import com.devexperto.architectcoders.data.datasource.MovieLocalDataSource
5 | import com.devexperto.architectcoders.data.datasource.MovieRemoteDataSource
6 | import com.devexperto.architectcoders.testshared.sampleMovie
7 | import kotlinx.coroutines.flow.flowOf
8 | import kotlinx.coroutines.runBlocking
9 | import org.junit.Assert.assertEquals
10 | import org.junit.Before
11 | import org.junit.Test
12 | import org.junit.runner.RunWith
13 | import org.mockito.Mock
14 | import org.mockito.junit.MockitoJUnitRunner
15 | import org.mockito.kotlin.any
16 | import org.mockito.kotlin.argThat
17 | import org.mockito.kotlin.verify
18 | import org.mockito.kotlin.whenever
19 |
20 | @RunWith(MockitoJUnitRunner::class)
21 | class MoviesRepositoryTest {
22 |
23 | @Mock
24 | lateinit var localDataSource: MovieLocalDataSource
25 |
26 | @Mock
27 | lateinit var remoteDataSource: MovieRemoteDataSource
28 |
29 | @Mock
30 | lateinit var regionRepository: RegionRepository
31 |
32 | private lateinit var moviesRepository: MoviesRepository
33 |
34 | private val localMovies = flowOf(listOf(sampleMovie.copy(1)))
35 |
36 | @Before
37 | fun setUp() {
38 | whenever(localDataSource.movies).thenReturn(localMovies)
39 | moviesRepository = MoviesRepository(regionRepository, localDataSource, remoteDataSource)
40 | }
41 |
42 | @Test
43 | fun `Popular movies are taken from local data source if available`(): Unit = runBlocking {
44 |
45 | val result = moviesRepository.popularMovies
46 |
47 | assertEquals(localMovies, result)
48 | }
49 |
50 | @Test
51 | fun `Popular movies are saved to local data source when it's empty`(): Unit = runBlocking {
52 | val remoteMovies = listOf(sampleMovie.copy(2))
53 | whenever(localDataSource.isEmpty()).thenReturn(true)
54 | whenever(regionRepository.findLastRegion()).thenReturn(RegionRepository.DEFAULT_REGION)
55 | whenever(remoteDataSource.findPopularMovies(any())).thenReturn(remoteMovies.right())
56 |
57 | moviesRepository.requestPopularMovies()
58 |
59 | verify(localDataSource).save(remoteMovies)
60 | }
61 |
62 | @Test
63 | fun `Finding a movie by id is done in local data source`(): Unit = runBlocking {
64 | val movie = flowOf(sampleMovie.copy(id = 5))
65 | whenever(localDataSource.findById(5)).thenReturn(movie)
66 |
67 | val result = moviesRepository.findById(5)
68 |
69 | assertEquals(movie, result)
70 | }
71 |
72 | @Test
73 | fun `Switching favorite updates local data source`(): Unit = runBlocking {
74 | val movie = sampleMovie.copy(id = 3)
75 |
76 | moviesRepository.switchFavorite(movie)
77 |
78 | verify(localDataSource).save(argThat { get(0).id == 3 })
79 | }
80 |
81 | @Test
82 | fun `Switching favorite marks as favorite an unfavorite movie`(): Unit = runBlocking {
83 | val movie = sampleMovie.copy(favorite = false)
84 |
85 | moviesRepository.switchFavorite(movie)
86 |
87 | verify(localDataSource).save(argThat { get(0).favorite })
88 | }
89 |
90 | @Test
91 | fun `Switching favorite marks as unfavorite a favorite movie`(): Unit = runBlocking {
92 | val movie = sampleMovie.copy(favorite = true)
93 |
94 | moviesRepository.switchFavorite(movie)
95 |
96 | verify(localDataSource).save(argThat { !get(0).favorite })
97 | }
98 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_detail.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
11 |
12 |
13 |
18 |
19 |
25 |
26 |
32 |
33 |
42 |
43 |
49 |
50 |
51 |
52 |
53 |
54 |
58 |
59 |
64 |
65 |
73 |
74 |
84 |
85 |
86 |
87 |
88 |
89 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/src/androidTest/assets/popular_movies.json:
--------------------------------------------------------------------------------
1 | {"page":1,"results":[{"adult":false,"backdrop_path":"/5P8SmMzSNYikXpxil6BYzJ16611.jpg","genre_ids":[80,9648,53],"id":414906,"original_language":"en","original_title":"The Batman","overview":"In his second year of fighting crime, Batman uncovers corruption in Gotham City that connects to his own family while facing a serial killer known as the Riddler.","popularity":11286.255,"poster_path":"/74xTEgt7R36Fpooo50r9T25onhq.jpg","release_date":"2022-03-04","title":"The Batman","video":false,"vote_average":7.8,"vote_count":3844},{"adult":false,"backdrop_path":"/2n95p9isIi1LYTscTcGytlI4zYd.jpg","genre_ids":[18,53,80],"id":799876,"original_language":"en","original_title":"The Outfit","overview":"Leonard is an English tailor who used to craft suits on London’s world-famous Savile Row. After a personal tragedy, he’s ended up in Chicago, operating a small tailor shop in a rough part of town where he makes beautiful clothes for the only people around who can afford them: a family of vicious gangsters.","popularity":3847.22,"poster_path":"/mBUoNT1nJ2dK53PXRSUOyoPez8S.jpg","release_date":"2022-03-18","title":"The Outfit","video":false,"vote_average":6.9,"vote_count":125},{"adult":false,"backdrop_path":"/iQFcwSGbZXMkeyKrxbPnwnRo5fl.jpg","genre_ids":[28,12,878],"id":634649,"original_language":"en","original_title":"Spider-Man: No Way Home","overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to discover what it truly means to be Spider-Man.","popularity":4469.989,"poster_path":"/1g0dhYtq4irTY1GPXvft6k4YLjm.jpg","release_date":"2021-12-17","title":"Spider-Man: No Way Home","video":false,"vote_average":8.1,"vote_count":12064},{"adult":false,"backdrop_path":"/fOy2Jurz9k6RnJnMUMRDAgBwru2.jpg","genre_ids":[16,10751,35,14],"id":508947,"original_language":"en","original_title":"Turning Red","overview":"Thirteen-year-old Mei is experiencing the awkwardness of being a teenager with a twist – when she gets too excited, she transforms into a giant red panda.","popularity":3359.913,"poster_path":"/qsdjk9oAKSQMWs0Vt5Pyfh6O4GZ.jpg","release_date":"2022-03-01","title":"Turning Red","video":false,"vote_average":7.5,"vote_count":1889},{"adult":false,"backdrop_path":"/egoyMDLqCxzjnSrWOz50uLlJWmD.jpg","genre_ids":[28,878,35,10751],"id":675353,"original_language":"en","original_title":"Sonic the Hedgehog 2","overview":"After settling in Green Hills, Sonic is eager to prove he has what it takes to be a true hero. His test comes when Dr. Robotnik returns, this time with a new partner, Knuckles, in search for an emerald that has the power to destroy civilizations. Sonic teams up with his own sidekick, Tails, and together they embark on a globe-trotting journey to find the emerald before it falls into the wrong hands.","popularity":3053.837,"poster_path":"/6DrHO1jr3qVrViUO6s6kFiAGM7.jpg","release_date":"2022-04-08","title":"Sonic the Hedgehog 2","video":false,"vote_average":7.6,"vote_count":700},{"adult":false,"backdrop_path":"/x747ZvF0CcYYTTpPRCoUrxA2cYy.jpg","genre_ids":[28,12,878],"id":406759,"original_language":"en","original_title":"Moonfall","overview":"A mysterious force knocks the moon from its orbit around Earth and sends it hurtling on a collision course with life as we know it.","popularity":2287.837,"poster_path":"/odVv1sqVs0KxBXiA8bhIBlPgalx.jpg","release_date":"2022-02-04","title":"Moonfall","video":false,"vote_average":6.5,"vote_count":796},{"adult":false,"backdrop_path":"/xicKILMzPn6XZYCOpWwaxlUzg6S.jpg","genre_ids":[53,28],"id":294793,"original_language":"en","original_title":"All the Old Knives","overview":"When the CIA discovers one of its agents leaked information that cost more than 100 people their lives, veteran operative Henry Pelham is assigned to root out the mole with his former lover and colleague Celia Harrison.","popularity":2199.058,"poster_path":"/g4tMniKxol1TBJrHlAtiDjjlx4Q.jpg","release_date":"2022-04-08","title":"All the Old Knives","video":false,"vote_average":6,"vote_count":168},{"adult":false,"backdrop_path":"/aEGiJJP91HsKVTEPy1HhmN0wRLm.jpg","genre_ids":[28,12],"id":335787,"original_language":"en","original_title":"Uncharted","overview":"A young street-smart, Nathan Drake and his wisecracking partner Victor “Sully” Sullivan embark on a dangerous pursuit of “the greatest treasure never found” while also tracking clues that may lead to Nathan’s long-lost brother.","popularity":2269.063,"poster_path":"/sqLowacltbZLoCa4KYye64RvvdQ.jpg","release_date":"2022-02-18","title":"Uncharted","video":false,"vote_average":7.2,"vote_count":1259},{"adult":false,"backdrop_path":"/dqWiut9F30jkiKHHkYTf2RIy1g7.jpg","genre_ids":[878,28],"id":919689,"original_language":"en","original_title":"War of the Worlds: Annihilation","overview":"A mother and son find themselves faced with a brutal alien invasion where survival will depend on discovering the unthinkable truth about the enemy.","popularity":1487.668,"poster_path":"/9eiUNsUAw2iwVyMeXNNiNQQad4E.jpg","release_date":"2021-12-22","title":"War of the Worlds: Annihilation","video":false,"vote_average":5.9,"vote_count":33},{"adult":false,"backdrop_path":"/3G1Q5xF40HkUBJXxt2DQgQzKTp5.jpg","genre_ids":[16,35,10751,14],"id":568124,"original_language":"en","original_title":"Encanto","overview":"The tale of an extraordinary family, the Madrigals, who live hidden in the mountains of Colombia, in a magical house, in a vibrant town, in a wondrous, charmed place called an Encanto. The magic of the Encanto has blessed every child in the family with a unique gift from super strength to the power to heal—every child except one, Mirabel. But when she discovers that the magic surrounding the Encanto is in danger, Mirabel decides that she, the only ordinary Madrigal, might just be her exceptional family's last hope.","popularity":1582.736,"poster_path":"/4j0PNHkMr5ax3IA8tjtxcmPU3QT.jpg","release_date":"2021-11-24","title":"Encanto","video":false,"vote_average":7.7,"vote_count":6195},{"adult":false,"backdrop_path":"/iDeWAGnmloZ5Oz3bocDp4rSbUXd.jpg","genre_ids":[28,53],"id":823625,"original_language":"en","original_title":"Blacklight","overview":"Travis Block is a shadowy Government agent who specializes in removing operatives whose covers have been exposed. He then has to uncover a deadly conspiracy within his own ranks that reaches the highest echelons of power.","popularity":1633.234,"poster_path":"/bv9dy8mnwftdY2j6gG39gCfSFpV.jpg","release_date":"2022-02-11","title":"Blacklight","video":false,"vote_average":6.1,"vote_count":297},{"adult":false,"backdrop_path":"/ewUqXnwiRLhgmGhuksOdLgh49Ch.jpg","genre_ids":[28,12,35,878],"id":696806,"original_language":"en","original_title":"The Adam Project","overview":"After accidentally crash-landing in 2022, time-traveling fighter pilot Adam Reed teams up with his 12-year-old self on a mission to save the future.","popularity":1367.691,"poster_path":"/wFjboE0aFZNbVOF05fzrka9Fqyx.jpg","release_date":"2022-03-11","title":"The Adam Project","video":false,"vote_average":7,"vote_count":1956},{"adult":false,"backdrop_path":"/33wnBK5NxvuKQv0Cxo3wMv0eR7F.jpg","genre_ids":[27,53],"id":833425,"original_language":"en","original_title":"No Exit","overview":"Stranded at a rest stop in the mountains during a blizzard, a recovering addict discovers a kidnapped child hidden in a car belonging to one of the people inside the building which sets her on a terrifying struggle to identify who among them is the kidnapper.","popularity":1200.072,"poster_path":"/5cnLoWq9o5tuLe1Zq4BTX4LwZ2B.jpg","release_date":"2022-02-25","title":"No Exit","video":false,"vote_average":6.7,"vote_count":396},{"adult":false,"backdrop_path":"/yzH5zvuEzzsHLZnn0jwYoPf0CMT.jpg","genre_ids":[53,28],"id":760926,"original_language":"en","original_title":"Gold","overview":"In the not-too-distant future, two drifters traveling through the desert stumble across the biggest gold nugget ever found and the dream of immense wealth and greed takes hold. They hatch a plan to excavate their bounty, with one man leaving to secure the necessary tools while the other remains with the gold. The man who remains must endure harsh desert elements, ravenous wild dogs, and mysterious intruders, while battling the sinking suspicion that he has been abandoned to his fate.","popularity":1148.918,"poster_path":"/ejXBuNLvK4kZ7YcqeKqUWnCxdJq.jpg","release_date":"2022-03-11","title":"Gold","video":false,"vote_average":6.4,"vote_count":206},{"adult":false,"backdrop_path":"/t7I942V5U1Ggn6OevN75u3sNYH9.jpg","genre_ids":[28,53],"id":760868,"original_language":"sv","original_title":"Svart krabba","overview":"To end an apocalyptic war and save her daughter, a reluctant soldier embarks on a desperate mission to cross a frozen sea carrying a top-secret cargo.","popularity":1052.163,"poster_path":"/mcIYHZYwUbvhvUt8Lb5nENJ7AlX.jpg","release_date":"2022-03-18","title":"Black Crab","video":false,"vote_average":6.2,"vote_count":365},{"adult":false,"backdrop_path":"/qBLEWvJNVsehJkEJqIigPsWyBse.jpg","genre_ids":[16,10751,14,35,12],"id":585083,"original_language":"en","original_title":"Hotel Transylvania: Transformania","overview":"When Van Helsing's mysterious invention, the \"Monsterfication Ray,\" goes haywire, Drac and his monster pals are all transformed into humans, and Johnny becomes a monster. In their new mismatched bodies, Drac and Johnny must team up and race across the globe to find a cure before it's too late, and before they drive each other crazy.","popularity":1051.177,"poster_path":"/teCy1egGQa0y8ULJvlrDHQKnxBL.jpg","release_date":"2022-02-25","title":"Hotel Transylvania: Transformania","video":false,"vote_average":7.1,"vote_count":757},{"adult":false,"backdrop_path":"/7CamWBejQ9JQOO5vAghZfrFpMXY.jpg","genre_ids":[28,53,80],"id":928381,"original_language":"fr","original_title":"Sans répit","overview":"After going to extremes to cover up an accident, a corrupt cop's life spirals out of control when he starts receiving threats from a mysterious witness.","popularity":977.182,"poster_path":"/9MP21x0OPv0R72obd63tMHssmGt.jpg","release_date":"2022-02-25","title":"Restless","video":false,"vote_average":5.9,"vote_count":233},{"adult":false,"backdrop_path":"/i9rEpTqC6aIQOWOc4PDEEAE3hFe.jpg","genre_ids":[10749,878,18],"id":818750,"original_language":"en","original_title":"The In Between","overview":"After surviving a car accident that took the life of her boyfriend, a teenage girl believes he's attempting to reconnect with her from the after world.","popularity":937.009,"poster_path":"/7RcyjraM1cB1Uxy2W9ZWrab4KCw.jpg","release_date":"2022-02-11","title":"The In Between","video":false,"vote_average":7.1,"vote_count":214},{"adult":false,"backdrop_path":"/cugmVwK0N4aAcLibelKN5jWDXSx.jpg","genre_ids":[16,28,14,12],"id":768744,"original_language":"ja","original_title":"僕のヒーローアカデミア THE MOVIE ワールド ヒーローズ ミッション","overview":"A mysterious group called Humarize strongly believes in the Quirk Singularity Doomsday theory which states that when quirks get mixed further in with future generations, that power will bring forth the end of humanity. In order to save everyone, the Pro-Heroes around the world ask UA Academy heroes-in-training to assist them and form a world-classic selected hero team. It is up to the heroes to save the world and the future of heroes in what is the most dangerous crisis to take place yet in My Hero Academia.","popularity":948.281,"poster_path":"/4NUzcKtYPKkfTwKsLjwNt8nRIXV.jpg","release_date":"2021-10-29","title":"My Hero Academia: World Heroes' Mission","video":false,"vote_average":7.2,"vote_count":152},{"adult":false,"backdrop_path":"/vIgyYkXkg6NC2whRbYjBD7eb3Er.jpg","genre_ids":[878,28,12],"id":580489,"original_language":"en","original_title":"Venom: Let There Be Carnage","overview":"After finding a host body in investigative reporter Eddie Brock, the alien symbiote must face a new enemy, Carnage, the alter ego of serial killer Cletus Kasady.","popularity":957.508,"poster_path":"/rjkmN1dniUHVYAtwuV3Tji7FsDO.jpg","release_date":"2021-10-01","title":"Venom: Let There Be Carnage","video":false,"vote_average":7,"vote_count":7058}],"total_pages":11126,"total_results":222505}
--------------------------------------------------------------------------------