diff --git a/.gitignore b/.gitignore index 342bdb8..ab731d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,4 @@ -*.iml -.gradle -/local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml .DS_Store -/build -/captures -.externalNativeBuild -.cxx # Built application files *.apk @@ -38,6 +25,7 @@ build/ # Local configuration file (sdk path, etc) local.properties +gradle.properties # Proguard folder generated by Eclipse proguard/ @@ -103,19 +91,3 @@ lint/tmp/ *.hprof .idea/ - - -*.iml -.gradle -/local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml -.DS_Store -/build -/captures -.externalNativeBuild -.cxx diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ffdef85 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,33 @@ +language: android +dist: trusty +jdk: oraclejdk8 + +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + - $HOME/.android/build-cache + +before_install: + - yes | sdkmanager "platforms;android-29" + - yes | sdkmanager "emulator" "tools" "platform-tools" + - yes | sdkmanager --licenses + - yes | sdkmanager "build-tools;30.0.1" + + +android: + components: + - build-tools-30.0.1 + - android-30 + - sys-img-x86-android-30 + licenses: + - 'android-sdk-preview-license-52d11cd2' + - 'android-sdk-license-.+' + +script: + - "./gradlew spotlessCheck" + - "./gradlew test" + - "./gradlew :app:clean :app:build" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c6ce52 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# MovieBrowser + +[![Build Status](https://travis-ci.com/harmittaa/MovieBrowser.svg?branch=main)](https://travis-ci.com/harmittaa/MovieBrowser) + +Simple browser, displays movies of different categories from the [TMDB](https://www.themoviedb.org/). + + +## Setting up + +Clone and set your TMDB API key in the `gradle.properties`. diff --git a/app/build.gradle b/app/build.gradle index 0d3801a..3d4c156 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,20 +1,18 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' -apply plugin: 'koin' apply plugin: 'kotlin-kapt' apply from: "$project.rootDir/spotless.gradle" - android { - compileSdkVersion 29 + compileSdkVersion 30 buildToolsVersion "30.0.1" defaultConfig { applicationId "com.github.harmittaa.moviebrowser" minSdkVersion 24 - targetSdkVersion 29 + targetSdkVersion 30 versionCode 1 versionName "1.0" @@ -22,11 +20,51 @@ android { } buildTypes { + debug { + try { + minifyEnabled false + buildConfigField "String", "TMDB_URL", "\"https://api.themoviedb.org/3/\"" + buildConfigField "String", "TMDB_KEY", tmdb_api_key + debuggable true + } catch (e) { + // add gradle.properties file with the following property: + // tmdb_api_key = "YOUR_API_KEY" + throw e + } + + } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + buildConfigField "String", "TMDB_URL", "\"https://api.themoviedb.org/3/\"" + buildConfigField "String", "TMDB_KEY", tmdb_api_key } } + + buildFeatures { + dataBinding = true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility 1.8 + targetCompatibility 1.8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + freeCompilerArgs += ["-Xopt-in=org.mylibrary.OptInAnnotation", "-Xopt-in=kotlin.RequiresOptIn"] + } + + configurations.all { + resolutionStrategy.force "org.antlr:antlr4-runtime:4.7.1" + resolutionStrategy.force "org.antlr:antlr4-tool:4.7.1" + } +} + +kapt { + correctErrorTypes = true } dependencies { @@ -55,7 +93,7 @@ dependencies { def glide_version = "4.11.0" implementation "com.github.bumptech.glide:glide:$glide_version" - annotationProcessor "com.github.bumptech.glide:compiler:$glide_version" + kapt "com.github.bumptech.glide:compiler:$glide_version" def timber_version = "4.7.1" implementation "com.jakewharton.timber:timber:$timber_version" @@ -70,13 +108,64 @@ dependencies { // Koin AndroidX Fragment features implementation "org.koin:koin-androidx-fragment:$koin_version" - def room_version ="2.2.5" + def room_version = "2.2.5" implementation "androidx.room:room-runtime:$room_version" // optional - Kotlin Extensions and Coroutines support for Room implementation "androidx.room:room-ktx:$room_version" kapt "androidx.room:room-compiler:$room_version" + def recycler_view_version = "1.2.0-alpha05" + implementation "androidx.recyclerview:recyclerview:$recycler_view_version" + + def retrofit_version = "2.9.0" + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version" + + def moshi_version = "1.9.3" + implementation "com.squareup.moshi:moshi:$moshi_version" + implementation "com.squareup.moshi:moshi-adapters:$moshi_version" + implementation "com.squareup.moshi:moshi-kotlin:$moshi_version" + kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" + + def okhttp_version = "4.8.1" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" + + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" + + def store_version = "4.0.0-alpha07" + implementation "com.dropbox.mobile.store:store4:$store_version" + + def coroutines_version = "1.3.9" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + def progressbar_version = "1.1.0" + implementation "com.github.castorflex.smoothprogressbar:library:$progressbar_version" + + // testing + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + def mockito_version = "3.4.0" + testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "org.mockito:mockito-inline:$mockito_version" + + def mockito_kotlin_version = "2.2.0" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "android.arch.core:core-testing:$arch_version" + + def epoxy_version = "4.0.0-beta6" + implementation "com.airbnb.android:epoxy:$epoxy_version" + implementation "com.airbnb.android:epoxy-databinding:$epoxy_version" + kapt "com.airbnb.android:epoxy-processor:$epoxy_version" + + def viewpager_version = "1.0.0" + implementation "androidx.viewpager2:viewpager2:$viewpager_version" + + def material_version = "1.3.0-alpha02" + implementation "com.google.android.material:material:$material_version" + + def gravity_snap_helper_version = "2.2.1" + implementation "com.github.rubensousa:gravitysnaphelper:$gravity_snap_helper_version" + implementation 'com.wajahatkarim3:roomexplorer:0.0.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcb285a..c0d7eb8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,18 +2,22 @@ + + - + android:theme="@style/AppTheme" + android:fullBackupContent="@xml/backup_descriptor"> + - diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/MainActivity.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/MainActivity.kt index 326ebd9..68e3cf7 100644 --- a/app/src/main/java/com/github/harmittaa/moviebrowser/MainActivity.kt +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/MainActivity.kt @@ -2,10 +2,54 @@ package com.github.harmittaa.moviebrowser import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import com.github.harmittaa.moviebrowser.db.MovieDatabase +import com.github.harmittaa.moviebrowser.domain.GenreLocal +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.java.KoinJavaComponent.inject class MainActivity : AppCompatActivity() { + private val db: MovieDatabase by inject(MovieDatabase::class.java) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + supportActionBar?.hide() setContentView(R.layout.activity_main) + prepopulateDb() } + + private fun prepopulateDb() { + val genresList = mutableListOf() + defaultGenreList.forEach { (id, name) -> + genresList.add(GenreLocal(id, name)) + } + + CoroutineScope(EmptyCoroutineContext).launch(Dispatchers.IO) { + db.genreDao().insertGenres(*genresList.toTypedArray()) + } + } + + private val defaultGenreList = mapOf( + 28 to "Action", + 12 to "Adventure", + 16 to "Animation", + 35 to "Comedy", + 80 to "Crime", + 99 to "Documentary", + 18 to "Drama", + 1075 to "Family", + 14 to "Fantasy", + 36 to "History", + 27 to "Horror", + 10402 to "Music", + 9648 to "Mystery", + 10749 to "Romance", + 878 to "Science Fiction", + 10770 to "TV Movie", + 53 to "Thriller", + 19752 to "War", + 37 to "Western" + ) } diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/application/MovieBrowserApplication.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/application/MovieBrowserApplication.kt index 591d301..457f6b3 100644 --- a/app/src/main/java/com/github/harmittaa/moviebrowser/application/MovieBrowserApplication.kt +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/application/MovieBrowserApplication.kt @@ -1,10 +1,25 @@ package com.github.harmittaa.moviebrowser.application import android.app.Application +import com.github.harmittaa.moviebrowser.browse.di.viewModelModule +import com.github.harmittaa.moviebrowser.data.di.storeRepositoryModule +import com.github.harmittaa.moviebrowser.data.di.useCaseModule +import com.github.harmittaa.moviebrowser.db.di.databaseModule +import com.github.harmittaa.moviebrowser.epoxy.di.epoxyModule +import com.github.harmittaa.moviebrowser.network.networkModule +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import timber.log.Timber +@OptIn( + FlowPreview::class, + ExperimentalCoroutinesApi::class, + ExperimentalTime::class, + ExperimentalStdlibApi::class +) class MovieBrowserApplication : Application() { override fun onCreate() { @@ -12,6 +27,16 @@ class MovieBrowserApplication : Application() { Timber.plant(Timber.DebugTree()) startKoin { androidContext(this@MovieBrowserApplication) + modules( + listOf( + networkModule, + viewModelModule, + storeRepositoryModule, + useCaseModule, + databaseModule, + epoxyModule + ) + ) } } } diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseFragment.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseFragment.kt index 5601d90..42867a8 100644 --- a/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseFragment.kt +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseFragment.kt @@ -4,22 +4,65 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment -import com.github.harmittaa.moviebrowser.R +import com.github.harmittaa.moviebrowser.databinding.FragmentBrowseBinding +import com.github.harmittaa.moviebrowser.epoxy.GenresController +import com.github.harmittaa.moviebrowser.epoxy.MoviesController +import com.github.harmittaa.moviebrowser.network.Resource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +@ExperimentalCoroutinesApi class BrowseFragment : Fragment() { - val viewModel: BrowseViewModel by viewModel() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } + private val viewModel: BrowseViewModel by viewModel() + private val genresController: GenresController by inject() + private val moviesController: MoviesController by inject() + private lateinit var binding: FragmentBrowseBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(R.layout.fragment_browse, container, false) + binding = FragmentBrowseBinding.inflate(inflater, container, false) + genresController.clickListener = viewModel + binding.apply { + viewModel = this@BrowseFragment.viewModel + lifecycleOwner = this@BrowseFragment.viewLifecycleOwner + genresRecycler.setController(genresController) + moviesRecycler.setController(moviesController) + } + + bindViewModel() + binding.clearFilters.setOnClickListener { + viewModel.clearFilters() + } + return binding.root + } + + private fun bindViewModel() { + viewModel.genres.observe(viewLifecycleOwner, { genres -> + genresController.genres = genres + }) + + viewModel.selectedGenres.observe(viewLifecycleOwner, { genres -> + genresController.selectedGenres = genres + }) + + viewModel.moviesOfCategory.observe(viewLifecycleOwner, { movies -> + when (movies) { + is Resource.Success -> moviesController.movies = movies.data + is Resource.Error -> { + moviesController.movies = emptyList() + Toast.makeText( + requireContext(), + "Error! ${movies.message}", + Toast.LENGTH_SHORT + ).show() + } + } + }) } } diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseViewModel.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseViewModel.kt index 5b575be..a14962f 100644 --- a/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseViewModel.kt +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/browse/BrowseViewModel.kt @@ -1,5 +1,70 @@ package com.github.harmittaa.moviebrowser.browse +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope +import com.github.harmittaa.moviebrowser.data.uc.GenreUseCase +import com.github.harmittaa.moviebrowser.data.uc.MovieUseCase +import com.github.harmittaa.moviebrowser.domain.Genre +import com.github.harmittaa.moviebrowser.domain.Movie +import com.github.harmittaa.moviebrowser.network.Resource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview -class BrowseViewModel : ViewModel() +interface GenreClickListener { + fun onGenreClicked(genre: Genre) +} + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +class BrowseViewModel( + genreUseCase: GenreUseCase, + private val movieUseCase: MovieUseCase +) : ViewModel(), GenreClickListener { + + private val _genres: LiveData> = + genreUseCase.getGenres().asLiveData(viewModelScope.coroutineContext) + val genres: LiveData> = _genres + + private val shouldFetchSites: MutableLiveData = MutableLiveData() + + private val _selectedGenres: MutableLiveData> = MutableLiveData() + val selectedGenres: LiveData> = _selectedGenres + + private val genreInputFilter = MediatorLiveData>() + + val moviesOfCategory: LiveData>> = genreInputFilter.switchMap { genres -> + movieUseCase.getMovies(genres).asLiveData(viewModelScope.coroutineContext) + } + + val showLoading: LiveData = moviesOfCategory.map { + it == Resource.Loading + } + + init { + genreInputFilter.addSource(shouldFetchSites) { + genreInputFilter.value = emptyList() + } + shouldFetchSites.value = Unit + genreInputFilter.addSource(selectedGenres) { + genreInputFilter.value = it.toList() + } + } + + override fun onGenreClicked(genre: Genre) { + val list = selectedGenres.value ?: mutableSetOf() + val addResult = list.add(genre) + if (!addResult) { + list.remove(genre) + } + _selectedGenres.value = list + } + + fun clearFilters() { + _selectedGenres.value = mutableSetOf() + } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/browse/di/BrowseModules.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/browse/di/BrowseModules.kt new file mode 100644 index 0000000..5de85c3 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/browse/di/BrowseModules.kt @@ -0,0 +1,10 @@ +package com.github.harmittaa.moviebrowser.browse.di + +import com.github.harmittaa.moviebrowser.browse.BrowseViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.koin.dsl.module + +@ExperimentalCoroutinesApi +val viewModelModule = module { + factory { BrowseViewModel(genreUseCase = get(), movieUseCase = get()) } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/data/MovieApi.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/data/MovieApi.kt new file mode 100644 index 0000000..f63e70a --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/data/MovieApi.kt @@ -0,0 +1,24 @@ +package com.github.harmittaa.moviebrowser.data + +import com.github.harmittaa.moviebrowser.domain.GenreDto +import com.github.harmittaa.moviebrowser.domain.MovieDto +import retrofit2.http.GET +import retrofit2.http.Query + +data class GenresResponse(val genres: List) +data class DiscoverEnvelope( + val results: List +) + +interface MovieApi { + + @GET("genre/movie/list") + suspend fun getGenres(): GenresResponse + + @GET("movie/top_rated") + suspend fun getMoviesForGenre( + @Query(encoded = true, value = "with_genres") genre: String, + @Query("vote_count.gte") count: Int = 1000, + @Query("sort_by") arg: String = "vote_average.desc" + ): DiscoverEnvelope +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/data/di/DataModules.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/data/di/DataModules.kt new file mode 100644 index 0000000..88b8a8f --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/data/di/DataModules.kt @@ -0,0 +1,21 @@ +package com.github.harmittaa.moviebrowser.data.di + +import com.github.harmittaa.moviebrowser.data.uc.GenreRepository +import com.github.harmittaa.moviebrowser.data.uc.GenreUseCase +import com.github.harmittaa.moviebrowser.data.uc.MovieUseCase +import com.github.harmittaa.moviebrowser.data.uc.Repository +import com.github.harmittaa.moviebrowser.db.di.databaseModule +import org.koin.core.context.loadKoinModules +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val useCaseModule = module { + factory { GenreUseCase(repository = get()) } + factory { MovieUseCase(repository = get(named("movieRepo"))) } + single { GenreRepository(database = get()) } +} + +val storeRepositoryModule = module { + loadKoinModules(databaseModule) + single(named("movieRepo")) { Repository.provideMovieRepository(api = get(), db = get()) } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/GenreRepository.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/GenreRepository.kt new file mode 100644 index 0000000..58b77d6 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/GenreRepository.kt @@ -0,0 +1,11 @@ +package com.github.harmittaa.moviebrowser.data.uc + +import com.github.harmittaa.moviebrowser.db.MovieDatabase +import com.github.harmittaa.moviebrowser.domain.Genre +import kotlinx.coroutines.flow.Flow + +class GenreRepository(val database: MovieDatabase) { + fun getGenres(): Flow> { + return database.genreDao().getGenres() + } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/GenreUseCase.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/GenreUseCase.kt new file mode 100644 index 0000000..f961fb2 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/GenreUseCase.kt @@ -0,0 +1,5 @@ +package com.github.harmittaa.moviebrowser.data.uc + +class GenreUseCase(private val repository: GenreRepository) { + fun getGenres() = repository.getGenres() +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/MovieUseCase.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/MovieUseCase.kt new file mode 100644 index 0000000..c978110 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/MovieUseCase.kt @@ -0,0 +1,56 @@ +package com.github.harmittaa.moviebrowser.data.uc + +import com.dropbox.android.external.store4.Store +import com.dropbox.android.external.store4.StoreRequest +import com.dropbox.android.external.store4.StoreResponse +import com.github.harmittaa.moviebrowser.domain.Genre +import com.github.harmittaa.moviebrowser.domain.Movie +import com.github.harmittaa.moviebrowser.network.Resource +import java.lang.Exception +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.transform +import timber.log.Timber + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +class MovieUseCase( + private val repository: Store, List>, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO +) { + + fun getMovies(selectedGenres: List) = flow { + getRepoFlow(selectedGenres).collect { + emit(it) + } + }.flowOn(coroutineDispatcher) + + private fun getRepoFlow(selectedGenres: List): Flow>> { + try { + return repository.stream( + StoreRequest.cached( + selectedGenres, refresh = true + ) + ).transform { response -> + when (response) { + is StoreResponse.Loading -> emit(Resource.Loading) + is StoreResponse.Error -> emit( + Resource.Error( + response.errorMessageOrNull() ?: "Unknown error" + ) + ) + is StoreResponse.Data -> emit(Resource.Success(response.value)) + } + } + } catch (e: Exception) { + Timber.d("Exception thrown from repo $e") + return flowOf(Resource.Error(e.message ?: "Unknown error")) + } + } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/Repository.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/Repository.kt new file mode 100644 index 0000000..7884fb0 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/data/uc/Repository.kt @@ -0,0 +1,38 @@ +package com.github.harmittaa.moviebrowser.data.uc + +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.SourceOfTruth +import com.dropbox.android.external.store4.Store +import com.dropbox.android.external.store4.StoreBuilder +import com.github.harmittaa.moviebrowser.data.MovieApi +import com.github.harmittaa.moviebrowser.db.MovieDatabase +import com.github.harmittaa.moviebrowser.domain.Genre +import com.github.harmittaa.moviebrowser.domain.Movie +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview + +@OptIn( + FlowPreview::class, + ExperimentalCoroutinesApi::class, + ExperimentalTime::class, + ExperimentalStdlibApi::class +) +object Repository { + fun provideMovieRepository(api: MovieApi, db: MovieDatabase): Store, List> { + return StoreBuilder.from( + fetcher = Fetcher.of { + genres: List -> + val genresList = genres.map { it.genreId }.joinToString(",") + api.getMoviesForGenre(genresList).results + }, + sourceOfTruth = SourceOfTruth.of( + reader = db.genreDao()::getMoviesOfGenres, + writer = db.genreDao()::insertGenresMovies, + delete = db.genreDao()::deleteMoviesOfGenre, + deleteAll = db.genreDao()::deleteAll + ) + ) + .build() + } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/db/Converters.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/db/Converters.kt new file mode 100644 index 0000000..520ae61 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/db/Converters.kt @@ -0,0 +1,22 @@ +package com.github.harmittaa.moviebrowser.db + +import androidx.room.TypeConverter +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import org.koin.java.KoinJavaComponent + +class Converters { + private val moshi: Moshi by KoinJavaComponent.inject(Moshi::class.java) + private val listConverter: JsonAdapter> + + init { + listConverter = moshi.adapter(Types.newParameterizedType(List::class.java, Integer::class.java)) + } + + @TypeConverter + fun fromList(value: String) = listConverter.fromJson(value) + + @TypeConverter + fun toList(value: List): String = listConverter.toJson(value) +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/db/GenreDao.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/db/GenreDao.kt new file mode 100644 index 0000000..3c3ce26 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/db/GenreDao.kt @@ -0,0 +1,92 @@ +package com.github.harmittaa.moviebrowser.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.github.harmittaa.moviebrowser.domain.Genre +import com.github.harmittaa.moviebrowser.domain.GenreLocal +import com.github.harmittaa.moviebrowser.domain.GenreMovieCrossRef +import com.github.harmittaa.moviebrowser.domain.GenreWithMovies +import com.github.harmittaa.moviebrowser.domain.Movie +import com.github.harmittaa.moviebrowser.domain.MovieDto +import com.github.harmittaa.moviebrowser.domain.MovieLocal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@Dao +abstract class GenreDao { + + fun getMoviesOfGenres(genres: List): Flow?> = + if (genres.isEmpty()) { + getAllMovies().map { movieList -> + movieList.sortedByDescending { it.rating } + } + } else { + val genreIds = genres.map { it.genreId } + queryMoviesOfGenres(genreIds).map { genresWithMovies -> + val movies = mutableSetOf() + + genresWithMovies?.forEach { movies.addAll(it?.movies ?: emptyList()) } + val filteredMovies = movies.filter { it.genreIds.containsAll(genreIds) } + .sortedByDescending { it.rating } + + if (movies.isEmpty()) { + emptyList() + } else { + filteredMovies + } + } + } + + @Transaction + open suspend fun insertGenresMovies(genre: List, movies: List) { + + val crossRefs = mutableListOf() + val toLocalMapped = movies.map { + it.toLocal() + } + + toLocalMapped.forEach { movie -> + movie.genreIds.forEach { genreId -> + crossRefs.add(GenreMovieCrossRef(genreId, movie.movieId)) + } + } + + insertGenreMovieCrossRefs(*crossRefs.toTypedArray()) + insertMovies(*toLocalMapped.toTypedArray()) + } + + suspend fun deleteMoviesOfGenre(genres: List) { + deleteMoviesOfGenreIds(genres.map { it.genreId }) + } + + @Transaction + @Query("SELECT * FROM genre WHERE genreId IN (:genreId)") + abstract fun queryMoviesOfGenres(genreId: List): Flow?> + + @Query("SELECT * FROM movie") + abstract fun getAllMovies(): Flow> + + @Query("DELETE FROM movie") + abstract suspend fun deleteAll() + + @Query("DELETE FROM movie WHERE genreIds IN (:genreId)") + protected abstract suspend fun deleteMoviesOfGenreIds(genreId: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertMovies(vararg movies: MovieLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertCrossRef(crossRef: GenreMovieCrossRef) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertGenreMovieCrossRefs(vararg lists: GenreMovieCrossRef) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertGenres(vararg genres: GenreLocal) + + @Query("SELECT * FROM genre") + abstract fun getGenres(): Flow> +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/db/MovieDatabase.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/db/MovieDatabase.kt new file mode 100644 index 0000000..8ea9686 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/db/MovieDatabase.kt @@ -0,0 +1,21 @@ +package com.github.harmittaa.moviebrowser.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.github.harmittaa.moviebrowser.domain.GenreLocal +import com.github.harmittaa.moviebrowser.domain.GenreMovieCrossRef +import com.github.harmittaa.moviebrowser.domain.MovieLocal + +private const val DB_VERSION = 1 +const val DB_NAME = "MOVIE_APP_DB" + +@Database( + entities = [GenreLocal::class, MovieLocal::class, GenreMovieCrossRef::class], + version = DB_VERSION, + exportSchema = false +) +@TypeConverters(Converters::class) +abstract class MovieDatabase : RoomDatabase() { + abstract fun genreDao(): GenreDao +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/db/di/DatabaseModule.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/db/di/DatabaseModule.kt new file mode 100644 index 0000000..3083b1b --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/db/di/DatabaseModule.kt @@ -0,0 +1,17 @@ +package com.github.harmittaa.moviebrowser.db.di + +import android.content.Context +import androidx.room.Room +import com.github.harmittaa.moviebrowser.db.DB_NAME +import com.github.harmittaa.moviebrowser.db.MovieDatabase +import org.koin.dsl.module + +val databaseModule = module { + single { provideDatabase(context = get()) } + single { get().genreDao() } +} + +fun provideDatabase(context: Context) = Room.databaseBuilder( + context, + MovieDatabase::class.java, DB_NAME +).build() diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/domain/Genre.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/domain/Genre.kt new file mode 100644 index 0000000..a6819b7 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/domain/Genre.kt @@ -0,0 +1,48 @@ +package com.github.harmittaa.moviebrowser.domain + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Junction +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +abstract class Genre { + abstract val genreId: Int + abstract val name: String +} + +@Entity(primaryKeys = ["genreId", "movieId"]) +data class GenreMovieCrossRef( + @ColumnInfo(index = true) + val genreId: Int, + @ColumnInfo(index = true) + val movieId: Int +) + +data class GenreWithMovies( + @Embedded val genre: GenreLocal, + @Relation( + parentColumn = "genreId", + entityColumn = "movieId", + associateBy = Junction(GenreMovieCrossRef::class) + ) + val movies: List +) + +@JsonClass(generateAdapter = true) +data class GenreDto( + @Json(name = "id") val genreId: Int, + val name: String +) + +@Entity(tableName = "genre") +data class GenreLocal( + @PrimaryKey + @ColumnInfo(name = "genreId") + override val genreId: Int, + @ColumnInfo(name = "name") + override val name: String +) : Genre() diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/domain/Movie.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/domain/Movie.kt new file mode 100644 index 0000000..773fd5f --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/domain/Movie.kt @@ -0,0 +1,53 @@ +package com.github.harmittaa.moviebrowser.domain + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +abstract class Movie { + abstract val movieId: Int + abstract val title: String + abstract val overview: String + abstract val backdropUrl: String + abstract val posterUrl: String + abstract val rating: String + abstract val genreIds: List + fun getBackdropPath() = "https://image.tmdb.org/t/p/w780/$backdropUrl" + fun getPosterPath() = "https://image.tmdb.org/t/p/w780/$posterUrl" +} + +@JsonClass(generateAdapter = true) +data class MovieDto( + @Json(name = "id") override val movieId: Int, + override val title: String, + override val overview: String, + @Json(name = "backdrop_path") override val backdropUrl: String, + @Json(name = "poster_path") override val posterUrl: String, + @Json(name = "vote_average") override val rating: String, + @Json(name = "genre_ids") override val genreIds: List +) : Movie() { + fun toLocal() = MovieLocal( + movieId = movieId, + title = title, + overview = overview, + backdropUrl = backdropUrl, + posterUrl = posterUrl, + rating = rating, + genreIds = genreIds + ) +} + +@Entity(tableName = "movie") +data class MovieLocal( + @ColumnInfo(index = true) + @PrimaryKey + override val movieId: Int, + override val title: String, + override val overview: String, + override val backdropUrl: String, + override val posterUrl: String, + override val rating: String, + override val genreIds: List +) : Movie() diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/EpoxyDataBindingPatterns.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/EpoxyDataBindingPatterns.kt new file mode 100644 index 0000000..cadd47d --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/EpoxyDataBindingPatterns.kt @@ -0,0 +1,7 @@ +package com.github.harmittaa.moviebrowser.epoxy + +import com.airbnb.epoxy.EpoxyDataBindingPattern +import com.github.harmittaa.moviebrowser.R + +@EpoxyDataBindingPattern(rClass = R::class, layoutPrefix = "item") +object EpoxyDataBindingPatterns diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/GenresController.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/GenresController.kt new file mode 100644 index 0000000..e5636c8 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/GenresController.kt @@ -0,0 +1,48 @@ +package com.github.harmittaa.moviebrowser.epoxy + +import com.airbnb.epoxy.AsyncEpoxyController +import com.airbnb.epoxy.Carousel +import com.airbnb.epoxy.carousel +import com.github.harmittaa.moviebrowser.GenreBrowserBindingModel_ +import com.github.harmittaa.moviebrowser.browse.GenreClickListener +import com.github.harmittaa.moviebrowser.domain.Genre + +class GenresController : AsyncEpoxyController() { + lateinit var clickListener: GenreClickListener + + var selectedGenres = mutableSetOf() + set(value) { + field = value + requestModelBuild() + } + + var genres: List = emptyList() + set(value) { + field = value + requestModelBuild() + } + + override fun buildModels() { + Carousel.setDefaultGlobalSnapHelperFactory(null) + + val genreModels = genres.map { + GenreBrowserBindingModel_().run { + id(it.genreId) + genre(it) + clickListener(clickListener) + isSelected(selectedGenres.contains(it)) + + this.clickListener(object : GenreClickListener { + override fun onGenreClicked(genre: Genre) { + this@GenresController.clickListener.onGenreClicked(genre) + } + }) + } + } + + carousel { + id("genres_top_carousel") + models(genreModels) + } + } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/MoviesController.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/MoviesController.kt new file mode 100644 index 0000000..19b3a5b --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/MoviesController.kt @@ -0,0 +1,43 @@ +package com.github.harmittaa.moviebrowser.epoxy + +import com.airbnb.epoxy.AsyncEpoxyController +import com.airbnb.epoxy.Carousel +import com.airbnb.epoxy.carousel +import com.github.harmittaa.moviebrowser.MovieCardVerticalBindingModel_ +import com.github.harmittaa.moviebrowser.domain.Movie +import com.github.harmittaa.moviebrowser.movieCardHorizontal + +private const val TOP_ITEM_COUNT = 5 + +class MoviesController : AsyncEpoxyController() { + + var movies: List = emptyList() + set(value) { + field = value + requestModelBuild() + } + + override fun buildModels() { + val topMovieModels = movies.take(TOP_ITEM_COUNT).map { movie -> + MovieCardVerticalBindingModel_().run { + id("top_movies_${movie.movieId}") + movie(movie) + } + } + + carousel { + id("top_movies") + models(topMovieModels) + padding(Carousel.Padding.dp(16, 4, 16, 16, 16)) + } + + if (movies.count() > TOP_ITEM_COUNT) { + movies.takeLast(movies.count() - TOP_ITEM_COUNT).forEach { movie -> + movieCardHorizontal { + id(movie.movieId) + movie(movie) + } + } + } + } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/di/EpoxyModule.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/di/EpoxyModule.kt new file mode 100644 index 0000000..f21e31e --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/epoxy/di/EpoxyModule.kt @@ -0,0 +1,10 @@ +package com.github.harmittaa.moviebrowser.epoxy.di + +import com.github.harmittaa.moviebrowser.epoxy.GenresController +import com.github.harmittaa.moviebrowser.epoxy.MoviesController +import org.koin.dsl.module + +val epoxyModule = module { + factory { GenresController() } + factory { MoviesController() } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/network/Resource.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/network/Resource.kt new file mode 100644 index 0000000..adbddeb --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/network/Resource.kt @@ -0,0 +1,30 @@ +package com.github.harmittaa.moviebrowser.network + +// from +// https://github.com/dropbox/Store/blob/main/app/src/main/java/com/dropbox/android/sample/utils/Lce.kt +sealed class Resource { + + open val data: T? = null + + abstract fun map(f: (T) -> R): Resource + + inline fun doOnData(f: (T) -> Unit) { + if (this is Success) { + f(data) + } + } + + data class Success(override val data: T) : Resource() { + override fun map(f: (T) -> R): Resource = Success(f(data)) + } + + data class Error(val message: String) : Resource() { + constructor(t: Throwable) : this(t.message ?: "") + + override fun map(f: (Nothing) -> R): Resource = this + } + + object Loading : Resource() { + override fun map(f: (Nothing) -> R): Resource = this + } +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/network/Retrofit.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/network/Retrofit.kt new file mode 100644 index 0000000..cc8bf26 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/network/Retrofit.kt @@ -0,0 +1,69 @@ +package com.github.harmittaa.moviebrowser.network + +import com.github.harmittaa.moviebrowser.BuildConfig +import com.github.harmittaa.moviebrowser.data.MovieApi +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +val networkModule = module { + single { provideLoggingInterceptor() } + factory { AuthInterceptor() } + single { provideOkHttpClient(authInterceptor = get(), loggingInterceptor = get()) } + + single { provideMoshi() } + factory { provideMoshiConverterFactory(moshi = get()) } + + single { provideRetrofit(okHttpClient = get(), moshiConverterFactory = get()) } + + single { provideMovieApi(retrofit = get()) } +} + +fun provideLoggingInterceptor(): HttpLoggingInterceptor { + val logger = HttpLoggingInterceptor() + logger.level = HttpLoggingInterceptor.Level.BODY + return logger +} + +fun provideRetrofit( + okHttpClient: OkHttpClient, + moshiConverterFactory: MoshiConverterFactory +): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.TMDB_URL) + .client(okHttpClient) + .addConverterFactory(moshiConverterFactory) + .build() +} + +private fun provideMoshi(): Moshi { + return Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() +} + +private fun provideMoshiConverterFactory(moshi: Moshi): MoshiConverterFactory { + return MoshiConverterFactory.create(moshi) +} + +private fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + loggingInterceptor: HttpLoggingInterceptor +): OkHttpClient { + return OkHttpClient().newBuilder().addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor).build() +} + +private fun provideMovieApi(retrofit: Retrofit): MovieApi = retrofit.create(MovieApi::class.java) + +private class AuthInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain) = chain.proceed( + chain.request().newBuilder().addHeader("Authorization", "Bearer ${BuildConfig.TMDB_KEY}") + .build() + ) +} diff --git a/app/src/main/java/com/github/harmittaa/moviebrowser/util/BindingAdapters.kt b/app/src/main/java/com/github/harmittaa/moviebrowser/util/BindingAdapters.kt new file mode 100644 index 0000000..84fbad9 --- /dev/null +++ b/app/src/main/java/com/github/harmittaa/moviebrowser/util/BindingAdapters.kt @@ -0,0 +1,42 @@ +package com.github.harmittaa.moviebrowser.util + +import android.view.View +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import com.github.harmittaa.moviebrowser.R +import com.github.harmittaa.moviebrowser.domain.Movie +import timber.log.Timber + +object BindingAdapters { + + @JvmStatic + @BindingAdapter("imageUrl") + fun loadImage(view: ImageView, movie: Movie?) { + if (movie == null) return + Glide.with(view) + .load(movie.getBackdropPath()) + .placeholder(R.drawable.placeholder_drawable) + .into(view) + } + + @JvmStatic + @BindingAdapter("visibleGone") + fun showHide(view: View, show: Boolean) { + Timber.d("visibleGone($view, $show)") + view.visibility = if (show) View.VISIBLE else View.GONE + } + + @JvmStatic + @BindingAdapter("visibleInvisible") + fun visibleInvisible(view: View, show: Boolean) { + Timber.d("visibleGone($view, $show)") + view.visibility = if (show) View.VISIBLE else View.INVISIBLE + } + + @JvmStatic + @BindingAdapter("setSelected") + fun setSelected(view: View, selected: Boolean) { + view.isSelected = selected + } +} diff --git a/app/src/main/res/color/chosen_genre_color.xml b/app/src/main/res/color/chosen_genre_color.xml new file mode 100644 index 0000000..0ca68a6 --- /dev/null +++ b/app/src/main/res/color/chosen_genre_color.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/color/genre_background_selector.xml b/app/src/main/res/color/genre_background_selector.xml new file mode 100644 index 0000000..f913925 --- /dev/null +++ b/app/src/main/res/color/genre_background_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/genre_rounded_corners.xml b/app/src/main/res/drawable/genre_rounded_corners.xml new file mode 100644 index 0000000..b88644a --- /dev/null +++ b/app/src/main/res/drawable/genre_rounded_corners.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..db4bf0a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to app/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/app/src/main/res/drawable/ic_launcher_round.xml b/app/src/main/res/drawable/ic_launcher_round.xml new file mode 100644 index 0000000..db4bf0a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_round.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/placeholder_drawable.xml b/app/src/main/res/drawable/placeholder_drawable.xml new file mode 100644 index 0000000..ba4e598 --- /dev/null +++ b/app/src/main/res/drawable/placeholder_drawable.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/shadow_gradient.xml b/app/src/main/res/drawable/shadow_gradient.xml new file mode 100644 index 0000000..32557f1 --- /dev/null +++ b/app/src/main/res/drawable/shadow_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/shadow_gradient_reverse.xml b/app/src/main/res/drawable/shadow_gradient_reverse.xml new file mode 100644 index 0000000..af19ca2 --- /dev/null +++ b/app/src/main/res/drawable/shadow_gradient_reverse.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/layout/fragment_browse.xml b/app/src/main/res/layout/fragment_browse.xml index e3a95fe..7f110cc 100644 --- a/app/src/main/res/layout/fragment_browse.xml +++ b/app/src/main/res/layout/fragment_browse.xml @@ -1,14 +1,83 @@ - - - - + + + + + + + + android:background="@color/background" + tools:context=".browse.BrowseFragment"> + + + + + + + + + + + - + + diff --git a/app/src/main/res/layout/item_genre_browser.xml b/app/src/main/res/layout/item_genre_browser.xml new file mode 100644 index 0000000..c962eeb --- /dev/null +++ b/app/src/main/res/layout/item_genre_browser.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_movie_card_horizontal.xml b/app/src/main/res/layout/item_movie_card_horizontal.xml new file mode 100644 index 0000000..bd45d6c --- /dev/null +++ b/app/src/main/res/layout/item_movie_card_horizontal.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_movie_card_vertical.xml b/app/src/main/res/layout/item_movie_card_vertical.xml new file mode 100644 index 0000000..c8f9077 --- /dev/null +++ b/app/src/main/res/layout/item_movie_card_vertical.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6b78462..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 6b78462..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index a571e60..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 61da551..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index c41dd28..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index db5080a..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 6dba46d..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index da31a87..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 15ac681..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index b216f2d..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index f25a419..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index e96783c..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 030098f..87ab8aa 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,13 @@ #6200EE - #3700B3 + #121212 #03DAC5 + #FFFFFF + + #F4F4F4 + #888888 + #121212 + #2E2E2E + #323232 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..3be7ab5 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,9 @@ + + + 4dp + 8dp + 16dp + 24dp + 64dp + 30dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb044ae..03c11ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ MovieBrowser - Hello blank fragment + movie poster + Looks like there were no items! Click to clear filters diff --git a/app/src/main/res/xml/backup_descriptor.xml b/app/src/main/res/xml/backup_descriptor.xml new file mode 100644 index 0000000..6fd6103 --- /dev/null +++ b/app/src/main/res/xml/backup_descriptor.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/com/github/harmittaa/moviebrowser/ExampleUnitTest.kt b/app/src/test/java/com/github/harmittaa/moviebrowser/ExampleUnitTest.kt deleted file mode 100644 index f4737f4..0000000 --- a/app/src/test/java/com/github/harmittaa/moviebrowser/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.harmittaa.moviebrowser - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/java/com/github/harmittaa/moviebrowser/browse/BrowseViewModelTest.kt b/app/src/test/java/com/github/harmittaa/moviebrowser/browse/BrowseViewModelTest.kt new file mode 100644 index 0000000..0ecf516 --- /dev/null +++ b/app/src/test/java/com/github/harmittaa/moviebrowser/browse/BrowseViewModelTest.kt @@ -0,0 +1,177 @@ +package com.github.harmittaa.moviebrowser.browse + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import com.github.harmittaa.moviebrowser.data.uc.GenreUseCase +import com.github.harmittaa.moviebrowser.data.uc.MovieUseCase +import com.github.harmittaa.moviebrowser.domain.Genre +import com.github.harmittaa.moviebrowser.domain.GenreLocal +import com.github.harmittaa.moviebrowser.domain.Movie +import com.github.harmittaa.moviebrowser.network.Resource +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.timeout +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@ExperimentalCoroutinesApi +@ObsoleteCoroutinesApi +@RunWith(JUnit4::class) +class BrowseViewModelTest { + + @Rule + @JvmField + val instantExecutorRule = InstantTaskExecutorRule() + + @ObsoleteCoroutinesApi + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + private val defaultTimeout = 5_00L + private val errorString = "Error" + private val movie: Movie = mock() + private val genre: Genre = GenreLocal(123, "genre name") + private val genres = listOf(genre) + private val movies = listOf(movie) + private val movieResource = Resource.Success(movies) + private val errorResource = Resource.Error(errorString) + private val loadingResource = Resource.Loading + private var genreUseCase: GenreUseCase = mock() + private var movieUseCase: MovieUseCase = mock() + private lateinit var viewModel: BrowseViewModel + + @Before + fun setUp() { + Dispatchers.setMain(mainThreadSurrogate) + whenever(genreUseCase.getGenres()).thenReturn(flowOf(genres)) + viewModel = BrowseViewModel(genreUseCase, movieUseCase) + } + + @Test + fun `test clearFilters - when filters are cleared then selected genres is empty`() { + // given + val observer: Observer> = mock() + viewModel.selectedGenres.observeForever(observer) + + // when + viewModel.clearFilters() + + // then + verify(observer, timeout(defaultTimeout)).onChanged(mutableSetOf()) + } + + @Test + fun `test onGenreClicked - when genre is not yet selected, then it is added to list`() { + // given + val observer: Observer> = mock() + viewModel.selectedGenres.observeForever(observer) + + // when + viewModel.onGenreClicked(genre) + + // then + verify(observer, timeout(defaultTimeout)).onChanged(mutableSetOf(genre)) + } + + @Test + fun `test onGenreClicked - when genre is already selected, then it is removed from list`() { + // given + val observer: Observer> = mock() + viewModel.selectedGenres.observeForever(observer) + + // when + viewModel.onGenreClicked(genre) + verify(observer).onChanged(mutableSetOf(genre)) + viewModel.onGenreClicked(genre) + + // then + verify(observer, timeout(defaultTimeout).times(2)).onChanged(mutableSetOf()) + } + + @Test + fun `test moviesOfCategory - when usecase emits loading is selected then loading is emitted`() { + // given + whenever(movieUseCase.getMovies(any())).thenReturn(flowOf(loadingResource)) + val observer: Observer>> = mock() + + // when + viewModel.moviesOfCategory.observeForever(observer) + verify(observer, timeout(defaultTimeout)).onChanged(loadingResource) + viewModel.onGenreClicked(genre) + + // then + verify(observer, timeout(defaultTimeout).times(2)).onChanged(loadingResource) + } + + @Test + fun `test moviesOfCategory - when UseCase emits data is selected then data is emitted`() { + // given + whenever(movieUseCase.getMovies(any())).thenReturn(flowOf(movieResource)) + val observer: Observer>> = mock() + + // when + viewModel.moviesOfCategory.observeForever(observer) + verify(observer, timeout(defaultTimeout)).onChanged(movieResource) + viewModel.onGenreClicked(genre) + + // then + verify(observer, timeout(defaultTimeout).times(2)).onChanged(movieResource) + } + + @Test + fun `test moviesOfCategory - when UseCase emits error is selected then error is emitted`() { + // given + whenever(movieUseCase.getMovies(any())).thenReturn(flowOf(errorResource)) + val observer: Observer>> = mock() + + // when + viewModel.moviesOfCategory.observeForever(observer) + verify(observer, timeout(defaultTimeout)).onChanged(errorResource) + viewModel.onGenreClicked(genre) + + // then + verify(observer, timeout(defaultTimeout).times(2)).onChanged(errorResource) + } + + @Test + fun `test moviesOfCategory - when UseCase emits all states is selected then states are emitted`() { + // given + whenever(movieUseCase.getMovies(any())).thenReturn(flowOf(loadingResource)) + val observer: Observer>> = mock() + + // when & then + // loading + viewModel.moviesOfCategory.observeForever(observer) + verify(observer, timeout(defaultTimeout)).onChanged(loadingResource) + viewModel.onGenreClicked(genre) + verify(observer, timeout(defaultTimeout).times(2)).onChanged(loadingResource) + + // success + whenever(movieUseCase.getMovies(any())).thenReturn(flowOf(movieResource)) + viewModel.onGenreClicked(genre) + verify(observer, timeout(defaultTimeout)).onChanged(movieResource) + + // error + whenever(movieUseCase.getMovies(any())).thenReturn(flowOf(errorResource)) + viewModel.onGenreClicked(genre) + verify(observer, timeout(defaultTimeout)).onChanged(errorResource) + } + + @After + fun tearDown() { + Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher + mainThreadSurrogate.close() + } +} diff --git a/app/src/test/java/com/github/harmittaa/moviebrowser/data/uc/GenreUseCaseTest.kt b/app/src/test/java/com/github/harmittaa/moviebrowser/data/uc/GenreUseCaseTest.kt new file mode 100644 index 0000000..ab5adec --- /dev/null +++ b/app/src/test/java/com/github/harmittaa/moviebrowser/data/uc/GenreUseCaseTest.kt @@ -0,0 +1,62 @@ +package com.github.harmittaa.moviebrowser.data.uc + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.github.harmittaa.moviebrowser.domain.Genre +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@ExperimentalCoroutinesApi +@ObsoleteCoroutinesApi +@RunWith(JUnit4::class) +class GenreUseCaseTest { + @Rule + @JvmField + val instantExecutorRule = InstantTaskExecutorRule() + + @ObsoleteCoroutinesApi + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + private lateinit var genreUseCase: GenreUseCase + private val repository: GenreRepository = mock() + + @Before + fun setUp() { + Dispatchers.setMain(mainThreadSurrogate) + genreUseCase = GenreUseCase(repository) + } + + @Test + fun `test getGenres - when genres are called then repository response is returned`() = runBlocking { + // given + val genre: Genre = mock() + whenever(repository.getGenres()).thenReturn(flowOf(listOf(genre))) + + // when + val repoList = genreUseCase.getGenres().toList() + + // then + assertSame(genre, repoList.first().first()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher + mainThreadSurrogate.close() + } +} diff --git a/app/src/test/java/com/github/harmittaa/moviebrowser/data/uc/MovieUseCaseTest.kt b/app/src/test/java/com/github/harmittaa/moviebrowser/data/uc/MovieUseCaseTest.kt new file mode 100644 index 0000000..6558ab1 --- /dev/null +++ b/app/src/test/java/com/github/harmittaa/moviebrowser/data/uc/MovieUseCaseTest.kt @@ -0,0 +1,82 @@ +package com.github.harmittaa.moviebrowser.data.uc + +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.SourceOfTruth +import com.dropbox.android.external.store4.Store +import com.dropbox.android.external.store4.StoreBuilder +import com.github.harmittaa.moviebrowser.domain.Genre +import com.github.harmittaa.moviebrowser.domain.Movie +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runBlockingTest +import org.junit.After +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn( + FlowPreview::class, + ExperimentalCoroutinesApi::class, + ExperimentalTime::class, + ExperimentalStdlibApi::class +) +@RunWith(JUnit4::class) +class MovieUseCaseTest { + private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() + + private lateinit var repository: Store, List> + private lateinit var useCase: MovieUseCase + private val movieList: List = listOf(mock()) + + @Before + fun setUp() { + repository = mock() + useCase = MovieUseCase(repository = repository) + } + + @Test + @Ignore("Failing coroutine mocks") + fun `test getMovies when repository throws an error then Error resource is returned`() = + runBlockingTest { + val fetcher: FakeFetcher, List> = mock() + val persister: FakePersister, List, List> = mock() + val repository = StoreBuilder.from(fetcher, sourceOfTruth = persister).build() + + whenever(fetcher.getData(any())).thenReturn(movieList) + whenever(persister.get(any())).thenReturn(movieList) + + useCase = MovieUseCase(repository, testDispatcher) + + val result = useCase.getMovies(listOf()).toList() + + assertSame(result.first().data!!, movieList) + } + + @After + fun tearDown() { + Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher + } + + abstract class FakeFetcher : Fetcher { + abstract fun getData(f: (Key) -> Output): Output + } + + abstract class FakePersister : + SourceOfTruth { + abstract fun get(f: (Key) -> Output): Output + abstract fun insert(f: (Key, Output) -> Unit) + abstract fun delete(f: (Key) -> Unit) + abstract fun deleteAl(f: () -> Unit) + } +} diff --git a/build.gradle b/build.gradle index 372cc49..5779268 100644 --- a/build.gradle +++ b/build.gradle @@ -8,13 +8,9 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:4.0.1" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - def koin_version = '2.1.6' - classpath "org.koin:koin-gradle-plugin:$koin_version" def spoless_version = "5.1.0" classpath "com.diffplug.spotless:spotless-plugin-gradle:$spoless_version" - - // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/gradle.properties b/gradle.properties index 4d15d01..ae09d2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,12 +10,18 @@ org.gradle.jvmargs=-Xmx2048m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true +# Enables building modules in parallel +# https://guides.gradle.org/performance/#parallel_execution +org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app"s APK +# Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +# Enables Gradle to use outputs from previous builds, instead of building everything again +# https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching=true +tmdb_api_key="ENTER_API_KEY"