create basic season details screen

This commit is contained in:
Owen LeJeune
2023-07-19 18:45:33 -04:00
parent c51d7de5ba
commit dcb24561ec
10 changed files with 512 additions and 259 deletions

View File

@@ -56,9 +56,6 @@ interface TvApi {
@Query("session_id") sessionId: String @Query("session_id") sessionId: String
): Response<StatusResponse> ): Response<StatusResponse>
@GET("tv/{id}/season/{season}")
suspend fun getSeason(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<Season>
@GET("tv/{id}/watch/providers") @GET("tv/{id}/watch/providers")
suspend fun getWatchProviders(@Path("id") seriesId: Int): Response<WatchProviderResponse> suspend fun getWatchProviders(@Path("id") seriesId: Int): Response<WatchProviderResponse>
@@ -73,4 +70,7 @@ interface TvApi {
@GET("trending/tv/{time_window}") @GET("trending/tv/{time_window}")
suspend fun trending(@Path("time_window") timeWindow: String, @Query("page") page: Int): Response<SearchResult<SearchResultTv>> suspend fun trending(@Path("time_window") timeWindow: String, @Query("page") page: Int): Response<SearchResult<SearchResultTv>>
@GET("tv/{id}/season/{season}")
suspend fun getSeason(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<Season>
} }

View File

@@ -11,3 +11,5 @@ fun Any.coroutineTask(runnable: suspend () -> Unit) {
fun <T> anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate) fun <T> anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate)
fun <T: Any> T.isIn(vararg items: T): Boolean = items.any { it == this } fun <T: Any> T.isIn(vararg items: T): Boolean = items.any { it == this }
fun <T> pairOf(a: T, b: T) = Pair(a, b)

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.pow
fun Float.dpToPx(context: Context): Float { fun Float.dpToPx(context: Context): Float {
return this * context.resources.displayMetrics.density return this * context.resources.displayMetrics.density
@@ -12,3 +13,19 @@ fun Float.dpToPx(context: Context): Float {
@Composable @Composable
fun Int.toDp(): Dp = (this / LocalContext.current.resources.displayMetrics.density).toInt().dp fun Int.toDp(): Dp = (this / LocalContext.current.resources.displayMetrics.density).toInt().dp
fun Int.combineWith(other: Int): Int {
val max = maxOf(this, other)
val min = minOf(this, other)
return 2f.pow(min).toInt() * ((2 * max) + 1)
}
fun Int.toCompositeParts(): Pair<Int, Int> {
var a = 0
var result = this
do {
result /= 2
a++
} while (result % 2 == 0)
return pairOf(a, result/2)
}

View File

@@ -5,9 +5,12 @@ import android.net.Uri
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -16,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -24,12 +28,14 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.CachePolicy import coil.request.CachePolicy
@@ -40,10 +46,16 @@ import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.LoadingState import com.owenlejeune.tvtime.api.LoadingState
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCastMember
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCrewMember
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Person
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCastMember
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCrewMember
import com.owenlejeune.tvtime.extensions.shimmerBackground import com.owenlejeune.tvtime.extensions.shimmerBackground
import com.owenlejeune.tvtime.extensions.toDp import com.owenlejeune.tvtime.extensions.toDp
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
@@ -58,6 +70,7 @@ fun DetailHeader(
imageCollection: ImageCollection? = null, imageCollection: ImageCollection? = null,
backdropUrl: String? = null, backdropUrl: String? = null,
posterUrl: String? = null, posterUrl: String? = null,
expandedPosterAsBackdrop: Boolean = false,
backdropContentDescription: String? = null, backdropContentDescription: String? = null,
posterContentDescription: String? = null, posterContentDescription: String? = null,
rating: Float? = null, rating: Float? = null,
@@ -71,20 +84,48 @@ fun DetailHeader(
.wrapContentHeight() .wrapContentHeight()
) )
) { ) {
if (imageCollection != null) { if (expandedPosterAsBackdrop) {
BackdropGallery( Box {
modifier = Modifier val url = TmdbUtils.getFullPosterPath(posterUrl)
.clickable { val model = ImageRequest.Builder(LocalContext.current)
showGalleryOverlay?.value = true .data(url)
}, .diskCacheKey(url ?: "")
imageCollection = imageCollection, .networkCachePolicy(CachePolicy.ENABLED)
state = pagerState .memoryCachePolicy(CachePolicy.ENABLED)
) .build()
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.blur(radius = 10.dp)
.fillMaxWidth()
.aspectRatio(1.778f)
)
Box(
modifier = Modifier
.matchParentSize()
.background(Color.Black.copy(alpha = 0.6f))
.blur(radius = 5.dp)
)
}
} else { } else {
Backdrop( if (imageCollection != null) {
imageUrl = backdropUrl, BackdropGallery(
contentDescription = backdropContentDescription modifier = Modifier
) .clickable {
showGalleryOverlay?.value = true
},
imageCollection = imageCollection,
state = pagerState
)
} else {
Backdrop(
imageUrl = backdropUrl,
contentDescription = backdropContentDescription
)
}
} }
Row( Row(
@@ -362,3 +403,118 @@ fun AdditionalDetailItem(
} }
} }
} }
@Composable
fun EpisodeItem(
episode: Episode,
elevation: Dp = 10.dp,
maxDescriptionLines: Int = 2
) {
Card(
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = elevation),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier
.fillMaxWidth()
.clickable {
}
) {
Box {
episode.stillPath?.let {
// Cloudy(
// modifier = Modifier.background(Color.Black.copy(alpha = 0.4f))
// ) {
val url = TmdbUtils.getFullEpisodeStillPath(it)
val model = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCacheKey(url ?: "")
.networkCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.blur(radius = 10.dp)
// .fillMaxWidth()
// .wrapContentHeight()
.matchParentSize()
)
Box(
modifier = Modifier
// .fillMaxSize()
.matchParentSize()
.background(Color.Black.copy(alpha = 0.4f))
.blur(radius = 5.dp)
)
// }
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(12.dp)
) {
val textColor = episode.stillPath?.let { Color.White } ?: if (isSystemInDarkTheme()) Color.White else Color.Black
Text(
text = "S${episode.seasonNumber}E${episode.episodeNumber}${episode.name}",
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
TmdbUtils.convertEpisodeDate(episode.airDate)?.let {
Text(
text = it,
fontStyle = FontStyle.Italic,
fontSize = 12.sp,
color = textColor
)
}
Text(
text = episode.overview,
overflow = TextOverflow.Ellipsis,
color = textColor,
maxLines = maxDescriptionLines
)
}
}
}
}
@Composable
fun CastCrewCard(
appNavController: NavController,
person: Person
) {
TwoLineImageTextCard(
title = person.name,
modifier = Modifier
.width(124.dp)
.wrapContentHeight(),
subtitle = when (person) {
is MovieCastMember -> person.character
is MovieCrewMember -> person.job
is TvCastMember -> {
val roles = person.roles.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
is TvCrewMember -> {
val roles = person.jobs.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
else -> null
},
imageUrl = TmdbUtils.getFullPersonImagePath(person),
titleTextColor = MaterialTheme.colorScheme.onPrimary,
subtitleTextColor = MaterialTheme.colorScheme.onSecondary,
onItemClicked = {
appNavController.navigate(
AppNavItem.DetailView.withArgs(MediaViewType.PERSON, person.id)
)
}
)
}

View File

@@ -1,10 +1,12 @@
package com.owenlejeune.tvtime.ui.navigation package com.owenlejeune.tvtime.ui.navigation
import android.widget.Toast
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -28,6 +30,7 @@ import com.owenlejeune.tvtime.ui.screens.ListDetailScreen
import com.owenlejeune.tvtime.ui.screens.MediaDetailScreen import com.owenlejeune.tvtime.ui.screens.MediaDetailScreen
import com.owenlejeune.tvtime.ui.screens.PersonDetailScreen import com.owenlejeune.tvtime.ui.screens.PersonDetailScreen
import com.owenlejeune.tvtime.ui.screens.SearchScreen import com.owenlejeune.tvtime.ui.screens.SearchScreen
import com.owenlejeune.tvtime.ui.screens.SeasonDetailsScreen
import com.owenlejeune.tvtime.ui.screens.SeasonListScreen import com.owenlejeune.tvtime.ui.screens.SeasonListScreen
import com.owenlejeune.tvtime.ui.screens.SettingsScreen import com.owenlejeune.tvtime.ui.screens.SettingsScreen
import com.owenlejeune.tvtime.ui.screens.WebLinkScreen import com.owenlejeune.tvtime.ui.screens.WebLinkScreen
@@ -54,14 +57,40 @@ fun AppNavigationHost(
exitTransition = { fadeOut(tween(500)) }, exitTransition = { fadeOut(tween(500)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(500)) } popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(500)) }
) { ) {
composable(route = AppNavItem.MainView.route) { // About View
applicationViewModel.currentRoute.value = AppNavItem.MainView.route composable(route = AppNavItem.AboutView.route) {
HomeScreen( applicationViewModel.currentRoute.value = AppNavItem.AboutView.route
AboutScreen(appNavController = appNavController)
}
// Account View
composable(
route = AppNavItem.AccountView.route,
deepLinks = listOf(
navDeepLink { uriPattern = "app://tvtime.auth.{${NavConstants.ACCOUNT_KEY}}" }
)
) {
val deepLink = it.arguments?.getString(NavConstants.ACCOUNT_KEY)
applicationViewModel.currentRoute.value = AppNavItem.AccountView.route
AccountScreen(
appNavController = appNavController, appNavController = appNavController,
mainNavStartRoute = mainNavStartRoute, doSignInPartTwo = deepLink == NavConstants.AUTH_REDIRECT_PAGE
windowSize = windowSize
) )
} }
// Cast Crew List View
composable(
route = AppNavItem.CastCrewListView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.CastCrewListView.withArgs(type, id)
CastCrewListScreen(appNavController = appNavController, type = type, id = id)
}
// Detail View
composable( composable(
route = AppNavItem.DetailView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"), route = AppNavItem.DetailView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf( arguments = listOf(
@@ -86,7 +115,7 @@ fun AppNavigationHost(
itemId = id itemId = id
) )
} }
else -> { MediaViewType.MOVIE, MediaViewType.TV -> {
MediaDetailScreen( MediaDetailScreen(
appNavController = appNavController, appNavController = appNavController,
itemId = id, itemId = id,
@@ -94,12 +123,75 @@ fun AppNavigationHost(
windowSize = windowSize windowSize = windowSize
) )
} }
MediaViewType.SEASON -> {
SeasonDetailsScreen(
appNavController = appNavController,
codedId = id
)
}
else -> {
appNavController.popBackStack()
Toast.makeText(LocalContext.current, stringResource(R.string.unexpected_error), Toast.LENGTH_SHORT).show()
}
} }
} }
composable(AppNavItem.SettingsView.route) { // Gallery View
applicationViewModel.currentRoute.value = AppNavItem.SettingsView.route composable(
SettingsScreen(appNavController = appNavController) route = AppNavItem.GalleryView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.GalleryView.withArgs(type, id)
GalleryView(id = id, type = type, appNavController = appNavController)
} }
// Keywords View
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)!!
applicationViewModel.currentRoute.value = AppNavItem.KeywordsView.withArgs(type, keywords, id)
KeywordResultsScreen(
type = type,
keyword = keywords,
id = id,
appNavController = appNavController
)
}
// Known For View
composable(
route = AppNavItem.KnownForView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.KnownForView.withArgs(id)
KnownForScreen(appNavController = appNavController, id = id)
}
// Main View
composable(route = AppNavItem.MainView.route) {
applicationViewModel.currentRoute.value = AppNavItem.MainView.route
HomeScreen(
appNavController = appNavController,
mainNavStartRoute = mainNavStartRoute,
windowSize = windowSize
)
}
// Search View
composable( composable(
route = AppNavItem.SearchView.route.plus("?searchType={${NavConstants.SEARCH_ID_KEY}}&pageTitle={${NavConstants.SEARCH_TITLE_KEY}}"), route = AppNavItem.SearchView.route.plus("?searchType={${NavConstants.SEARCH_ID_KEY}}&pageTitle={${NavConstants.SEARCH_TITLE_KEY}}"),
arguments = listOf( arguments = listOf(
@@ -128,6 +220,24 @@ fun AppNavigationHost(
) )
} }
} }
// Season List View
composable(
route = AppNavItem.SeasonListView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.SeasonListView.withArgs(id)
SeasonListScreen(id = id, appNavController = appNavController)
}
// Settings View
composable(AppNavItem.SettingsView.route) {
applicationViewModel.currentRoute.value = AppNavItem.SettingsView.route
SettingsScreen(appNavController = appNavController)
}
// Web Link View
composable( composable(
route = AppNavItem.WebLinkView.route.plus("/{${NavConstants.WEB_LINK_KEY}}"), route = AppNavItem.WebLinkView.route.plus("/{${NavConstants.WEB_LINK_KEY}}"),
arguments = listOf( arguments = listOf(
@@ -140,91 +250,6 @@ fun AppNavigationHost(
WebLinkScreen(url = url, appNavController = appNavController) WebLinkScreen(url = url, appNavController = appNavController)
} }
} }
composable(
route = AppNavItem.AccountView.route,
deepLinks = listOf(
navDeepLink { uriPattern = "app://tvtime.auth.{${NavConstants.ACCOUNT_KEY}}" }
)
) {
val deepLink = it.arguments?.getString(NavConstants.ACCOUNT_KEY)
applicationViewModel.currentRoute.value = AppNavItem.AccountView.route
AccountScreen(
appNavController = appNavController,
doSignInPartTwo = deepLink == NavConstants.AUTH_REDIRECT_PAGE
)
}
composable(route = AppNavItem.AboutView.route) {
applicationViewModel.currentRoute.value = 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)!!
applicationViewModel.currentRoute.value = AppNavItem.KeywordsView.withArgs(type, keywords, id)
KeywordResultsScreen(
type = type,
keyword = keywords,
id = id,
appNavController = appNavController
)
}
composable(
route = AppNavItem.KnownForView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.KnownForView.withArgs(id)
KnownForScreen(appNavController = appNavController, id = id)
}
composable(
route = AppNavItem.GalleryView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.GalleryView.withArgs(type, id)
GalleryView(id = id, type = type, appNavController = appNavController)
}
composable(
route = AppNavItem.SeasonListView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.SeasonListView.withArgs(id)
SeasonListScreen(id = id, appNavController = appNavController)
}
composable(
route = AppNavItem.CastCrewListView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.CastCrewListView.withArgs(type, id)
CastCrewListScreen(appNavController = appNavController, type = type, id = id)
}
} }
} }
@@ -234,33 +259,33 @@ sealed class AppNavItem(val route: String) {
val Items = listOf(MainView, DetailView, SettingsView) val Items = listOf(MainView, DetailView, SettingsView)
} }
object MainView: AppNavItem("main_route") object AboutView: AppNavItem("about_route")
object AccountView: AppNavItem("account_route")
object CastCrewListView: AppNavItem("cast_crew_list_route") {
fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id")
}
object DetailView: AppNavItem("detail_route") { object DetailView: AppNavItem("detail_route") {
fun withArgs(type: MediaViewType, id: Int) = route.plus("/${type}/${id}") fun withArgs(type: MediaViewType, id: Int) = route.plus("/${type}/${id}")
} }
object SettingsView: AppNavItem("settings_route") object GalleryView: AppNavItem("gallery_view_route") {
object SearchView: AppNavItem("search_route") { fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id")
fun withArgs(searchType: MediaViewType, pageTitle: String) = route.plus("?searchType=$searchType&pageTitle=$pageTitle")
} }
object WebLinkView: AppNavItem("web_link_route") {
fun withArgs(url: String) = route.plus("/$url")
}
object AccountView: AppNavItem("account_route")
object AboutView: AppNavItem("about_route")
object KeywordsView: AppNavItem("keywords_route") { object KeywordsView: AppNavItem("keywords_route") {
fun withArgs(type: MediaViewType, keyword: String, id: Int) = route.plus("/$type?keyword=$keyword&keywordId=$id") fun withArgs(type: MediaViewType, keyword: String, id: Int) = route.plus("/$type?keyword=$keyword&keywordId=$id")
} }
object KnownForView: AppNavItem("known_for_route") { object KnownForView: AppNavItem("known_for_route") {
fun withArgs(id: Int) = route.plus("/$id") fun withArgs(id: Int) = route.plus("/$id")
} }
object GalleryView: AppNavItem("gallery_view_route") { object MainView: AppNavItem("main_route")
fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id") object SearchView: AppNavItem("search_route") {
fun withArgs(searchType: MediaViewType, pageTitle: String) = route.plus("?searchType=$searchType&pageTitle=$pageTitle")
} }
object SeasonListView: AppNavItem("season_list_route") { object SeasonListView: AppNavItem("season_list_route") {
fun withArgs(id: Int) = route.plus("/$id") fun withArgs(id: Int) = route.plus("/$id")
} }
object CastCrewListView: AppNavItem("cast_crew_list_route") { object SettingsView: AppNavItem("settings_route")
fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id") object WebLinkView: AppNavItem("web_link_route") {
fun withArgs(url: String) = route.plus("/$url")
} }
} }

View File

@@ -102,6 +102,7 @@ import com.owenlejeune.tvtime.ui.components.ActionsView
import com.owenlejeune.tvtime.ui.components.AdditionalDetailItem import com.owenlejeune.tvtime.ui.components.AdditionalDetailItem
import com.owenlejeune.tvtime.ui.components.AvatarImage import com.owenlejeune.tvtime.ui.components.AvatarImage
import com.owenlejeune.tvtime.ui.components.BackButton import com.owenlejeune.tvtime.ui.components.BackButton
import com.owenlejeune.tvtime.ui.components.CastCrewCard
import com.owenlejeune.tvtime.ui.components.ChipDefaults import com.owenlejeune.tvtime.ui.components.ChipDefaults
import com.owenlejeune.tvtime.ui.components.ChipGroup import com.owenlejeune.tvtime.ui.components.ChipGroup
import com.owenlejeune.tvtime.ui.components.ChipInfo import com.owenlejeune.tvtime.ui.components.ChipInfo
@@ -872,42 +873,6 @@ private fun CastCard(
} }
} }
@Composable
private fun CastCrewCard(
appNavController: NavController,
person: Person
) {
TwoLineImageTextCard(
title = person.name,
modifier = Modifier
.width(124.dp)
.wrapContentHeight(),
subtitle = when (person) {
is MovieCastMember -> person.character
is MovieCrewMember -> person.job
is TvCastMember -> {
val roles = person.roles.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
is TvCrewMember -> {
val roles = person.jobs.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
else -> null
},
imageUrl = TmdbUtils.getFullPersonImagePath(person),
titleTextColor = MaterialTheme.colorScheme.onPrimary,
subtitleTextColor = MaterialTheme.colorScheme.onSecondary,
onItemClicked = {
appNavController.navigate(
AppNavItem.DetailView.withArgs(MediaViewType.PERSON, person.id)
)
}
)
}
@Composable @Composable
private fun SeasonCard( private fun SeasonCard(
itemId: Int, itemId: Int,

View File

@@ -0,0 +1,163 @@
package com.owenlejeune.tvtime.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season
import com.owenlejeune.tvtime.extensions.toCompositeParts
import com.owenlejeune.tvtime.ui.components.BackButton
import com.owenlejeune.tvtime.ui.components.CastCrewCard
import com.owenlejeune.tvtime.ui.components.ContentCard
import com.owenlejeune.tvtime.ui.components.DetailHeader
import com.owenlejeune.tvtime.ui.components.EpisodeItem
import com.owenlejeune.tvtime.ui.components.TVTTopAppBar
import com.owenlejeune.tvtime.ui.theme.Typography
import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun fetchData(
mainViewModel: MainViewModel,
seasonNumber: Int,
seriesId: Int,
force: Boolean = false
) {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch { mainViewModel.getSeason(seriesId, seasonNumber, force) }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SeasonDetailsScreen(
appNavController: NavController,
codedId: Int
) {
val mainViewModel = viewModel<MainViewModel>()
val applicationViewModel = viewModel<ApplicationViewModel>()
applicationViewModel.statusBarColor.value = MaterialTheme.colorScheme.background
applicationViewModel.navigationBarColor.value = MaterialTheme.colorScheme.background
val (a, b) = codedId.toCompositeParts()
val seasonNumber = minOf(a, b)
val seriesId = maxOf(a, b)
LaunchedEffect(Unit) {
fetchData(mainViewModel, seasonNumber, seriesId)
}
val seasonsMap = remember { mainViewModel.tvSeasons }
val season = seasonsMap[seriesId]?.firstOrNull { it.seasonNumber == seasonNumber }
Scaffold(
topBar = {
TVTTopAppBar(
title = { },
appNavController = appNavController,
navigationIcon = { BackButton(navController = appNavController) }
)
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
SeasonContent(appNavController = appNavController, season = season)
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun SeasonContent(
appNavController: NavController,
season: Season?
) {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.verticalScroll(state = rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
DetailHeader(
posterUrl = TmdbUtils.getFullPosterPath(season?.posterPath),
elevation = 0.dp,
expandedPosterAsBackdrop = true
)
Column(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = season?.name ?: "",
color = MaterialTheme.colorScheme.secondary,
style = Typography.headlineLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
season?.episodes?.forEach { episode ->
SeasonEpisodeItem(appNavController = appNavController, episode = episode)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun SeasonEpisodeItem(
appNavController: NavController,
episode: Episode
) {
ContentCard {
EpisodeItem(episode = episode, elevation = 0.dp, maxDescriptionLines = 5)
episode.guestStars?.let { guestStars ->
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.guest_stars_label),
style = Typography.headlineSmall
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(8.dp))
guestStars.forEach {
CastCrewCard(appNavController = appNavController, person = it)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}

View File

@@ -6,27 +6,20 @@ import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -43,29 +36,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season
import com.owenlejeune.tvtime.extensions.combineWith
import com.owenlejeune.tvtime.ui.components.BackButton import com.owenlejeune.tvtime.ui.components.BackButton
import com.owenlejeune.tvtime.ui.components.EpisodeItem
import com.owenlejeune.tvtime.ui.components.TVTTopAppBar import com.owenlejeune.tvtime.ui.components.TVTTopAppBar
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.types.MediaViewType
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -80,7 +65,7 @@ fun SeasonListScreen(
applicationViewModel.navigationBarColor.value = MaterialTheme.colorScheme.background applicationViewModel.navigationBarColor.value = MaterialTheme.colorScheme.background
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val numSeasons = mainViewModel.detailedTv[id]!!.numberOfSeasons val numSeasons = mainViewModel.detailedTv[id]?.numberOfSeasons ?: 0
for (i in 0..numSeasons) { for (i in 0..numSeasons) {
mainViewModel.getSeason(id, i, true) mainViewModel.getSeason(id, i, true)
} }
@@ -113,7 +98,12 @@ fun SeasonListScreen(
val seasons = seasonsMap[id] ?: emptySet() val seasons = seasonsMap[id] ?: emptySet()
seasons.sortedBy { it.seasonNumber }.forEachIndexed { index, season -> seasons.sortedBy { it.seasonNumber }.forEachIndexed { index, season ->
SeasonSection(season = season, expandedByDefault = index == 0) SeasonSection(
appNavController = appNavController,
seriesId = id,
season = season,
expandedByDefault = index == 0
)
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
@@ -123,7 +113,12 @@ fun SeasonListScreen(
} }
@Composable @Composable
private fun SeasonSection(season: Season, expandedByDefault: Boolean) { private fun SeasonSection(
appNavController: NavController,
seriesId: Int,
season: Season,
expandedByDefault: Boolean
) {
var isExpanded by remember { mutableStateOf(expandedByDefault) } var isExpanded by remember { mutableStateOf(expandedByDefault) }
Row( Row(
@@ -135,7 +130,8 @@ private fun SeasonSection(season: Season, expandedByDefault: Boolean) {
.weight(1f) .weight(1f)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.clickable { .clickable {
val combinedId = seriesId.combineWith(season.seasonNumber)
appNavController.navigate(AppNavItem.DetailView.withArgs(type = MediaViewType.SEASON, id = combinedId))
} }
) { ) {
Text( Text(
@@ -183,77 +179,3 @@ private fun SeasonSection(season: Season, expandedByDefault: Boolean) {
} }
} }
} }
@Composable
private fun EpisodeItem(episode: Episode) {
Card(
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier
.fillMaxWidth()
.clickable {
}
) {
Box(
modifier = Modifier.height(112.dp)
) {
episode.stillPath?.let {
// Cloudy(
// modifier = Modifier.background(Color.Black.copy(alpha = 0.4f))
// ) {
val url = TmdbUtils.getFullEpisodeStillPath(it)
val model = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCacheKey(url ?: "")
.networkCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.blur(radius = 10.dp)
.fillMaxWidth()
.wrapContentHeight()
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.blur(radius = 5.dp)
)
// }
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(12.dp)
) {
val textColor = episode.stillPath?.let { Color.White } ?: if (isSystemInDarkTheme()) Color.White else Color.Black
Text(
text = "S${episode.seasonNumber}E${episode.episodeNumber}${episode.name}",
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
TmdbUtils.convertEpisodeDate(episode.airDate)?.let {
Text(
text = it,
fontStyle = FontStyle.Italic,
fontSize = 12.sp,
color = textColor
)
}
Text(
text = episode.overview,
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
}
}
}

View File

@@ -9,6 +9,7 @@ enum class MediaViewType {
TV, TV,
@SerializedName("person") @SerializedName("person")
PERSON, PERSON,
SEASON,
EPISODE, EPISODE,
MIXED, MIXED,
LIST; LIST;

View File

@@ -260,4 +260,6 @@
<string name="place_of_birth">Place of birth</string> <string name="place_of_birth">Place of birth</string>
<string name="birthday">Birthday</string> <string name="birthday">Birthday</string>
<string name="date_of_death">Date of death</string> <string name="date_of_death">Date of death</string>
<string name="unexpected_error">An unexpected error occurred</string>
<string name="guest_stars_label">Guest stars</string>
</resources> </resources>