diff --git a/app/src/main/java/com/example/movieapp/dagger/module/NetworkModule.kt b/app/src/main/java/com/example/movieapp/dagger/module/NetworkModule.kt index 5d93202..86cd839 100644 --- a/app/src/main/java/com/example/movieapp/dagger/module/NetworkModule.kt +++ b/app/src/main/java/com/example/movieapp/dagger/module/NetworkModule.kt @@ -44,7 +44,8 @@ class NetworkModule { .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .addInterceptor(interceptor) - .build() } + .build() + } @Provides @Reusable diff --git a/app/src/main/java/com/example/movieapp/model/network/MovieApi.kt b/app/src/main/java/com/example/movieapp/model/network/MovieApi.kt index ae4dcf0..56b8632 100644 --- a/app/src/main/java/com/example/movieapp/model/network/MovieApi.kt +++ b/app/src/main/java/com/example/movieapp/model/network/MovieApi.kt @@ -3,6 +3,7 @@ package com.example.movieapp.model.network import com.example.movieapp.model.network.data.movie.MovieInfo import com.example.movieapp.model.network.data.movie.Results import com.example.movieapp.model.network.data.movie.SmallMovie +import com.example.movieapp.model.network.data.search.GenreResponse import com.example.movieapp.utils.API_KEY import io.reactivex.rxjava3.core.Single import retrofit2.http.GET @@ -31,4 +32,10 @@ interface MovieApi { @GET("movie/{id}?api_key=$API_KEY&language=ru") fun getMovieByID(@Path("id") id: Int): Single + + @GET("genre/movie/list") + fun getGenres( + @Query("api_key") key: String = API_KEY, + @Query("language") language: String = "ru" + ): Single } \ No newline at end of file diff --git a/app/src/main/java/com/example/movieapp/model/network/NetworkSource.kt b/app/src/main/java/com/example/movieapp/model/network/NetworkSource.kt index 331486d..62934af 100644 --- a/app/src/main/java/com/example/movieapp/model/network/NetworkSource.kt +++ b/app/src/main/java/com/example/movieapp/model/network/NetworkSource.kt @@ -2,15 +2,24 @@ package com.example.movieapp.model.network import com.example.movieapp.model.network.data.movie.ListMovie import com.example.movieapp.model.network.data.movie.MovieInfo +import com.example.movieapp.model.network.data.movie.ParentListMovie import com.example.movieapp.model.network.data.movie.SmallMovieList +import com.example.movieapp.model.network.data.search.Genre +import com.example.movieapp.model.network.data.search.GenreResponse import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.functions.BiFunction import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject class MovieListSource @Inject constructor(private val api: MovieApi) { - fun fetchMovieList(category: String, key: String, language: String, page: Int): Single> { + fun fetchMovieList( + category: String, + key: String, + language: String, + page: Int + ): Single> { return api.getPropertyAsync(category, key, language, page) .map { it.networkMovie } .subscribeOn(Schedulers.io()) @@ -18,23 +27,55 @@ class MovieListSource @Inject constructor(private val api: MovieApi) { } } -class SmallMovieListSource @Inject constructor(private val api: MovieApi) { +class SmallMovieListSource @Inject constructor( + private val api: MovieApi +) { + + private val titleCategoryMap = hashMapOf( + 1 to ("Сейчас в кино" to "now_playing"), + 2 to ("Топ рейтинг" to "top_rated"), + 3 to ("Популярное" to "popular"), + 4 to ("Рекомендации" to "upcoming") + ) + fun fetchSmallMovieList( categories: List, - key: String, + language: String + ): Single> { + val moviesSource = fetchMovies(categories, language) + val genresSource = fetchGenres(language) + + return moviesSource.zipWith(genresSource, + BiFunction { movies: List>, genres: List -> + movies.mapIndexed { index, list -> + ParentListMovie( + titleCategoryMap[index]?.first ?: "", + titleCategoryMap[index]?.second ?: "", + list.withGenres(genres) + ) + } + }).observeOn(AndroidSchedulers.mainThread()) + } + + private fun fetchMovies( + categories: List, language: String ): Single>> { return Flowable.fromIterable(categories) .concatMap { category -> - api.getListOfPosters(category, key, language) + api.getListOfPosters(category = category, language = language) .toFlowable() .subscribeOn(Schedulers.io()) } .map { it.smallMovieList } .toList() - .observeOn(AndroidSchedulers.mainThread()) } + private fun fetchGenres(language: String): Single> { + return api.getGenres(language = language) + .map(GenreResponse::genres) + .subscribeOn(Schedulers.io()) + } } class MovieDetailSource @Inject constructor(private val api: MovieApi) { @@ -45,5 +86,12 @@ class MovieDetailSource @Inject constructor(private val api: MovieApi) { } } - - +fun List.withGenres(allGenres: List): List { + return map { item -> + item.apply { + genres = genreIds.map { id -> + allGenres.find { genre -> genre.id == id }?.name ?: "" + }.filterNot(String::isNullOrEmpty) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/movieapp/model/network/data/movie/SmallMovie.kt b/app/src/main/java/com/example/movieapp/model/network/data/movie/SmallMovie.kt index 31f65eb..662e11e 100644 --- a/app/src/main/java/com/example/movieapp/model/network/data/movie/SmallMovie.kt +++ b/app/src/main/java/com/example/movieapp/model/network/data/movie/SmallMovie.kt @@ -18,7 +18,9 @@ data class SmallMovieList constructor( @SerializedName("poster_path") val posterPath: String, @SerializedName("title") val title: String, @SerializedName("vote_average")val voteAverage: Float, - @SerializedName("backdrop_path") val backdropPath: String + @SerializedName("backdrop_path") val backdropPath: String, + @SerializedName("genre_ids") var genreIds: List = emptyList(), + var genres: List = emptyList() ):Parcelable{ - constructor(): this(0,"","", 0.0F, "") + constructor(): this(0,"","", 0.0F, "", emptyList(), emptyList()) } \ No newline at end of file diff --git a/app/src/main/java/com/example/movieapp/ui/home/overview/OverviewViewModel.kt b/app/src/main/java/com/example/movieapp/ui/home/overview/OverviewViewModel.kt index 1a299dc..c105369 100644 --- a/app/src/main/java/com/example/movieapp/ui/home/overview/OverviewViewModel.kt +++ b/app/src/main/java/com/example/movieapp/ui/home/overview/OverviewViewModel.kt @@ -11,8 +11,9 @@ import com.example.movieapp.model.network.data.movie.SmallMovieList import io.reactivex.rxjava3.disposables.CompositeDisposable import javax.inject.Inject -class OverviewViewModel @Inject constructor(private val networkSource: SmallMovieListSource) : - ViewModel() { +class OverviewViewModel @Inject constructor( + private val networkSource: SmallMovieListSource +) : ViewModel() { private var disposableBack = CompositeDisposable() private val categoryList = listOf("upcoming", "top_rated", "popular", "now_playing") @@ -50,32 +51,16 @@ class OverviewViewModel @Inject constructor(private val networkSource: SmallMovi private fun fetchMoviesLists() { Log.d("ViewModel", "load data") - val titleCategoryMap = hashMapOf( - 1 to ("Сейчас в кино" to "now_playing"), - 2 to ("Топ рейтинг" to "top_rated"), - 3 to ("Популярное" to "popular"), - 4 to ("Рекомендации" to "upcoming") - ) - - val dis = networkSource.fetchSmallMovieList(categoryList, "26f381d6ab8dd659b22d983cab9aa255", "ru") - .subscribe({ collectionList -> - val mListMovie = collectionList.mapIndexed { index, list -> - ParentListMovie( - titleCategoryMap[index]?.first ?: "", - titleCategoryMap[index]?.second ?: "", - list - ) - } + networkSource.fetchSmallMovieList(categoryList, "ru") + .subscribe({ parentMoviesList -> _eventNetworkError.value = false _isNetworkErrorShown.value = false - _parentListMovie.value = mListMovie + _parentListMovie.value = parentMoviesList }, { if (parentListMovie.value.isNullOrEmpty()) { _eventNetworkError.value = true } - } - ) - disposableBack.add(dis) + }).let(disposableBack::add) } fun displayPropertyDetails(movie: SmallMovieList) { diff --git a/app/src/main/java/com/example/movieapp/ui/search/SearchFragment.kt b/app/src/main/java/com/example/movieapp/ui/search/SearchFragment.kt index dd0ddb0..8926bfa 100644 --- a/app/src/main/java/com/example/movieapp/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/example/movieapp/ui/search/SearchFragment.kt @@ -5,23 +5,19 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager -import com.example.movieapp.dagger.App import com.example.movieapp.dagger.module.viewModule.ViewModelFactory import com.example.movieapp.databinding.SearchFragmentBinding import com.example.movieapp.model.network.data.search.SearchItem import com.example.movieapp.utils.adapters.SearchAdapter import com.example.movieapp.utils.injectViewModel -import com.jakewharton.rxbinding.widget.RxTextView +import com.example.movieapp.utils.toFlowable +import com.example.movieapp.utils.withParams import dagger.android.support.DaggerFragment +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.android.synthetic.main.search_fragment.* -import rx.Single -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import java.util.concurrent.TimeUnit import javax.inject.Inject class SearchFragment : DaggerFragment() { @@ -31,13 +27,13 @@ class SearchFragment : DaggerFragment() { lateinit var viewModel: SearchViewModel lateinit var binding: SearchFragmentBinding lateinit var adapter: SearchAdapter + private val disposables = CompositeDisposable() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - viewModel = injectViewModel(viewModelFactory) binding = SearchFragmentBinding.inflate(inflater) adapter = SearchAdapter(SearchAdapter.ClickListener { @@ -57,34 +53,28 @@ class SearchFragment : DaggerFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - RxTextView.textChanges(binding.edit) - .subscribeOn(AndroidSchedulers.mainThread()) - .filter { - it.length > 2 - } - .debounce(300, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { showProgress() } - .observeOn(Schedulers.io()) - .switchMap { - apiRequest(it).toObservable() - } + + binding.edit.toFlowable() + .withParams( + minLength = 2, + debounce = 500, + switchMapper = ::apiRequest, + doOnNext = ::showProgress + ) .map { resp -> - resp - .filterNot { it.name == null || it.name == "null" } + resp.filterNot { it.name.isNullOrEmpty() || it.name == "null" } } - .observeOn(AndroidSchedulers.mainThread()) .subscribe({ - Log.d("response", "result: $it") hideProgress() adapter.submitList(it) }, { Log.d("response", "error: $it") - }) + } + ).let(disposables::add) } - private fun apiRequest(chars: CharSequence): Single> { - return viewModel.getSearchResult(chars.toString()) + private fun apiRequest(chars: CharSequence): Flowable> { + return viewModel.getSearchResult(chars.toString()).toFlowable() } private fun showProgress() { @@ -96,4 +86,9 @@ class SearchFragment : DaggerFragment() { searchProgressBar.visibility = View.INVISIBLE search_rv.visibility = View.VISIBLE } + + override fun onDestroy() { + super.onDestroy() + disposables.clear() + } } diff --git a/app/src/main/java/com/example/movieapp/ui/search/SearchViewModel.kt b/app/src/main/java/com/example/movieapp/ui/search/SearchViewModel.kt index 41025c1..62e5879 100644 --- a/app/src/main/java/com/example/movieapp/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/example/movieapp/ui/search/SearchViewModel.kt @@ -2,11 +2,14 @@ package com.example.movieapp.ui.search import androidx.lifecycle.ViewModel import com.example.movieapp.model.network.SearchApi +import com.example.movieapp.model.network.data.movie.SmallMovieList import com.example.movieapp.model.network.data.search.Genre import com.example.movieapp.model.network.data.search.SearchItem import com.example.movieapp.model.network.data.search.SearchResponse +import com.google.gson.annotations.SerializedName +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers -import rx.Single import javax.inject.Inject class SearchViewModel @Inject constructor(private val api: SearchApi) : ViewModel() { @@ -14,30 +17,31 @@ class SearchViewModel @Inject constructor(private val api: SearchApi) : ViewMode // val genres: io.reactivex.rxjava3.core.Single> // get() = api.getGenres().map { it.genres }.cache().subscribeOn(Schedulers.io()) - fun getSearchResult(query: String): Single> { - return Single.create> { s -> + private lateinit var allGenres: List - api.getListOfPosters(query) - .map(SearchResponse::results) + init { + api.getGenres() + .map { it.genres } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ + allGenres = it + }, { + allGenres = emptyList() + }) + } -// .flattenAsFlowable { it.results } -// .flatMap { r -> -// Flowable.just(r.genreIds) -// .flatMap { gId -> -// genres.toFlowable().zipWith( -// Flowable.just(r), -// BiFunction, SearchItem, SearchItem> { t1, t2 -> -// t2.apply { genres = t1.filter { u -> gId.contains(u.id) }.map(Genre::name) } -// } -// ) -// } + fun getSearchResult(query: String): Single> { + return api.getListOfPosters(query) + .map(SearchResponse::results) +// .map { items -> +// items.map { item -> +// item.apply { +// genres = genreIds.map { id -> +// allGenres.find { genre -> genre.id == id }?.name ?: "" +// }.filterNot(String::isNullOrEmpty) +// } // } -// .toList() - .subscribe({ - s.onSuccess(it) - }, { - s.onError(it) - }) - } +// } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/movieapp/utils/Extensions.kt b/app/src/main/java/com/example/movieapp/utils/Extensions.kt index 39f2ceb..ff82352 100644 --- a/app/src/main/java/com/example/movieapp/utils/Extensions.kt +++ b/app/src/main/java/com/example/movieapp/utils/Extensions.kt @@ -1,9 +1,48 @@ package com.example.movieapp.utils +import android.widget.EditText +import androidx.core.widget.doAfterTextChanged +import androidx.core.widget.doOnTextChanged import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import java.util.concurrent.TimeUnit inline fun Fragment.injectViewModel(factory: ViewModelProvider.Factory): T { return ViewModelProvider(this, factory)[T::class.java] } + +fun EditText.toFlowable(): Flowable { + return Flowable.fromPublisher { subscriber -> + doAfterTextChanged { + subscriber.onNext(it) + } + } +} + +fun Flowable.withParams( + minLength: Int = 2, + debounce: Long = 300, + timeUnit: TimeUnit = TimeUnit.MILLISECONDS, + switchMapper: ((CharSequence) -> Flowable)? = null, + doOnNext: (() -> Unit)? = null +): Flowable { + return subscribeOn(AndroidSchedulers.mainThread()) + .filter { + it.length >= minLength + } + .debounce(debounce, timeUnit) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { doOnNext?.invoke() } + .observeOn(Schedulers.io()) + .switchMap { + switchMapper?.let { mapper -> return@switchMap mapper(it) } + return@switchMap Flowable.just(it) as Flowable + } + .observeOn(AndroidSchedulers.mainThread()) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/movieapp/utils/adapters/ChildAdapter.kt b/app/src/main/java/com/example/movieapp/utils/adapters/ChildAdapter.kt index a6aad66..5d9bdff 100644 --- a/app/src/main/java/com/example/movieapp/utils/adapters/ChildAdapter.kt +++ b/app/src/main/java/com/example/movieapp/utils/adapters/ChildAdapter.kt @@ -11,6 +11,7 @@ import com.example.movieapp.databinding.ItemBinding import com.example.movieapp.databinding.ItemCustomBinding import com.example.movieapp.model.network.data.movie.SmallMovieList import com.example.movieapp.ui.home.overview.OverviewFragmentDirections +import com.google.android.material.chip.Chip import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -88,6 +89,13 @@ class ChildAdapter(private val clickListener: MovieListener) : fun bind(item: SmallMovieList) { binding.movie = item + if (binding.genresChipGroup.childCount == 0) { + item.genres.first().let { genre -> + val chip = Chip(binding.root.context) + chip.text = genre + binding.genresChipGroup.addView(chip) + } + } binding.executePendingBindings() } diff --git a/app/src/main/java/com/example/movieapp/utils/adapters/SearchAdapter.kt b/app/src/main/java/com/example/movieapp/utils/adapters/SearchAdapter.kt index 9c20351..8d28f8b 100644 --- a/app/src/main/java/com/example/movieapp/utils/adapters/SearchAdapter.kt +++ b/app/src/main/java/com/example/movieapp/utils/adapters/SearchAdapter.kt @@ -7,6 +7,7 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.example.movieapp.databinding.SearchItemBinding import com.example.movieapp.model.network.data.search.SearchItem +import com.google.android.material.chip.Chip class SearchAdapter(private val onClickListener: ClickListener) : ListAdapter(SearchDiffUtil()) { @@ -22,18 +23,29 @@ class SearchAdapter(private val onClickListener: ClickListener) : } } + class ClickListener(val clickListener: (movie: SearchItem) -> Unit) { fun onClick(movie: SearchItem) = clickListener(movie) } } -class SearchViewHolder(private val binding: SearchItemBinding): RecyclerView.ViewHolder(binding.root) { +class SearchViewHolder(private val binding: SearchItemBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(searchItem: SearchItem) { binding.movie = searchItem + +// searchItem.genres.forEach { genre -> +// val chip = Chip(binding.root.context).apply { +// text = genre +// } +// binding.genresChipGroup.addView(chip) +// } +// binding.genres.text = searchItem.genres.joinToString(separator = " ") { it } } } -class SearchDiffUtil: DiffUtil.ItemCallback() { +class SearchDiffUtil : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SearchItem, newItem: SearchItem): Boolean { return oldItem.id == newItem.id } diff --git a/app/src/main/res/layout/item.xml b/app/src/main/res/layout/item.xml index 2377129..551920f 100644 --- a/app/src/main/res/layout/item.xml +++ b/app/src/main/res/layout/item.xml @@ -73,6 +73,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/name" /> + + diff --git a/app/src/main/res/layout/search_item.xml b/app/src/main/res/layout/search_item.xml index 50c3713..81fdaea 100644 --- a/app/src/main/res/layout/search_item.xml +++ b/app/src/main/res/layout/search_item.xml @@ -55,16 +55,25 @@ app:layout_constraintStart_toEndOf="@+id/cardView" app:layout_constraintTop_toTopOf="@+id/cardView" /> - + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@+id/name" + + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + app:layout_constraintTop_toBottomOf="@+id/genresChipGroup" /> diff --git a/google-services.json b/google-services.json new file mode 100644 index 0000000..335e661 --- /dev/null +++ b/google-services.json @@ -0,0 +1,40 @@ +{ + "project_info": { + "project_number": "328407397490", + "firebase_url": "https://movieapp-f1849.firebaseio.com", + "project_id": "movieapp-f1849", + "storage_bucket": "movieapp-f1849.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:328407397490:android:14c46be1a39fa997b03eeb", + "android_client_info": { + "package_name": "com.example.movieapp" + } + }, + "oauth_client": [ + { + "client_id": "328407397490-818v96c0okmeeg0ed5g7e63ba8i84evh.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyA3J_r4Bjo5qoJDDTw8NMRNK2PatmnvDKU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "328407397490-818v96c0okmeeg0ed5g7e63ba8i84evh.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file