add screen for movies/tv by keyword from details page

This commit is contained in:
Owen LeJeune
2023-06-23 21:15:21 -04:00
parent 90969247f9
commit 62324d8de7
15 changed files with 221 additions and 35 deletions

View File

@@ -29,4 +29,6 @@ interface DetailService {
suspend fun getAccountStates(id: Int)
suspend fun discover(keywords: String? = null, page: Int): Response<out SearchResult<out SearchResultMedia>>
}

View File

@@ -64,4 +64,7 @@ interface MoviesApi {
@GET("movie/{id}/account_states")
suspend fun getAccountStates(@Path("id") id: Int, @Query("session_id") sessionId: String): Response<AccountStates>
@GET("discover/movie")
suspend fun discover(@Query("with_keywords") keywords: String? = null, @Query("page") page: Int): Response<SearchResult<SearchResultMovie>>
}

View File

@@ -19,6 +19,8 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieReleaseResults
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Review
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMedia
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMovie
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Searchable
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.StatusResponse
@@ -53,6 +55,7 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
val releaseDates = Collections.synchronizedMap(mutableStateMapOf<Int, List<MovieReleaseResults.ReleaseDateResult>>())
val similar = Collections.synchronizedMap(mutableStateMapOf<Int, Flow<PagingData<TmdbItem>>>())
val accountStates = Collections.synchronizedMap(mutableStateMapOf<Int, AccountStates>())
val keywordResults = Collections.synchronizedMap(mutableStateMapOf<Int, Flow<PagingData<SearchResultMedia>>>())
override suspend fun getById(id: Int) {
@@ -134,6 +137,9 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
}
}
override suspend fun discover(keywords: String?, page: Int): Response<out SearchResult<out SearchResultMedia>> {
return movieService.discover(keywords, page)
}
override suspend fun getSimilar(id: Int, page: Int): Response<out HomePageResponse> {
return movieService.getSimilarMovies(id, page)

View File

@@ -67,4 +67,6 @@ interface TvApi {
@GET("tv/{id}/account_states")
suspend fun getAccountStates(@Path("id") id: Int): Response<AccountStates>
@GET("discover/tv")
suspend fun discover(@Query("page") page: Int, @Query("with_keywords") keywords: String? = null): Response<SearchResult<SearchResultTv>>
}

View File

@@ -21,6 +21,8 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KeywordsResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Review
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ReviewResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMedia
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.StatusResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
@@ -57,6 +59,7 @@ class TvService: KoinComponent, DetailService, HomePageService {
val contentRatings = Collections.synchronizedMap(mutableStateMapOf<Int, List<TvContentRatings.TvContentRating>>())
val similar = Collections.synchronizedMap(mutableStateMapOf<Int, Flow<PagingData<TmdbItem>>>())
val accountStates = Collections.synchronizedMap(mutableStateMapOf<Int, AccountStates>())
val keywordResults = Collections.synchronizedMap(mutableStateMapOf<Int, Flow<PagingData<SearchResultMedia>>>())
private val _seasons = Collections.synchronizedMap(mutableStateMapOf<Int, MutableSet<Season>>())
val seasons: MutableMap<Int, out Set<Season>>
@@ -137,6 +140,10 @@ class TvService: KoinComponent, DetailService, HomePageService {
}
}
override suspend fun discover(keywords: String?, page: Int): Response<out SearchResult<out SearchResultMedia>> {
return service.discover(page, keywords)
}
override suspend fun getSimilar(id: Int, page: Int): Response<out HomePageResponse> {
return service.getSimilarTvShows(id, page)
}

View File

@@ -0,0 +1,14 @@
package com.owenlejeune.tvtime.extensions
import android.os.Build
import android.os.Bundle
import java.io.Serializable
import kotlin.reflect.KClass
fun <T: Serializable> Bundle.safeGetSerializable(key: String, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getSerializable(key, clazz)
} else {
getSerializable(key) as? T
}
}

View File

@@ -130,7 +130,7 @@ fun ActionButton(
.clip(CircleShape)
.height(40.dp)
.requiredWidthIn(min = 40.dp)
.background(color = MaterialTheme.colorScheme.actionButtonColor)
.background(color = MaterialTheme.colorScheme.tertiary)
.clickable(onClick = onClick)
) {
Icon(

View File

@@ -222,6 +222,8 @@ fun SearchView(
val homeScreenViewModel = viewModel<HomeScreenViewModel>()
homeScreenViewModel.fab.value = @Composable {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.tertiary,
onClick = {
appNavController.navigate(route)
}
@@ -279,7 +281,8 @@ fun MinLinesText(
class ChipInfo(
val text: String,
val enabled: Boolean = true
val enabled: Boolean = true,
val id: Int? = null
)
sealed class ChipStyle(val mainAxisSpacing: Dp, val crossAxisSpacing: Dp) {
@@ -329,11 +332,10 @@ object ChipDefaults {
@Composable
fun BoxyChip(
text: String,
chipInfo: ChipInfo,
style: TextStyle = MaterialTheme.typography.bodySmall,
isSelected: Boolean = true,
onSelectionChanged: (String) -> Unit = {},
enabled: Boolean = true,
onSelectionChanged: (ChipInfo) -> Unit = {},
colors: ChipColors = ChipDefaults.boxyChipColors()
) {
Surface(
@@ -346,13 +348,13 @@ fun BoxyChip(
.toggleable(
value = isSelected,
onValueChange = {
onSelectionChanged(text)
onSelectionChanged(chipInfo)
},
enabled = enabled
enabled = chipInfo.enabled
)
) {
Text(
text = text,
text = chipInfo.text,
style = style,
color = if (isSelected) colors.selectedContentColor() else colors.unselectedContentColor(),
modifier = Modifier.padding(8.dp)
@@ -363,11 +365,10 @@ fun BoxyChip(
@Composable
fun RoundedChip(
text: String,
chipInfo: ChipInfo,
style: TextStyle = MaterialTheme.typography.bodySmall,
isSelected: Boolean = false,
onSelectionChanged: (String) -> Unit = {},
enabled: Boolean = true,
onSelectionChanged: (ChipInfo) -> Unit = {},
colors: ChipColors = ChipDefaults.roundedChipColors()
) {
val borderColor = if (isSelected) colors.selectedContainerColor() else colors.unselectedContainerColor()
@@ -382,14 +383,14 @@ fun RoundedChip(
.toggleable(
value = isSelected,
onValueChange = {
onSelectionChanged(text)
onSelectionChanged(chipInfo)
},
enabled = enabled
enabled = chipInfo.enabled
)
.padding(8.dp)
) {
Text(
text = text,
text = chipInfo.text,
style = style,
color = if (isSelected) colors.selectedContentColor() else colors.unselectedContentColor()
)
@@ -401,7 +402,7 @@ fun RoundedChip(
fun ChipGroup(
modifier: Modifier = Modifier,
chips: List<ChipInfo> = emptyList(),
onSelectedChanged: (String) -> Unit = {},
onSelectedChanged: (ChipInfo) -> Unit = {},
chipStyle: ChipStyle = ChipStyle.Boxy,
roundedChipColors: ChipColors = ChipDefaults.roundedChipColors(),
boxyChipColors: ChipColors = ChipDefaults.boxyChipColors()
@@ -412,17 +413,15 @@ fun ChipGroup(
when (chipStyle) {
ChipStyle.Boxy -> {
BoxyChip(
text = chip.text,
chipInfo = chip,
onSelectionChanged = onSelectedChanged,
enabled = chip.enabled,
colors = boxyChipColors
)
}
ChipStyle.Rounded -> {
RoundedChip(
text = chip.text,
chipInfo = chip,
onSelectionChanged = onSelectedChanged,
enabled = chip.enabled,
colors = roundedChipColors
)
}
@@ -453,6 +452,7 @@ fun RatingRing(
size: Dp = 60.dp,
ringStrokeWidth: Dp = 4.dp,
ringColor: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
textColor: Color = Color.White,
textSize: TextUnit = 14.sp
) {
@@ -464,7 +464,9 @@ fun RatingRing(
modifier = Modifier.fillMaxSize(),
progress = progress,
strokeWidth = ringStrokeWidth,
color = ringColor
color = ringColor,
trackColor = trackColor,
strokeCap = StrokeCap.Round
)
Text(
@@ -688,7 +690,7 @@ fun UserInitials(
modifier = Modifier
.clip(CircleShape)
.size(size)
.background(color = MaterialTheme.colorScheme.secondary)
.background(color = MaterialTheme.colorScheme.tertiary)
) {
Text(
modifier = Modifier.align(Alignment.Center),

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.ui.navigation
import android.os.Build
import android.util.Log
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
@@ -15,10 +16,12 @@ import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.extensions.WindowSizeClass
import com.owenlejeune.tvtime.extensions.safeGetSerializable
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.screens.AboutScreen
import com.owenlejeune.tvtime.ui.screens.AccountScreen
import com.owenlejeune.tvtime.ui.screens.HomeScreen
import com.owenlejeune.tvtime.ui.screens.KeywordResultsScreen
import com.owenlejeune.tvtime.ui.screens.ListDetailScreen
import com.owenlejeune.tvtime.ui.screens.MediaDetailScreen
import com.owenlejeune.tvtime.ui.screens.PersonDetailScreen
@@ -116,7 +119,7 @@ fun AppNavigationHost(
)
} else {
Pair(
arguments.getSerializable(NavConstants.SEARCH_ID_KEY) as MediaViewType,
arguments.safeGetSerializable(NavConstants.SEARCH_ID_KEY, MediaViewType::class.java)!!,
arguments.getString(NavConstants.SEARCH_TITLE_KEY) ?: ""
)
}
@@ -154,6 +157,25 @@ fun AppNavigationHost(
composable(route = AppNavItem.AboutView.route) {
AboutScreen(appNavController = appNavController)
}
composable(
route = AppNavItem.KeywordsView.route.plus("/{${NavConstants.KEYWORD_TYPE_KEY}}?keyword={${NavConstants.KEYWORD_NAME_KEY}}&keywordId={${NavConstants.KEYWORD_ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.KEYWORD_TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.KEYWORD_NAME_KEY) { type = NavType.StringType },
navArgument(NavConstants.KEYWORD_ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.KEYWORD_TYPE_KEY, MediaViewType::class.java)!!
val keywords = navBackStackEntry.arguments?.getString(NavConstants.KEYWORD_NAME_KEY) ?: ""
val id = navBackStackEntry.arguments?.getInt(NavConstants.KEYWORD_ID_KEY)!!
KeywordResultsScreen(
type = type,
keyword = keywords,
id = id,
appNavController = appNavController
)
}
}
}
@@ -178,5 +200,8 @@ sealed class AppNavItem(val route: String) {
}
object AccountView: AppNavItem("account_route")
object AboutView: AppNavItem("about_route")
object KeywordsView: AppNavItem("keywords_route") {
fun withArgs(type: MediaViewType, keyword: String, id: Int) = route.plus("/$type?keyword=$keyword&keywordId=$id")
}
}

View File

@@ -1,5 +1,7 @@
package com.owenlejeune.tvtime.ui.navigation
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Face
import androidx.compose.material.icons.outlined.Movie
@@ -30,7 +32,12 @@ fun HomeScreenNavHost(
) {
val homeScreenViewModel = viewModel<HomeScreenViewModel>()
NavHost(navController = navController, startDestination = startDestination) {
NavHost(
navController = navController,
startDestination = startDestination,
enterTransition = { fadeIn() },
exitTransition = { fadeOut() }
) {
composable(HomeScreenNavItem.Movies.route) {
homeScreenViewModel.appBarActions.value = {}
MediaTab(

View File

@@ -0,0 +1,91 @@
package com.owenlejeune.tvtime.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.paging.compose.collectAsLazyPagingItems
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.extensions.lazyPagingItems
import com.owenlejeune.tvtime.ui.components.MediaResultCard
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeywordResultsScreen(
type: MediaViewType,
keyword: String,
id: Int,
appNavController: NavController
) {
val mainViewModel = viewModel<MainViewModel>()
LaunchedEffect(Unit) {
mainViewModel.getKeywordResults(id, keyword, type)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = keyword.capitalize(Locale.current)) },
navigationIcon = {
IconButton(
onClick = { appNavController.popBackStack() }
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = null
)
}
}
)
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
val keywordResultsMap = remember { mainViewModel.produceKeywordsResultsFor(type) }
val keywordResults = keywordResultsMap[id]
val pagingResults = keywordResults?.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier.padding(all = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
pagingResults?.let {
lazyPagingItems(it) { result ->
result?.let {
MediaResultCard(
appNavController = appNavController,
mediaViewType = type,
id = result.id,
backdropPath = TmdbUtils.getFullBackdropPath(result.backdropPath),
posterPath = TmdbUtils.getFullPosterPath(result.posterPath),
title = result.name,
additionalDetails = listOf(result.overview)
)
}
}
}
}
}
}
}

View File

@@ -385,7 +385,7 @@ private fun MainContent(
windowSize: WindowSizeClass,
mainViewModel: MainViewModel
) {
OverviewCard(itemId = itemId, mediaItem = mediaItem, type = type, mainViewModel = mainViewModel)
OverviewCard(itemId = itemId, mediaItem = mediaItem, type = type, mainViewModel = mainViewModel, appNavController = appNavController)
CastCard(itemId = itemId, appNavController = appNavController, type = type, mainViewModel = mainViewModel)
@@ -500,11 +500,12 @@ private fun MiscDetails(
@Composable
private fun OverviewCard(
modifier: Modifier = Modifier,
itemId: Int,
mediaItem: DetailedItem?,
type: MediaViewType,
mainViewModel: MainViewModel
mainViewModel: MainViewModel,
appNavController: NavController,
modifier: Modifier = Modifier
) {
LaunchedEffect(Unit) {
mainViewModel.getKeywords(itemId, type)
@@ -530,7 +531,8 @@ private fun OverviewCard(
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = tagline,
color = MaterialTheme.colorScheme.primary,
color = MaterialTheme.colorScheme.tertiary
,
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
)
@@ -545,7 +547,7 @@ private fun OverviewCard(
keywords?.let { keywords ->
val keywordsChipInfo = keywords.map { ChipInfo(it.name, false) }
val keywordsChipInfo = keywords.map { ChipInfo(it.name, true, it.id) }
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(ChipStyle.Rounded.mainAxisSpacing)
@@ -553,16 +555,13 @@ private fun OverviewCard(
Spacer(modifier = Modifier.width(8.dp))
keywordsChipInfo.forEach { keywordChipInfo ->
RoundedChip(
text = keywordChipInfo.text,
enabled = keywordChipInfo.enabled,
chipInfo = keywordChipInfo,
colors = ChipDefaults.roundedChipColors(
unselectedContainerColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.primary
),
onSelectionChanged = { chip ->
// if (service is MoviesService) {
// // Toast.makeText(context, chip, Toast.LENGTH_SHORT).show()
// }
appNavController.navigate(AppNavItem.KeywordsView.withArgs(type, chip.text, chip.id!!))
}
)
}

View File

@@ -61,7 +61,7 @@ fun MediaTabContent(
mediaTabItem: MediaTabNavItem
) {
val viewModel = viewModel<MainViewModel>()
val mediaListItems = viewModel.produceFlowFor(mediaType, mediaTabItem.type).collectAsLazyPagingItems()
val mediaListItems = viewModel.produceMediaTabFlowFor(mediaType, mediaTabItem.type).collectAsLazyPagingItems()
PagingPosterGrid(
lazyPagingItems = mediaListItems,

View File

@@ -15,6 +15,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Keyword
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Review
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMedia
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders
@@ -43,6 +44,7 @@ class MainViewModel: ViewModel(), KoinComponent {
val movieReleaseDates = movieService.releaseDates
val similarMovies = movieService.similar
val movieAccountStates = movieService.accountStates
val movieKeywordResults = movieService.keywordResults
val popularMovies = createPagingFlow(
fetcher = { p -> movieService.getPopular(p) },
@@ -74,6 +76,7 @@ class MainViewModel: ViewModel(), KoinComponent {
val tvSeasons = tvService.seasons
val similarTv = tvService.similar
val tvAccountStates = tvService.accountStates
val tvKeywordResults = tvService.keywordResults
val popularTv = createPagingFlow(
fetcher = { p -> tvService.getPopular(p) },
@@ -155,7 +158,7 @@ class MainViewModel: ViewModel(), KoinComponent {
return providesForType(type, { movieAccountStates }, { tvAccountStates} )
}
fun produceFlowFor(mediaType: MediaViewType, contentType: MediaTabNavItem.Type): Flow<PagingData<TmdbItem>> {
fun produceMediaTabFlowFor(mediaType: MediaViewType, contentType: MediaTabNavItem.Type): Flow<PagingData<TmdbItem>> {
return providesForType(
mediaType,
{
@@ -177,6 +180,10 @@ class MainViewModel: ViewModel(), KoinComponent {
)
}
fun produceKeywordsResultsFor(mediaType: MediaViewType): Map<Int, Flow<PagingData<SearchResultMedia>>> {
return providesForType(mediaType, { movieKeywordResults }, { tvKeywordResults })
}
suspend fun getById(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getById(id)
@@ -287,6 +294,24 @@ class MainViewModel: ViewModel(), KoinComponent {
}
}
fun getKeywordResults(id: Int, keyword: String, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> {
movieKeywordResults[id] = createPagingFlow(
fetcher = { p -> movieService.discover(keyword, p) },
processor = { it.results }
)
}
MediaViewType.TV -> {
tvKeywordResults[id] = createPagingFlow(
fetcher = { p -> tvService.discover(keyword, p) },
processor = { it.results }
)
}
else -> {}
}
}
suspend fun getReleaseDates(id: Int) {
movieService.getReleaseDates(id)
}

View File

@@ -9,4 +9,7 @@ object NavConstants {
const val ACCOUNT_KEY = "account_key"
const val AUTH_REDIRECT_PAGE = "return"
const val WEB_LINK_KEY = "web_link_key"
const val KEYWORD_TYPE_KEY = "keyword_type_key"
const val KEYWORD_NAME_KEY = "keyword_name_key"
const val KEYWORD_ID_KEY = "keyword_id_key"
}