Skip to content

Commit

Permalink
Merge pull request #1040 from UweTrottmann/movie-collections
Browse files Browse the repository at this point in the history
Movie collections
  • Loading branch information
UweTrottmann committed Aug 2, 2024
2 parents b313271 + e883024 commit 6a0f178
Show file tree
Hide file tree
Showing 16 changed files with 456 additions and 43 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
@@ -1,7 +1,7 @@
// Copyright 2023 Uwe Trottmann
// SPDX-License-Identifier: Apache-2.0
// Copyright 2023-2024 Uwe Trottmann

package com.battlelancer.seriesguide.movies.similar
package com.battlelancer.seriesguide.movies.base

import android.content.Context
import android.view.ViewGroup
Expand All @@ -12,7 +12,10 @@ import com.battlelancer.seriesguide.movies.tools.MovieTools
import com.battlelancer.seriesguide.settings.TmdbSettings
import com.uwetrottmann.tmdb2.entities.BaseMovie

class SimilarMoviesAdapter(
/**
* Binds a list of [BaseMovie] to [MovieViewHolder].
*/
class BaseMovieListAdapter(
private val context: Context,
) : ListAdapter<BaseMovie, MovieViewHolder>(
MovieViewHolder.DIFF_CALLBACK_BASE_MOVIE
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2024 Uwe Trottmann

package com.battlelancer.seriesguide.movies.base

import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.battlelancer.seriesguide.R
import com.battlelancer.seriesguide.movies.search.MoviesSearchActivity

/**
* A [MenuProvider] that just adds a search action that opens [MoviesSearchActivity].
*/
class SearchMenuProvider(private val context: Context) : MenuProvider {

override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.add(0, MENU_ITEM_SEARCH_ID, 0, R.string.search).apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
setIcon(R.drawable.ic_search_white_24dp)
}
}

override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
MENU_ITEM_SEARCH_ID -> {
context.startActivity(MoviesSearchActivity.intentSearch(context))
true
}

else -> false
}
}

companion object {
private const val MENU_ITEM_SEARCH_ID = 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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.databinding.ActivityMovieCollectionBinding
import com.battlelancer.seriesguide.ui.BaseActivity
import com.battlelancer.seriesguide.util.ThemeUtils
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 = ActivityMovieCollectionBinding.inflate(layoutInflater)
setContentView(binding.root)
ThemeUtils.configureForEdgeToEdge(binding.root)
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)
}
}

}
Loading

0 comments on commit 6a0f178

Please sign in to comment.