Skip to content

Commit

Permalink
Collections: add movie collection screen
Browse files Browse the repository at this point in the history
  • Loading branch information
UweTrottmann committed Aug 2, 2024
1 parent 3cb4593 commit ff893d3
Show file tree
Hide file tree
Showing 7 changed files with 313 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
android:name=".movies.details.MovieDetailsActivity"
android:parentActivityName="com.battlelancer.seriesguide.ui.MoviesActivity" />
<activity android:name=".movies.similar.SimilarMoviesActivity" />
<activity android:name=".movies.collection.MovieCollectionActivity" />

<!-- Settings -->
<activity android:name=".preferences.MoreOptionsActivity" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MovieCollectionFragment>(
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)
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}

}
Original file line number Diff line number Diff line change
@@ -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<BaseMovie>) : MoviesListUiState()
data class Error(val message: String) : MoviesListUiState()
}

private val tmdb = SgApp.getServicesComponent(application).tmdb()

private val moviesRefreshTrigger = Channel<Unit>(capacity = Channel.CONFLATED)
val movies: Flow<MoviesListUiState> = 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<Int> {}

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)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CrewMember> = compareBy({ it.job }, { it.name })
Expand Down

0 comments on commit ff893d3

Please sign in to comment.