diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 545786e2..656334db 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,32 +2,53 @@ name: TMDB CI on: push: - branches: [ master ] + branches: [ master, feature/github_actions ] pull_request: - branches: [ master ] + branches: [ master, feature/github_actions ] jobs: test: name: Run Unit Tests - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest #Each job runs in a runner environment specified steps: - - uses: actions/checkout@v1 - - name: set up JDK 1.8 - uses: actions/setup-java@v1 + - uses: actions/checkout@v2.3.3 + - name: set up JDK 11 + uses: actions/setup-java@v2 with: - java-version: 1.8 + java-version: '11' + distribution: 'adopt' + cache: gradle - name: Unit tests run: bash ./gradlew test --stacktrace - name: Unit tests results - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v2.2.3 with: name: unit-tests-results path: app/build/reports/tests/testDebugUnitTest/index.html + lint: + name: Lint Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.3 + - name: set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'adopt' + cache: gradle + - name: Lint debug flavor + run: bash ./gradlew lintDebug --stacktrace + - name: Lint results + uses: actions/upload-artifact@v2.2.3 + with: + name: lint-result + path: app/build/reports/lint-results-debug.html + build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v2.3.3 - name: set up JDK 11 uses: actions/setup-java@v2 with: @@ -35,7 +56,30 @@ jobs: distribution: 'adopt' cache: gradle - - name: Grant execute permission for gradlew - run: chmod +x gradlew - name: Build with Gradle - run: ./gradlew build \ No newline at end of file + run: bash ./gradlew assembleDebug --stacktrace + + - name: Upload APK + uses: actions/upload-artifact@v2.2.3 + with: + name: app + path: app/build/outputs/apk/debug/*.apk + + - name: Send mail + if: always() + uses: dawidd6/action-send-mail@v2 + with: + # mail server settings + server_address: smtp.gmail.com + server_port: 465 + # user credentials + username: ${{ secrets.EMAIL_USERNAME }} + password: ${{ secrets.EMAIL_PASSWORD }} + # email subject + subject: ${{ github.job }} job of ${{ github.repository }} has ${{ job.status }} + # email body as text + body: ${{ github.job }} job in worflow ${{ github.workflow }} of ${{ github.repository }} has ${{ job.status }} + # comma-separated string, send email to + to: francisco.beccuti@intive.com + # from email name + from: TMDB Team! GitHub Actions! \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 11b6a809..58dc2e1e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,10 +5,18 @@ plugins { id 'dagger.hilt.android.plugin' id 'androidx.navigation.safeargs.kotlin' id 'kotlin-parcelize' + id "org.sonarqube" version "3.3" } android { compileSdk 30 + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + // Sets Java compatibility to Java 8 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } def _major def _minor @@ -96,6 +104,7 @@ dependencies { implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "androidx.palette:palette-ktx:$paletteVersion" implementation "com.jakewharton.timber:timber:$timberVersion" + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' implementation "androidx.room:room-ktx:$roomVersion" testImplementation "androidx.arch.core:core-testing:$archTestingVersion" diff --git a/app/src/main/java/com/intive/tmdbandroid/datasource/TVShowSearchSource.kt b/app/src/main/java/com/intive/tmdbandroid/datasource/TVShowSearchSource.kt new file mode 100644 index 00000000..f5746c17 --- /dev/null +++ b/app/src/main/java/com/intive/tmdbandroid/datasource/TVShowSearchSource.kt @@ -0,0 +1,40 @@ +package com.intive.tmdbandroid.datasource + +import androidx.paging.PagingSource +import com.intive.tmdbandroid.entity.ResultTVShowsEntity +import androidx.paging.PagingState +import com.intive.tmdbandroid.datasource.network.Service +import com.intive.tmdbandroid.model.TVShow +import kotlinx.coroutines.flow.collect + +class TVShowSearchSource(private val service: Service, private val query: String) : PagingSource() { + companion object { + const val DEFAULT_PAGE_INDEX = 1 + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val pageNumber = params.key ?: DEFAULT_PAGE_INDEX + lateinit var response: ResultTVShowsEntity + service.getTvShowByTitle(query, pageNumber).collect { response = it } + + val prevKey = if (pageNumber > DEFAULT_PAGE_INDEX) pageNumber - 1 else null + val nextKey = if (response.TVShows.isNotEmpty()) pageNumber + 1 else null + + LoadResult.Page( + data = response.toTVShowList(), + prevKey = prevKey, + nextKey = nextKey + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { + state.closestPageToPosition(it)?.prevKey?.plus(1) + ?: state.closestPageToPosition(it)?.nextKey?.minus(1) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/datasource/local/LocalStorage.kt b/app/src/main/java/com/intive/tmdbandroid/datasource/local/LocalStorage.kt index 83069c7d..da10e0a8 100644 --- a/app/src/main/java/com/intive/tmdbandroid/datasource/local/LocalStorage.kt +++ b/app/src/main/java/com/intive/tmdbandroid/datasource/local/LocalStorage.kt @@ -8,7 +8,7 @@ import com.intive.tmdbandroid.model.converter.CreatedByConverter import com.intive.tmdbandroid.model.converter.GenreConverter @Database(entities = [(TVShowORMEntity::class)], version = 1, exportSchema = false) -@TypeConverters(CreatedByConverter::class,GenreConverter::class) +@TypeConverters(CreatedByConverter::class, GenreConverter::class) abstract class LocalStorage : RoomDatabase() { abstract fun tvShowDao(): Dao } \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/datasource/network/ApiClient.kt b/app/src/main/java/com/intive/tmdbandroid/datasource/network/ApiClient.kt index e6b616ef..bd95100b 100644 --- a/app/src/main/java/com/intive/tmdbandroid/datasource/network/ApiClient.kt +++ b/app/src/main/java/com/intive/tmdbandroid/datasource/network/ApiClient.kt @@ -14,4 +14,9 @@ interface ApiClient { @GET("tv/{tv_id}") suspend fun getTVShowByID(@Path("tv_id") tvShowID: Int, @Query("api_key") apiKey: String) : TVShow + + @GET("search/tv") + suspend fun getTVShowByName(@Query("api_key") apiKey: String, + @Query("query") query: String, + @Query("page") page: Int) : ResultTVShowsEntity } \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/datasource/network/Service.kt b/app/src/main/java/com/intive/tmdbandroid/datasource/network/Service.kt index 565ebba4..4d3a1005 100644 --- a/app/src/main/java/com/intive/tmdbandroid/datasource/network/Service.kt +++ b/app/src/main/java/com/intive/tmdbandroid/datasource/network/Service.kt @@ -10,15 +10,21 @@ import kotlinx.coroutines.flow.flow class Service { private val retrofit = RetrofitHelper.getRetrofit() - fun getPaginatedPopularTVShows(page: Int) : Flow { + fun getPaginatedPopularTVShows(page: Int): Flow { return flow { emit(retrofit.create(ApiClient::class.java).getPaginatedPopularTVShows(BuildConfig.API_KEY, page)) } } - fun getTVShowByID(tvShowID: Int) : Flow { + fun getTVShowByID(tvShowID: Int): Flow { return flow { emit(retrofit.create(ApiClient::class.java).getTVShowByID(tvShowID, BuildConfig.API_KEY)) } } -} \ No newline at end of file + + fun getTvShowByTitle(tvShowTitle: String, page: Int): Flow { + return flow { + emit(retrofit.create(ApiClient::class.java).getTVShowByName(BuildConfig.API_KEY, tvShowTitle, page)) + } + } +} diff --git a/app/src/main/java/com/intive/tmdbandroid/details/ui/DetailFragment.kt b/app/src/main/java/com/intive/tmdbandroid/details/ui/DetailFragment.kt index 08bf332b..64ccc8a7 100644 --- a/app/src/main/java/com/intive/tmdbandroid/details/ui/DetailFragment.kt +++ b/app/src/main/java/com/intive/tmdbandroid/details/ui/DetailFragment.kt @@ -23,6 +23,8 @@ import com.intive.tmdbandroid.details.viewmodel.DetailsViewModel import com.intive.tmdbandroid.model.TVShow import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.* @@ -47,8 +49,8 @@ class DetailFragment : Fragment() { ): View { val binding = FragmentDetailBinding.inflate(inflater, container, false) - collectDataFromViewModel(binding) - setupToolbar(binding) + collectTVShowDetailFromViewModel(binding) + collectWatchlistDataFromViewModel(binding) return binding.root } @@ -65,7 +67,7 @@ class DetailFragment : Fragment() { Glide.get(requireContext()).clearMemory() } - private fun collectDataFromViewModel(binding: FragmentDetailBinding) { + private fun collectTVShowDetailFromViewModel(binding: FragmentDetailBinding) { binding.coordinatorContainerDetail.visibility = View.INVISIBLE lifecycleScope.launchWhenCreated { viewModel.uiState.collect { state -> @@ -89,6 +91,30 @@ class DetailFragment : Fragment() { } } + private fun collectWatchlistDataFromViewModel(binding: FragmentDetailBinding) { + lifecycleScope.launch { + viewModel.watchlistUIState.collectLatest { + when (it) { + is State.Success -> { + binding.layoutErrorDetail.errorContainer.visibility = View.GONE + binding.layoutLoadingDetail.progressBar.visibility = View.GONE + selectOrUnselectWatchlistFav(binding, it.data) + isSaveOnWatchlist = it.data + } + State.Error -> { + binding.layoutLoadingDetail.progressBar.visibility = View.GONE + binding.layoutErrorDetail.errorContainer.visibility = View.VISIBLE + binding.coordinatorContainerDetail.visibility = View.VISIBLE + } + State.Loading -> { + binding.layoutErrorDetail.errorContainer.visibility = View.GONE + binding.layoutLoadingDetail.progressBar.visibility = View.VISIBLE + } + } + } + } + } + private fun setupUI(binding: FragmentDetailBinding, tvShow: TVShow) { setImages(binding, tvShow) @@ -97,6 +123,8 @@ class DetailFragment : Fragment() { setPercentageToCircularPercentage(binding, tvShow.vote_average) + setupToolbar(binding, tvShow) + binding.toolbar.title = tvShow.name binding.statusDetailTextView.text = tvShow.status @@ -126,6 +154,8 @@ class DetailFragment : Fragment() { binding.overviewDetailTextView.text = tvShow.overview binding.coordinatorContainerDetail.visibility = View.VISIBLE + + tvShowId?.let { viewModel.existAsFavorite(it) } } private fun setPercentageToCircularPercentage( @@ -139,10 +169,14 @@ class DetailFragment : Fragment() { val context = binding.root.context when { - percentage < 25 -> binding.circularPercentage.progressTintList = ContextCompat.getColorStateList(context, R.color.red) - percentage < 45 -> binding.circularPercentage.progressTintList = ContextCompat.getColorStateList(context, R.color.orange) - percentage < 75 -> binding.circularPercentage.progressTintList = ContextCompat.getColorStateList(context, R.color.yellow) - else -> binding.circularPercentage.progressTintList = ContextCompat.getColorStateList(context, R.color.green) + percentage < 25 -> binding.circularPercentage.progressTintList = + ContextCompat.getColorStateList(context, R.color.red) + percentage < 45 -> binding.circularPercentage.progressTintList = + ContextCompat.getColorStateList(context, R.color.orange) + percentage < 75 -> binding.circularPercentage.progressTintList = + ContextCompat.getColorStateList(context, R.color.yellow) + else -> binding.circularPercentage.progressTintList = + ContextCompat.getColorStateList(context, R.color.green) } binding.screeningPopularity.text = resources.getString(R.string.popularity, percentage) } @@ -150,7 +184,8 @@ class DetailFragment : Fragment() { private fun setDate(binding: FragmentDetailBinding, firstAirDate: String) { try { val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(firstAirDate) - val stringDate = date?.let { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(it) } + val stringDate = + date?.let { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(it) } binding.firstAirDateDetailTextView.text = stringDate } catch (e: Exception) { binding.firstAirDateDetailTextView.text = "" @@ -176,15 +211,19 @@ class DetailFragment : Fragment() { .into(binding.backgroundImageToolbarLayout) } - private fun setupToolbar(binding: FragmentDetailBinding) { + private fun setupToolbar(binding: FragmentDetailBinding, tvShow: TVShow) { val navController = findNavController() val appBarConfiguration = AppBarConfiguration(navController.graph) val toolbar = binding.toolbar toolbar.inflateMenu(R.menu.watchlist_favorite_detail_fragment) toolbar.setOnMenuItemClickListener { - when(it.itemId) { + when (it.itemId) { R.id.ic_heart_watchlist -> { - selectOrUnselectWatchlistFav(binding) + if (!isSaveOnWatchlist) { + viewModel.addToWatchlist(tvShow.toTVShowORMEntity()) + } else { + viewModel.deleteFromWatchlist(tvShow.toTVShowORMEntity()) + } true } else -> false @@ -202,13 +241,14 @@ class DetailFragment : Fragment() { }) } - private fun selectOrUnselectWatchlistFav(binding: FragmentDetailBinding) { - isSaveOnWatchlist = !isSaveOnWatchlist + private fun selectOrUnselectWatchlistFav(binding: FragmentDetailBinding, isFav: Boolean) { val watchlistItem = binding.toolbar.menu.findItem(R.id.ic_heart_watchlist) - if (isSaveOnWatchlist){ - watchlistItem.icon = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_heart_selected) - }else { - watchlistItem.icon = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_heart_unselected) - } + if (isFav) { + watchlistItem.icon = + AppCompatResources.getDrawable(requireContext(), R.drawable.ic_heart_selected) + } else watchlistItem.icon = + AppCompatResources.getDrawable(requireContext(), R.drawable.ic_heart_unselected) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModel.kt b/app/src/main/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModel.kt index 3e73da33..8ead7380 100644 --- a/app/src/main/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModel.kt +++ b/app/src/main/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModel.kt @@ -3,8 +3,12 @@ package com.intive.tmdbandroid.details.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.intive.tmdbandroid.common.State +import com.intive.tmdbandroid.entity.TVShowORMEntity import com.intive.tmdbandroid.model.TVShow import com.intive.tmdbandroid.usecase.DetailTVShowUseCase +import com.intive.tmdbandroid.usecase.GetIfExistsUseCase +import com.intive.tmdbandroid.usecase.RemoveTVShowFromWatchlistUseCase +import com.intive.tmdbandroid.usecase.SaveTVShowInWatchlistUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -16,12 +20,17 @@ import javax.inject.Inject @HiltViewModel class DetailsViewModel @Inject internal constructor( private val tVShowUseCase: DetailTVShowUseCase, + private val saveTVShowInWatchlistUseCase: SaveTVShowInWatchlistUseCase, + private val removeTVShowFromWatchlistUseCase: RemoveTVShowFromWatchlistUseCase, + private val getIfExistsUseCase: GetIfExistsUseCase ) : ViewModel() { private val _state = MutableStateFlow>(State.Loading) - val uiState: StateFlow> = _state + private val _watchlistState = MutableStateFlow>(State.Loading) + val watchlistUIState: StateFlow> = _watchlistState + fun tVShows(id: Int) { viewModelScope.launch { tVShowUseCase(id) @@ -33,4 +42,40 @@ class DetailsViewModel @Inject internal constructor( } } } + + fun addToWatchlist(tvShow: TVShowORMEntity) { + viewModelScope.launch { + saveTVShowInWatchlistUseCase(tvShow) + .catch { + _watchlistState.value = State.Error + } + .collect { + _watchlistState.value = State.Success(it) + } + } + } + + fun deleteFromWatchlist(tvShow: TVShowORMEntity) { + viewModelScope.launch { + removeTVShowFromWatchlistUseCase(tvShow) + .catch { + _watchlistState.value = State.Error + } + .collect { + _watchlistState.value = State.Success(it) + } + } + } + + fun existAsFavorite(id: Int) { + viewModelScope.launch { + getIfExistsUseCase(id) + .catch { + _watchlistState.value = State.Error + } + .collect { + _watchlistState.value = State.Success(it) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/entity/TVShowORMEntity.kt b/app/src/main/java/com/intive/tmdbandroid/entity/TVShowORMEntity.kt index da581699..b6160fdd 100644 --- a/app/src/main/java/com/intive/tmdbandroid/entity/TVShowORMEntity.kt +++ b/app/src/main/java/com/intive/tmdbandroid/entity/TVShowORMEntity.kt @@ -7,6 +7,7 @@ import com.intive.tmdbandroid.model.CreatedBy import com.intive.tmdbandroid.model.Genre import com.intive.tmdbandroid.model.TVShow import com.intive.tmdbandroid.model.converter.CreatedByConverter +import com.intive.tmdbandroid.model.converter.GenreConverter @Entity data class TVShowORMEntity( @@ -14,6 +15,7 @@ data class TVShowORMEntity( @TypeConverters(CreatedByConverter::class) val created_by: List, val first_air_date: String?, + @TypeConverters(GenreConverter::class) val genres: List, @PrimaryKey(autoGenerate = false) val id: Int, diff --git a/app/src/main/java/com/intive/tmdbandroid/home/ui/HomeFragment.kt b/app/src/main/java/com/intive/tmdbandroid/home/ui/HomeFragment.kt index a9968fe8..9903b71d 100644 --- a/app/src/main/java/com/intive/tmdbandroid/home/ui/HomeFragment.kt +++ b/app/src/main/java/com/intive/tmdbandroid/home/ui/HomeFragment.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController @@ -39,6 +40,11 @@ class HomeFragment : Fragment() { viewModel.popularTVShows() } + override fun onResume() { + super.onResume() + viewModel.watchlistTVShows() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -50,15 +56,19 @@ class HomeFragment : Fragment() { initViews(binding) subscribePopularData(binding) setupToolbar(binding) - + subscribeWatchlistData(binding) return binding.root } private fun setupToolbar(binding: FragmentHomeBinding) { val navController = findNavController() val appBarConfiguration = AppBarConfiguration(navController.graph) - val toolbar = binding.fragmentHomeToolbar - toolbar.setupWithNavController(navController, appBarConfiguration) + binding.fragmentHomeToolbar.setupWithNavController(navController, appBarConfiguration) + binding.fragmentHomeToolbar.inflateMenu(R.menu.options_menu) + binding.fragmentHomeToolbar.setOnMenuItemClickListener{ + binding.fragmentHomeToolbar.findNavController().navigate(R.id.action_homeFragmentDest_to_searchFragment) + true + } } private fun subscribePopularData(binding: FragmentHomeBinding) { @@ -72,8 +82,6 @@ class HomeFragment : Fragment() { binding.layoutError.errorContainer.visibility = View.GONE binding.layoutProgressbar.progressBar.visibility = View.GONE - updateMockWatchlist() - tvShowPageAdapter.submitData(resultTVShows.data) if (tvShowPageAdapter.itemCount == 0) { @@ -93,15 +101,26 @@ class HomeFragment : Fragment() { } } - private fun updateMockWatchlist() { - val list = listOf( - TVShow("", emptyList(), "2021-09-13", emptyList(), 0, "", "Some TV Show 0", 1, 1, "", "", "", "", 2.0, 10), - TVShow("", emptyList(), "2021-09-13", emptyList(), 1, "", "Some TV Show 1", 2, 2, "", "", "", "", 4.0, 10), - TVShow("", emptyList(), "2021-09-13", emptyList(), 2, "", "Some TV Show 2", 3, 3, "", "", "", "", 6.0, 10), - TVShow("", emptyList(), "2021-09-13", emptyList(), 3, "", "Some TV Show 3", 4, 4, "", "", "", "", 8.0, 10) - ) - - tvShowPageAdapter.refreshWatchlistAdapter(list) + private fun subscribeWatchlistData(binding: FragmentHomeBinding) { + lifecycleScope.launchWhenStarted { + viewModel.watchlistUIState.collectLatest { + when(it) { + is State.Success> -> { + binding.layoutError.errorContainer.visibility = View.GONE + binding.layoutProgressbar.progressBar.visibility = View.GONE + tvShowPageAdapter.refreshWatchlistAdapter(it.data) + } + is State.Error -> { + binding.layoutProgressbar.progressBar.visibility = View.GONE + binding.layoutError.errorContainer.visibility = View.VISIBLE + } + is State.Loading -> { + binding.layoutProgressbar.progressBar.visibility = View.VISIBLE + binding.layoutError.errorContainer.visibility = View.GONE + } + } + } + } } private fun initViews(binding: FragmentHomeBinding) { @@ -109,13 +128,10 @@ class HomeFragment : Fragment() { rvTopTVShows.apply { val displayMetrics = context.resources.displayMetrics - Timber.i("MAS - density: $displayMetrics") - val dpWidth = displayMetrics.widthPixels / displayMetrics.density Timber.i("MAS - dpWidth: $dpWidth") val scaling = resources.getInteger(R.integer.screening_width) - Timber.i("MAS - scaling: $scaling") val columnCount = floor(dpWidth / scaling).toInt() Timber.i("MAS - columnCount: $columnCount") diff --git a/app/src/main/java/com/intive/tmdbandroid/home/viewmodel/HomeViewModel.kt b/app/src/main/java/com/intive/tmdbandroid/home/viewmodel/HomeViewModel.kt index 6f224ab4..64af955d 100644 --- a/app/src/main/java/com/intive/tmdbandroid/home/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/com/intive/tmdbandroid/home/viewmodel/HomeViewModel.kt @@ -6,6 +6,7 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import com.intive.tmdbandroid.common.State import com.intive.tmdbandroid.model.TVShow +import com.intive.tmdbandroid.usecase.GetAllItemsInWatchlistUseCase import com.intive.tmdbandroid.usecase.PaginatedPopularTVShowsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -18,11 +19,15 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject internal constructor( private val paginatedPopularTVShowsUseCase: PaginatedPopularTVShowsUseCase, + private val getAllItemsInWatchlistUseCase: GetAllItemsInWatchlistUseCase ) : ViewModel() { private val _state = MutableStateFlow>>(State.Loading) val uiState: StateFlow>> = _state + private val _watchlistState = MutableStateFlow>>(State.Loading) + val watchlistUIState: StateFlow>> = _watchlistState + fun popularTVShows() { viewModelScope.launch { paginatedPopularTVShowsUseCase() @@ -35,4 +40,16 @@ class HomeViewModel @Inject internal constructor( } } } + + fun watchlistTVShows() { + viewModelScope.launch { + getAllItemsInWatchlistUseCase() + .catch { + _watchlistState.value = State.Error + } + .collect { + _watchlistState.value = State.Success(it) + } + } + } } diff --git a/app/src/main/java/com/intive/tmdbandroid/model/converter/CreatedByConverter.kt b/app/src/main/java/com/intive/tmdbandroid/model/converter/CreatedByConverter.kt index 3caeaafd..fdb9182b 100644 --- a/app/src/main/java/com/intive/tmdbandroid/model/converter/CreatedByConverter.kt +++ b/app/src/main/java/com/intive/tmdbandroid/model/converter/CreatedByConverter.kt @@ -1,9 +1,9 @@ package com.intive.tmdbandroid.model.converter import androidx.room.TypeConverter -import com.intive.tmdbandroid.model.CreatedBy import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import com.intive.tmdbandroid.model.CreatedBy import java.lang.reflect.Type @@ -24,7 +24,7 @@ class CreatedByConverter { return null } val gson = Gson() - val type: Type = object : TypeToken?>() {}.getType() + val type: Type = object : TypeToken?>() {}.type return gson.toJson(createdBy, type) } } \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/model/converter/GenreConverter.kt b/app/src/main/java/com/intive/tmdbandroid/model/converter/GenreConverter.kt index af320d49..b14d1668 100644 --- a/app/src/main/java/com/intive/tmdbandroid/model/converter/GenreConverter.kt +++ b/app/src/main/java/com/intive/tmdbandroid/model/converter/GenreConverter.kt @@ -1,7 +1,6 @@ package com.intive.tmdbandroid.model.converter import androidx.room.TypeConverter -import com.intive.tmdbandroid.model.CreatedBy import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.intive.tmdbandroid.model.Genre @@ -25,7 +24,7 @@ class GenreConverter { return null } val gson = Gson() - val type: Type = object : TypeToken?>() {}.getType() + val type: Type = object : TypeToken?>() {}.type return gson.toJson(genre, type) } } \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/repository/CatalogRepository.kt b/app/src/main/java/com/intive/tmdbandroid/repository/CatalogRepository.kt index dbb2c577..1243ed84 100644 --- a/app/src/main/java/com/intive/tmdbandroid/repository/CatalogRepository.kt +++ b/app/src/main/java/com/intive/tmdbandroid/repository/CatalogRepository.kt @@ -4,9 +4,11 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.intive.tmdbandroid.datasource.TVShowPagingSource +import com.intive.tmdbandroid.datasource.TVShowSearchSource import com.intive.tmdbandroid.datasource.network.Service import com.intive.tmdbandroid.model.TVShow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -33,4 +35,16 @@ class CatalogRepository @Inject constructor( fun getTVShowByID(id:Int): Flow{ return service.getTVShowByID(id) } + + fun search(name:String): Flow> { + return Pager( + config = PagingConfig( + pageSize = DEFAULT_PAGE_SIZE, + enablePlaceholders = false + ), + pagingSourceFactory = { + TVShowSearchSource(service = service, name) + } + ).flow + } } \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/search/ui/SearchFragment.kt b/app/src/main/java/com/intive/tmdbandroid/search/ui/SearchFragment.kt new file mode 100644 index 00000000..d2e77b3a --- /dev/null +++ b/app/src/main/java/com/intive/tmdbandroid/search/ui/SearchFragment.kt @@ -0,0 +1,126 @@ +package com.intive.tmdbandroid.search.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupWithNavController +import androidx.paging.PagingData +import androidx.recyclerview.widget.LinearLayoutManager +import com.intive.tmdbandroid.common.State +import com.intive.tmdbandroid.databinding.FragmentSearchBinding +import com.intive.tmdbandroid.model.TVShow +import com.intive.tmdbandroid.search.ui.adapters.TVShowSearchAdapter +import com.intive.tmdbandroid.search.viewmodel.SearchViewModel +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest + +@AndroidEntryPoint +class SearchFragment: Fragment() { + private val viewModel: SearchViewModel by viewModels() + + private val clickListener = { tvShow: TVShow -> + val action = SearchFragmentDirections.actionSearchFragmentToTVShowDetail(tvShow.id) + findNavController().navigate(action) + } + + private val searchAdapter = TVShowSearchAdapter(clickListener) + + private var searchViewQuery: String = "" + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = FragmentSearchBinding.inflate(inflater, container, false) + binding.layoutProgressbar.progressBar.visibility = View.GONE + setupToolbar(binding) + binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + + override fun onQueryTextChange(newText: String): Boolean { + return false + } + + override fun onQueryTextSubmit(query: String): Boolean { + if (query.isNotEmpty()){ + searchAdapter.query = query + binding.searchView.clearFocus() + viewModel.search(query) + searchViewQuery = query + subscribeViewModel(binding) + return true + } + return false + } + }) + initViews(binding) + if(searchViewQuery.isEmpty()){ + binding.searchView.requestFocus() + val imm = binding.searchView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + binding.searchView.postDelayed( { + imm.showSoftInput(binding.searchView, InputMethodManager.SHOW_IMPLICIT) + }, 50) + } + else{ + binding.layoutSearchHint.hintContainer.visibility = View.GONE + } + return binding.root + } + + private fun setupToolbar(binding: FragmentSearchBinding) { + val navController = findNavController() + val appBarConfiguration = AppBarConfiguration(navController.graph) + val toolbar = binding.fragmentSearchToolbar + toolbar.setupWithNavController(navController, appBarConfiguration) + } + + fun subscribeViewModel(binding: FragmentSearchBinding){ + searchAdapter.notifyItemChanged(0) + searchAdapter.differ.addLoadStateListener { loadState -> + if(loadState.append.endOfPaginationReached){ + if (searchAdapter.itemCount < 1 + 1) { + binding.layoutEmpty.root.visibility = View.VISIBLE + } else binding.layoutEmpty.root.visibility = View.GONE + } + } + lifecycleScope.launchWhenStarted { + viewModel.uiState.collectLatest { resultTVShow -> + + when (resultTVShow) { + is State.Success> -> { + binding.layoutSearchHint.hintContainer.visibility = View.GONE + binding.layoutProgressbar.progressBar.visibility = View.GONE + searchAdapter.submitData(resultTVShow.data) + } + is State.Error -> { + binding.layoutError.errorContainer.visibility = View.VISIBLE + binding.layoutSearchHint.hintContainer.visibility = View.GONE + binding.layoutProgressbar.progressBar.visibility = View.GONE + } + is State.Loading -> { + binding.layoutSearchHint.hintContainer.visibility = View.GONE + binding.layoutProgressbar.progressBar.visibility = View.VISIBLE + } + } + } + } + } + + private fun initViews(binding: FragmentSearchBinding) { + val resultsList = binding.searchResults + + resultsList.apply { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + adapter = searchAdapter + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/search/ui/adapters/TVShowSearchAdapter.kt b/app/src/main/java/com/intive/tmdbandroid/search/ui/adapters/TVShowSearchAdapter.kt new file mode 100644 index 00000000..b4475f4b --- /dev/null +++ b/app/src/main/java/com/intive/tmdbandroid/search/ui/adapters/TVShowSearchAdapter.kt @@ -0,0 +1,150 @@ +package com.intive.tmdbandroid.search.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.PagingData +import androidx.recyclerview.widget.AdapterListUpdateCallback +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.intive.tmdbandroid.R +import com.intive.tmdbandroid.databinding.HeaderResultsSearchBinding +import com.intive.tmdbandroid.databinding.ItemFoundSearchBinding +import com.intive.tmdbandroid.model.TVShow +import timber.log.Timber +import java.lang.Exception +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class TVShowSearchAdapter(private val clickListener: ((TVShow) -> Unit)) : RecyclerView.Adapter() { + var query: String = "" + + val adapterCallback = AdapterListUpdateCallback(this) + + companion object { + private const val HEADER = 0 + private const val ITEM = 1 + } + + val differ = AsyncPagingDataDiffer( + TVShowAsyncPagingDataDiffCallback(), + object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + adapterCallback.onInserted(position + 1, count) + } + + override fun onRemoved(position: Int, count: Int) { + adapterCallback.onRemoved(position + 1, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + adapterCallback.onMoved(fromPosition + 1, toPosition + 1) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + adapterCallback.onChanged(position + 1, count, payload) + } + + } + ) + + suspend fun submitData(tvShowPagingData: PagingData) { + differ.submitData(tvShowPagingData) + } + + override fun getItemCount(): Int { + return differ.itemCount + 1 + } + + override fun getItemViewType(position: Int): Int { + return when (position) { + 0 -> HEADER + else -> ITEM + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + HEADER -> HeaderHolder(HeaderResultsSearchBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + ITEM -> SearchResultHolder(ItemFoundSearchBinding.inflate(LayoutInflater.from(parent.context), parent, false), clickListener) + else -> throw Exception("Illegal ViewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is HeaderHolder -> holder.bind(query) + is SearchResultHolder -> differ.getItem(position - 1)?.let { holder.bind(it) } + } + } + + inner class HeaderHolder (private val binding: HeaderResultsSearchBinding): RecyclerView.ViewHolder(binding.root){ + fun bind(query: String){ + binding.searchHeader.text = binding.root.context.getString(R.string.search_result_header, query) + } + } + + inner class SearchResultHolder (private val binding: ItemFoundSearchBinding, private val clickListener: ((TVShow) -> Unit)) : RecyclerView.ViewHolder(binding.root){ + + private val itemTitle = binding.itemTitleSearch + private val itemYear = binding.itemYearSearch + private val itemSeasons = binding.itemSeasonsSearch + private val itemRating = binding.itemRatingSearch + + fun bind(item: TVShow){ + + itemView.setOnClickListener { + clickListener.invoke(item) + } + + try { + if(!item.first_air_date.isNullOrBlank()){ + val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val dateStr = item.first_air_date + val date: LocalDate = LocalDate.parse(dateStr, formatter) + itemYear.text = date.year.toString() + } + } catch (e : Exception){ + Timber.e(e) + } + + itemTitle.text = item.name + itemSeasons.text = item.number_of_seasons?.let { + binding.root.resources.getQuantityString( + R.plurals.numberOfSeasons, + it, + it + ) + } + itemRating.rating = item.vote_average.toFloat()/2 + + val options = RequestOptions() + .centerCrop() + .placeholder(R.drawable.ic_image) + .error(R.drawable.ic_image) + + val posterURL = binding.root.resources.getString(R.string.base_imageURL) + item.poster_path + + Glide.with(binding.root.context) + .load(posterURL) + .apply(options) + .into(binding.itemPosterSearch) + + + } + } + + private class TVShowAsyncPagingDataDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: TVShow, newItem: TVShow): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: TVShow, newItem: TVShow): Boolean { + return oldItem == newItem + } + } + +} diff --git a/app/src/main/java/com/intive/tmdbandroid/search/viewmodel/SearchViewModel.kt b/app/src/main/java/com/intive/tmdbandroid/search/viewmodel/SearchViewModel.kt new file mode 100644 index 00000000..145b9eaf --- /dev/null +++ b/app/src/main/java/com/intive/tmdbandroid/search/viewmodel/SearchViewModel.kt @@ -0,0 +1,40 @@ +package com.intive.tmdbandroid.search.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.intive.tmdbandroid.common.State +import com.intive.tmdbandroid.model.TVShow +import com.intive.tmdbandroid.usecase.SearchUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val searchUseCase: SearchUseCase +) : ViewModel() { + + private val _state = MutableStateFlow>>(State.Loading) + + val uiState: StateFlow>> = _state + + fun search(name:String) { + viewModelScope.launch { + searchUseCase(name) + .cachedIn(viewModelScope) + .catch { + _state.value = State.Error + } + .collect { + _state.value = State.Success(it) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/intive/tmdbandroid/usecase/SearchUseCase.kt b/app/src/main/java/com/intive/tmdbandroid/usecase/SearchUseCase.kt new file mode 100644 index 00000000..bebdae18 --- /dev/null +++ b/app/src/main/java/com/intive/tmdbandroid/usecase/SearchUseCase.kt @@ -0,0 +1,12 @@ +package com.intive.tmdbandroid.usecase + +import androidx.paging.PagingData +import com.intive.tmdbandroid.model.TVShow +import com.intive.tmdbandroid.repository.CatalogRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class SearchUseCase @Inject constructor(private val catalogRepository: CatalogRepository) { + + operator fun invoke(name:String): Flow> = catalogRepository.search(name) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 00000000..e2dd96c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_hint.xml b/app/src/main/res/drawable/ic_search_hint.xml new file mode 100644 index 00000000..7ccdb5eb --- /dev/null +++ b/app/src/main/res/drawable/ic_search_hint.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index 00955499..0ff7f5a8 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -8,10 +8,10 @@ tools:context=".details.ui.DetailFragment"> + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + app:layout_collapseMode="parallax" + android:contentDescription="@string/collapsing_toolbar_description" + tools:src="@drawable/ic_launcher_foreground" /> + android:contentDescription="@string/details_poster_description" + tools:src="@drawable/ic_launcher_foreground" /> @@ -174,15 +177,17 @@ - + android:layout_height="match_parent" /> - + android:layout_height="match_parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml new file mode 100644 index 00000000..43a38ca8 --- /dev/null +++ b/app/src/main/res/layout/fragment_search.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/header_results_search.xml b/app/src/main/res/layout/header_results_search.xml new file mode 100644 index 00000000..cbc60314 --- /dev/null +++ b/app/src/main/res/layout/header_results_search.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_found_search.xml b/app/src/main/res/layout/item_found_search.xml new file mode 100644 index 00000000..00f048fc --- /dev/null +++ b/app/src/main/res/layout/item_found_search.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_screening.xml b/app/src/main/res/layout/item_screening.xml index fdcd34d0..473abe50 100644 --- a/app/src/main/res/layout/item_screening.xml +++ b/app/src/main/res/layout/item_screening.xml @@ -8,6 +8,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" + android:paddingBottom="8dp" app:cardCornerRadius="5dp" android:elevation="5dp"> diff --git a/app/src/main/res/layout/layout_search_hint.xml b/app/src/main/res/layout/layout_search_hint.xml new file mode 100644 index 00000000..47f9149d --- /dev/null +++ b/app/src/main/res/layout/layout_search_hint.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/options_menu.xml b/app/src/main/res/menu/options_menu.xml new file mode 100644 index 00000000..e237b7a8 --- /dev/null +++ b/app/src/main/res/menu/options_menu.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/watchlist_favorite_detail_fragment.xml b/app/src/main/res/menu/watchlist_favorite_detail_fragment.xml index cc99741b..675f8bed 100644 --- a/app/src/main/res/menu/watchlist_favorite_detail_fragment.xml +++ b/app/src/main/res/menu/watchlist_favorite_detail_fragment.xml @@ -1,8 +1,9 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> - diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 8a323b8b..9fdc4e61 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -14,6 +14,10 @@ android:id="@+id/action_homeFragmentDest_to_TVShowDetail" app:destination="@id/detailFragmentDest" app:popUpTo="@id/homeFragmentDest" /> + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index cd9ac96f..3cd3d772 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,8 @@ #FF000000 + #BEBEBE + #949494 #FFFFFFFF #0198c5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a66d4ab..6ba23b9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,12 +6,17 @@ Popular TV shows https://image.tmdb.org/t/p/w780 "%1$d%%" - TV Show details!!!!!!!! Oops! Something went wrong.\nSorry about that :( Nothing to see here Warning Overview Add to Watchlist + Search a TV Show or Movie + Start typing to find awesome TV shows + Search Hint Icon + Backdrop Poster Header + TV Show Poster + Results for: %1$s %d Season diff --git a/app/src/test/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModelTest.kt b/app/src/test/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModelTest.kt index a7f9fb53..b99cb3e0 100644 --- a/app/src/test/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModelTest.kt +++ b/app/src/test/java/com/intive/tmdbandroid/details/viewmodel/DetailsViewModelTest.kt @@ -8,6 +8,9 @@ import com.intive.tmdbandroid.common.State import com.intive.tmdbandroid.model.Genre import com.intive.tmdbandroid.model.TVShow import com.intive.tmdbandroid.usecase.DetailTVShowUseCase +import com.intive.tmdbandroid.usecase.GetIfExistsUseCase +import com.intive.tmdbandroid.usecase.RemoveTVShowFromWatchlistUseCase +import com.intive.tmdbandroid.usecase.SaveTVShowInWatchlistUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runBlockingTest @@ -18,7 +21,7 @@ import org.mockito.Mockito.* import kotlin.time.ExperimentalTime @ExperimentalCoroutinesApi -class DetailsViewModelTest{ +class DetailsViewModelTest { @get:Rule var mainCoroutineRule = MainCoroutineRule() @@ -26,10 +29,10 @@ class DetailsViewModelTest{ @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() - private val tvShow = TVShow( + private val tvShow = TVShow( backdrop_path = "BACKDROP_PATH", first_air_date = "1983-10-20", - genres = listOf(Genre(1, "genre1"), Genre(2,"genre2")), + genres = listOf(Genre(1, "genre1"), Genre(2, "genre2")), id = 1, name = "Simona la Cacarisa", original_name = "El cochiloco", @@ -45,19 +48,30 @@ class DetailsViewModelTest{ ) private lateinit var detailViewModel: DetailsViewModel - private lateinit var detailUseCase: DetailTVShowUseCase + private lateinit var tVShowUseCase: DetailTVShowUseCase + private lateinit var saveTVShowInWatchlistUseCase: SaveTVShowInWatchlistUseCase + private lateinit var removeTVShowFromWatchlistUseCase: RemoveTVShowFromWatchlistUseCase + private lateinit var getIfExistsUseCase: GetIfExistsUseCase @Before fun setup() { - detailUseCase = mock(DetailTVShowUseCase::class.java) - detailViewModel = DetailsViewModel(detailUseCase) + tVShowUseCase = mock(DetailTVShowUseCase::class.java) + saveTVShowInWatchlistUseCase = mock(SaveTVShowInWatchlistUseCase::class.java) + removeTVShowFromWatchlistUseCase = mock(RemoveTVShowFromWatchlistUseCase::class.java) + getIfExistsUseCase = mock(GetIfExistsUseCase::class.java) + detailViewModel = DetailsViewModel( + tVShowUseCase, + saveTVShowInWatchlistUseCase, + removeTVShowFromWatchlistUseCase, + getIfExistsUseCase + ) } @Test @ExperimentalTime fun tVShowsTest() = mainCoroutineRule.runBlockingTest { - `when`(detailUseCase.invoke(anyInt())).thenReturn( + `when`(tVShowUseCase.invoke(anyInt())).thenReturn( flow { emit( tvShow diff --git a/app/src/test/java/com/intive/tmdbandroid/search/viewmodel/SearchViewModelTest.kt b/app/src/test/java/com/intive/tmdbandroid/search/viewmodel/SearchViewModelTest.kt new file mode 100644 index 00000000..e9d5c45e --- /dev/null +++ b/app/src/test/java/com/intive/tmdbandroid/search/viewmodel/SearchViewModelTest.kt @@ -0,0 +1,101 @@ +package com.intive.tmdbandroid.search.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagingData +import app.cash.turbine.test +import com.google.common.truth.Truth +import com.intive.tmdbandroid.common.MainCoroutineRule +import com.intive.tmdbandroid.common.State +import com.intive.tmdbandroid.model.Genre +import com.intive.tmdbandroid.model.TVShow +import com.intive.tmdbandroid.usecase.SearchUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runBlockingTest +import org.junit.* +import org.mockito.BDDMockito +import org.mockito.Mockito.* +import kotlin.time.ExperimentalTime + +@ExperimentalCoroutinesApi +class SearchViewModelTest{ + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testTVShowPagingData = PagingData.from( + listOf( + TVShow( + backdrop_path = "BACKDROP_PATH", + first_air_date = "1983-10-20", + genres = listOf(Genre(1, "genre1"), Genre(2,"genre2")), + id = 1, + name = "Simona la Cacarisa", + original_name = "El cochiloco", + overview = "Simona la cacarisa, el cochiloco", + poster_path = "POSTER_PATH", + vote_average = 10.5, + vote_count = 100, + created_by = emptyList(), + last_air_date = "1990-09-25", + number_of_episodes = 5, + number_of_seasons = 2, + status = "Online" + ) + ) + ) + + private lateinit var searchViewModel: SearchViewModel + private lateinit var searchUseCase: SearchUseCase + + + @Before + fun setup() { + searchUseCase = mock(SearchUseCase::class.java) + searchViewModel = SearchViewModel(searchUseCase) + } + + @Test + @ExperimentalTime + @ExperimentalCoroutinesApi + @Ignore("There's a problem in how the cachedIn ext func from paging data works (it's using a flow to handle the cache which makes the content of the success not to be a paging data but actually a new flow). Ignoring this test for now, until we get a better way to test the paging library.") + fun tvShowsTest() = mainCoroutineRule.runBlockingTest { + `when`(searchUseCase.invoke(anyString())).thenReturn( + flow { + emit( + testTVShowPagingData + ) + } + ) + + searchViewModel.search("Simona la Cacarisa") + + searchViewModel.uiState.test { + Truth.assertThat(awaitItem()).isEqualTo(State.Success(testTVShowPagingData)) + } + + } + + @ExperimentalTime + @ExperimentalCoroutinesApi + @Test + fun searchTvShowError() { + mainCoroutineRule.runBlockingTest { + val runtimeException = RuntimeException() + BDDMockito.given(searchUseCase.invoke(anyString())).willReturn(flow { + throw runtimeException + }) + + searchViewModel.search("alberto fernandez") + + searchViewModel.uiState.test { + Truth.assertThat(awaitItem()).isEqualTo(State.Error) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/intive/tmdbandroid/usecase/SearchTVShowUseCaseTest.kt b/app/src/test/java/com/intive/tmdbandroid/usecase/SearchTVShowUseCaseTest.kt new file mode 100644 index 00000000..40aa02b8 --- /dev/null +++ b/app/src/test/java/com/intive/tmdbandroid/usecase/SearchTVShowUseCaseTest.kt @@ -0,0 +1,84 @@ +package com.intive.tmdbandroid.usecase + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagingData +import app.cash.turbine.test +import com.intive.tmdbandroid.common.MainCoroutineRule +import com.intive.tmdbandroid.model.Genre +import com.intive.tmdbandroid.model.TVShow +import com.intive.tmdbandroid.repository.CatalogRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.anyString +import org.mockito.Mockito.mock +import kotlin.time.ExperimentalTime + +@ExperimentalCoroutinesApi +class SearchTVShowUseCaseTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testTVShowPagingData = PagingData.from( + listOf( + TVShow( + backdrop_path = "BACKDROP_PATH", + first_air_date = "1983-10-20", + genres = listOf(Genre(1, "genre1"), Genre(2,"genre2")), + id = 1, + name = "Simona la Cacarisa", + original_name = "El cochiloco", + overview = "Simona la cacarisa, el cochiloco", + poster_path = "POSTER_PATH", + vote_average = 10.5, + vote_count = 100, + created_by = emptyList(), + last_air_date = "1990-09-25", + number_of_episodes = 5, + number_of_seasons = 2, + status = "Online" + ) + ) + ) + + private lateinit var searchUseCase: SearchUseCase + private lateinit var catalogRepository: CatalogRepository + + @Before + fun setup(){ + catalogRepository = mock(CatalogRepository::class.java) + searchUseCase = SearchUseCase(catalogRepository) + } + + @Test + @ExperimentalTime + fun invokeTest() { + mainCoroutineRule.runBlockingTest { + Mockito.`when`(catalogRepository.search(anyString())) + .thenReturn( + flow { + emit( + testTVShowPagingData + ) + } + ) + + val actual = searchUseCase("cristina kirchner") + actual.test { + Assert.assertEquals(awaitItem(), testTVShowPagingData) + awaitComplete() + } + Mockito.verify(catalogRepository, Mockito.only()).search("cristina kirchner") + + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/intive/tmdbandroid/viewmodel/HomeViewModelTest.kt b/app/src/test/java/com/intive/tmdbandroid/viewmodel/HomeViewModelTest.kt index ca9d135a..ec6c2438 100644 --- a/app/src/test/java/com/intive/tmdbandroid/viewmodel/HomeViewModelTest.kt +++ b/app/src/test/java/com/intive/tmdbandroid/viewmodel/HomeViewModelTest.kt @@ -9,6 +9,7 @@ import com.intive.tmdbandroid.common.State import com.intive.tmdbandroid.home.viewmodel.HomeViewModel import com.intive.tmdbandroid.model.Genre import com.intive.tmdbandroid.model.TVShow +import com.intive.tmdbandroid.usecase.GetAllItemsInWatchlistUseCase import com.intive.tmdbandroid.usecase.PaginatedPopularTVShowsUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flow @@ -56,6 +57,9 @@ class HomeViewModelTest { @Mock private lateinit var popularTVShowsUseCase: PaginatedPopularTVShowsUseCase + @Mock + private lateinit var getAllItemsInWatchlistUseCase: GetAllItemsInWatchlistUseCase + // Set the main coroutines dispatcher for unit testing. @ExperimentalCoroutinesApi @get:Rule @@ -67,7 +71,7 @@ class HomeViewModelTest { @Before fun setupViewModel() { - viewModel = HomeViewModel(popularTVShowsUseCase) + viewModel = HomeViewModel(popularTVShowsUseCase, getAllItemsInWatchlistUseCase) } @ExperimentalTime