From ff893d3736c8ed1db5ce279c28f5914851b408a1 Mon Sep 17 00:00:00 2001 From: Uwe Trottmann Date: Thu, 1 Aug 2024 11:24:24 +0200 Subject: [PATCH] Collections: add movie collection screen --- CHANGELOG.md | 1 + app/src/main/AndroidManifest.xml | 1 + .../collection/MovieCollectionActivity.kt | 62 +++++++++ .../collection/MovieCollectionFragment.kt | 124 ++++++++++++++++++ .../collection/MovieCollectionViewModel.kt | 100 ++++++++++++++ .../movies/details/MovieDetailsFragment.kt | 16 ++- .../seriesguide/tmdbapi/TmdbTools2.kt | 11 ++ 7 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionActivity.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionFragment.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2803ece28c..f00010a99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Version 2024.4 -------------- *in development* +* 🌟 Movies: display the collection (like a movie series) a movie is in, view the collection. * 📝 Latest user interface translations from Crowdin. Version 2024.3 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67242fbb6b..798b53ddeb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -177,6 +177,7 @@ android:name=".movies.details.MovieDetailsActivity" android:parentActivityName="com.battlelancer.seriesguide.ui.MoviesActivity" /> + diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionActivity.kt new file mode 100644 index 0000000000..228a57a5c1 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionActivity.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Uwe Trottmann + +package com.battlelancer.seriesguide.movies.collection + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.replace +import com.battlelancer.seriesguide.R +import com.battlelancer.seriesguide.ui.BaseActivity +import com.battlelancer.seriesguide.ui.SinglePaneActivity +import com.battlelancer.seriesguide.util.commitReorderingAllowed + +/** + * Hosts a [MovieCollectionFragment], contains a large top app bar that lifts on scroll, + * displays close navigation indicator. + */ +class MovieCollectionActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = SinglePaneActivity.onCreateFor(this) + binding.sgAppBarLayout.sgAppBarLayout.liftOnScrollTargetViewId = + MovieCollectionFragment.liftOnScrollTargetViewId + setupActionBar() + + val title = intent.getStringExtra(EXTRA_TITLE) + ?: getString(R.string.title_similar_movies) + setTitle(title) + + val collectionId = intent.getIntExtra(EXTRA_COLLECTION_ID, 0) + if (collectionId <= 0) { + throw IllegalArgumentException("EXTRA_COLLECTION_ID must be positive, but was $collectionId") + } + + if (savedInstanceState == null) { + supportFragmentManager.commitReorderingAllowed { + replace( + R.id.content_frame, + args = MovieCollectionFragment.buildArgs(collectionId) + ) + } + } + } + + override fun setupActionBar() { + super.setupActionBar() + supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_clear_24dp) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + companion object { + private const val EXTRA_COLLECTION_ID = "COLLECTION_ID" + private const val EXTRA_TITLE = "ARG_TITLE" + + fun intent(context: Context, collectionId: Int, title: String): Intent = + Intent(context, MovieCollectionActivity::class.java) + .putExtra(EXTRA_COLLECTION_ID, collectionId) + .putExtra(EXTRA_TITLE, title) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionFragment.kt new file mode 100644 index 0000000000..d3bb8deb15 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionFragment.kt @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Uwe Trottmann + +package com.battlelancer.seriesguide.movies.collection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import com.battlelancer.seriesguide.R +import com.battlelancer.seriesguide.databinding.FragmentShowsSimilarBinding +import com.battlelancer.seriesguide.movies.base.BaseMovieListAdapter +import com.battlelancer.seriesguide.movies.base.SearchMenuProvider +import com.battlelancer.seriesguide.movies.collection.MovieCollectionViewModel.MoviesListUiState +import com.battlelancer.seriesguide.ui.AutoGridLayoutManager +import com.battlelancer.seriesguide.util.ThemeUtils +import com.battlelancer.seriesguide.util.ViewTools +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Displays movies of a TMDB movie collection. + * + * Re-uses [FragmentShowsSimilarBinding] layout. + */ +class MovieCollectionFragment : Fragment() { + + private val viewModel: MovieCollectionViewModel by viewModels( + extrasProducer = { + MovieCollectionViewModel.creationExtras( + defaultViewModelCreationExtras, + requireArguments().getInt(ARG_COLLECTION_ID) + ) + }, + factoryProducer = { MovieCollectionViewModel.Factory } + ) + private var binding: FragmentShowsSimilarBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return FragmentShowsSimilarBinding.inflate(layoutInflater, container, false) + .also { binding = it } + .root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = binding!! + ThemeUtils.applyBottomPaddingForNavigationBar(binding.recyclerViewShowsSimilar) + ThemeUtils.applyBottomMarginForNavigationBar(binding.textViewPoweredByShowsSimilar) + + binding.swipeRefreshLayoutShowsSimilar.apply { + ViewTools.setSwipeRefreshLayoutColors(requireActivity().theme, this) + setOnRefreshListener { viewModel.refresh() } + } + binding.emptyViewShowsSimilar.setButtonClickListener { + binding.swipeRefreshLayoutShowsSimilar.isRefreshing = true + viewModel.refresh() + } + + binding.swipeRefreshLayoutShowsSimilar.isRefreshing = true + binding.emptyViewShowsSimilar.isGone = true + + val adapter = BaseMovieListAdapter(requireContext()) + binding.recyclerViewShowsSimilar.also { + it.setHasFixedSize(true) + it.layoutManager = + AutoGridLayoutManager(it.context, R.dimen.movie_grid_columnWidth, 1, 1) + it.adapter = adapter + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.movies.collectLatest { + binding.swipeRefreshLayoutShowsSimilar.isRefreshing = false + when (it) { + is MoviesListUiState.Error -> { + binding.recyclerViewShowsSimilar.isGone = true + binding.emptyViewShowsSimilar.apply { + isVisible = true + setMessage(it.message) + } + } + + is MoviesListUiState.Success -> { + // Note: no need to handle empty state, collection should never be empty + adapter.submitList(it.movies) + binding.recyclerViewShowsSimilar.isVisible = true + binding.emptyViewShowsSimilar.isGone = true + } + } + } + } + + requireActivity().addMenuProvider( + SearchMenuProvider(requireContext()), + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + companion object { + val liftOnScrollTargetViewId = R.id.recyclerViewShowsSimilar + + private const val ARG_COLLECTION_ID = "COLLECTION_ID" + + fun buildArgs(collectionId: Int): Bundle = bundleOf( + ARG_COLLECTION_ID to collectionId + ) + } + +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionViewModel.kt new file mode 100644 index 0000000000..0ec29d8b29 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/collection/MovieCollectionViewModel.kt @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Uwe Trottmann + +package com.battlelancer.seriesguide.movies.collection + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.battlelancer.seriesguide.R +import com.battlelancer.seriesguide.SgApp +import com.battlelancer.seriesguide.movies.MoviesSettings +import com.battlelancer.seriesguide.tmdbapi.TmdbTools2 +import com.uwetrottmann.androidutils.AndroidUtils +import com.uwetrottmann.tmdb2.entities.BaseMovie +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.shareIn + +/** + * Provides a movie collection [Flow] that can be [refresh]ed. + */ +class MovieCollectionViewModel( + application: Application, + collectionId: Int +) : AndroidViewModel(application) { + + sealed class MoviesListUiState { + data class Success(val movies: List) : MoviesListUiState() + data class Error(val message: String) : MoviesListUiState() + } + + private val tmdb = SgApp.getServicesComponent(application).tmdb() + + private val moviesRefreshTrigger = Channel(capacity = Channel.CONFLATED) + val movies: Flow = flow { + for (trigger in moviesRefreshTrigger) { + val collection = TmdbTools2().getMovieCollection( + tmdb, + collectionId, + MoviesSettings.getMoviesLanguage(application) + ) + if (collection == null) { + val message = if (AndroidUtils.isNetworkConnected(application)) { + application.getString( + R.string.api_error_generic, + application.getString(R.string.tmdb) + ) + } else { + application.getString(R.string.offline) + } + emit(MoviesListUiState.Error(message)) + } else { + val parts = collection.parts + parts?.sortBy { it.release_date } + emit(MoviesListUiState.Success(parts ?: emptyList())) + } + } + }.flowOn(Dispatchers.IO) + .shareIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + replay = 1 + ) + + init { + refresh() + } + + fun refresh() { + moviesRefreshTrigger.trySend(Unit) + } + + companion object { + private val KEY_COLLECTION_ID = object : CreationExtras.Key {} + + val Factory = viewModelFactory { + initializer { + MovieCollectionViewModel( + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]!!, + this[KEY_COLLECTION_ID]!! + ) + } + } + + fun creationExtras(defaultExtras: CreationExtras, collectionId: Int) = + MutableCreationExtras(defaultExtras).apply { + set(KEY_COLLECTION_ID, collectionId) + } + } + +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt index bb8bc19bb3..857f34891b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt @@ -41,6 +41,7 @@ import com.battlelancer.seriesguide.extensions.MovieActionsContract import com.battlelancer.seriesguide.movies.MovieLoader import com.battlelancer.seriesguide.movies.MovieLocalizationDialogFragment import com.battlelancer.seriesguide.movies.MoviesSettings +import com.battlelancer.seriesguide.movies.collection.MovieCollectionActivity import com.battlelancer.seriesguide.movies.similar.SimilarMoviesActivity import com.battlelancer.seriesguide.movies.tools.MovieTools import com.battlelancer.seriesguide.people.PeopleListHelper @@ -476,8 +477,19 @@ class MovieDetailsFragment : Fragment(), MovieActionsContract { // Show collection button if movies is part of one binding.containerMovieButtons.buttonMovieCollection.apply { val collection = tmdbMovie.belongs_to_collection - if (collection != null) { - text = collection.name + val collectionId = collection?.id + val collectionName = collection?.name + if (collectionId != null && collectionName != null) { + setOnClickListener { + startActivity( + MovieCollectionActivity.intent( + requireContext(), + collectionId, + collectionName + ) + ) + } + text = collectionName isVisible = true } else { isGone = true diff --git a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt index cee229acea..5ca8e35d23 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt @@ -19,6 +19,7 @@ import com.github.michaelbull.result.runCatching import com.uwetrottmann.tmdb2.DiscoverTvBuilder import com.uwetrottmann.tmdb2.Tmdb import com.uwetrottmann.tmdb2.entities.AppendToResponse +import com.uwetrottmann.tmdb2.entities.Collection import com.uwetrottmann.tmdb2.entities.Credits import com.uwetrottmann.tmdb2.entities.CrewMember import com.uwetrottmann.tmdb2.entities.DiscoverFilter @@ -542,6 +543,16 @@ class TmdbTools2 { ?.imdb_id } + suspend fun getMovieCollection( + tmdb: Tmdb, + collectionId: Int, + languageCode: String? + ): Collection? { + return tmdb.collectionService() + .summary(collectionId, languageCode) + .awaitResponse("movie collection") + } + companion object { // In UI currently not grouping by department, so for easier scanning only sort by job private val crewComparator: Comparator = compareBy({ it.job }, { it.name })