display cast and overview on details screen

This commit is contained in:
Owen LeJeune
2022-02-15 17:18:52 -05:00
parent 86034bf198
commit cbb44513b6
17 changed files with 276 additions and 61 deletions

View File

@@ -15,8 +15,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.TVTime">
android:theme="@style/Theme.TVTime"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,28 +1,20 @@
package com.owenlejeune.tvtime
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Scaffold
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.view.WindowCompat
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.owenlejeune.tvtime.ui.navigation.BottomNavItem
import com.owenlejeune.tvtime.ui.navigation.BottomNavigationRoutes
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.owenlejeune.tvtime.ui.navigation.MainNavigationRoutes
import com.owenlejeune.tvtime.ui.theme.TVTimeTheme
@@ -31,7 +23,14 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
setContent {
val displayUnderStatusBar = remember { mutableStateOf(false) }
WindowCompat.setDecorFitsSystemWindows(window, !displayUnderStatusBar.value)
// WindowCompat.setDecorFitsSystemWindows(window, !displayUnderStatusBar.value)
// val statusBarColor = if (displayUnderStatusBar.value) {
// Color.Transparent
// } else {
// MaterialTheme.colorScheme.background
// }
// val systemUiController = rememberSystemUiController()
// systemUiController.setStatusBarColor(statusBarColor, !isSystemInDarkTheme())
MyApp(displayUnderStatusBar = displayUnderStatusBar)
}
}

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.CastAndCrew
import com.owenlejeune.tvtime.api.tmdb.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.model.DetailedItem
import retrofit2.Response
@@ -10,4 +11,6 @@ interface DetailService {
suspend fun getImages(id: Int): Response<ImageCollection>
suspend fun getCastAndCrew(id: Int): Response<CastAndCrew>
}

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.CastAndCrew
import com.owenlejeune.tvtime.api.tmdb.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.model.DetailedMovie
import com.owenlejeune.tvtime.api.tmdb.model.PopularMoviesResponse
@@ -19,4 +20,7 @@ interface MoviesApi {
@GET("movie/{id}/images")
suspend fun getMovieImages(@Path("id") id: Int): Response<ImageCollection>
@GET("movie/{id}/credits")
suspend fun getCastAndCrew(@Path("id") id: Int): Response<CastAndCrew>
}

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.CastAndCrew
import com.owenlejeune.tvtime.api.tmdb.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.model.DetailedItem
import org.koin.core.component.KoinComponent
@@ -19,4 +20,8 @@ class MoviesService: KoinComponent, DetailService {
return service.getMovieImages(id)
}
override suspend fun getCastAndCrew(id: Int): Response<CastAndCrew> {
return service.getCastAndCrew(id)
}
}

View File

@@ -2,10 +2,15 @@ package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.DetailedItem
import com.owenlejeune.tvtime.api.tmdb.model.Image
import com.owenlejeune.tvtime.api.tmdb.model.Person
import com.owenlejeune.tvtime.api.tmdb.model.TmdbItem
object TmdbUtils {
private const val POSTER_BASE = "https://image.tmdb.org/t/p/original"
private const val BACKDROP_BASE = "https://www.themoviedb.org/t/p/original"
private const val PERSON_BASE = "https://www.themoviedb.org/t/p/w600_and_h900_bestv2"
fun getFullPosterPath(posterPath: String?): String? {
return posterPath?.let { "https://image.tmdb.org/t/p/original${posterPath}" }
}
@@ -30,4 +35,12 @@ object TmdbUtils {
return getFullBackdropPath(image.filePath)
}
fun getFullPersonImagePath(path: String?): String? {
return path?.let { "https://www.themoviedb.org/t/p/w600_and_h900_bestv2${path}" }
}
fun getFullPersonImagePath(person: Person): String? {
return getFullPersonImagePath(person.profilePath)
}
}

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.CastAndCrew
import com.owenlejeune.tvtime.api.tmdb.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.model.PopularTvResponse
import com.owenlejeune.tvtime.api.tmdb.model.DetailedTv
@@ -19,4 +20,7 @@ interface TvApi {
@GET("tv/{id}/images")
suspend fun getTvImages(@Path("id") id: Int): Response<ImageCollection>
@GET("tv/{id}/credits")
suspend fun getCastAndCrew(@Path("id") id: Int): Response<CastAndCrew>
}

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.CastAndCrew
import com.owenlejeune.tvtime.api.tmdb.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.model.DetailedItem
import org.koin.core.component.KoinComponent
@@ -18,4 +19,8 @@ class TvService: KoinComponent, DetailService {
override suspend fun getImages(id: Int): Response<ImageCollection> {
return service.getTvImages(id)
}
override suspend fun getCastAndCrew(id: Int): Response<CastAndCrew> {
return service.getCastAndCrew(id)
}
}

View File

@@ -0,0 +1,8 @@
package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
class CastAndCrew(
@SerializedName("cast") val cast: List<CastMember>,
@SerializedName("crew") val crew: List<CrewMember>
)

View File

@@ -0,0 +1,13 @@
package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
class CastMember(
@SerializedName("character") val character: String,
@SerializedName("order") val order: Int,
id: Int,
creditId: String,
name: String,
gender: Int,
profilePath: String?
): Person(id, creditId, name, gender, profilePath)

View File

@@ -0,0 +1,13 @@
package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
class CrewMember(
@SerializedName("department") val department: String,
@SerializedName("job") val job: String,
id: Int,
creditId: String,
name: String,
gender: Int,
profilePath: String?
): Person(id, creditId, name, gender, profilePath)

View File

@@ -4,7 +4,8 @@ import com.google.gson.annotations.SerializedName
open class Person(
@SerializedName("id") val id: Int,
@SerializedName("credit_id") val creditId: Int,
@SerializedName("credit_id") val creditId: String,
@SerializedName("name") val name: String,
@SerializedName("gender") val gender: Int
@SerializedName("gender") val gender: Int,
@SerializedName("profile_path") val profilePath: String?
)

View File

@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.GridCells
import androidx.compose.foundation.lazy.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -67,28 +69,35 @@ fun PosterItem(
) {
val context = LocalContext.current
val poster = mediaItem?.let { TmdbUtils.getFullPosterPath(mediaItem) }
Image(
painter = if (mediaItem != null) {
rememberImagePainter(
data = poster,
builder = {
transformations(RoundedCornersTransformation(5f.dpToPx(context)))
placeholder(R.drawable.placeholder)
}
)
} else {
rememberImagePainter(ContextCompat.getDrawable(context, R.drawable.placeholder))
},
contentDescription = mediaItem?.title,
Card(
elevation = 8.dp,
modifier = modifier
.size(width = width, height = height)
.padding(5.dp)
.clickable {
mediaItem?.let {
onClick(mediaItem.id)
.padding(5.dp),
shape = RoundedCornerShape(5.dp)
) {
Image(
painter = if (mediaItem != null) {
rememberImagePainter(
data = poster,
builder = {
transformations(RoundedCornersTransformation(5f.dpToPx(context)))
placeholder(R.drawable.placeholder)
}
)
} else {
rememberImagePainter(ContextCompat.getDrawable(context, R.drawable.placeholder))
},
contentDescription = mediaItem?.title,
modifier = Modifier
.size(width = width, height = height)
.clickable {
mediaItem?.let {
onClick(mediaItem.id)
}
}
}
)
)
}
}
@SuppressLint("CoroutineCreationDuringComposition")

View File

@@ -26,8 +26,6 @@ fun MainNavigationRoutes(navController: NavHostController, displayUnderStatusBar
NavHost(navController = navController, startDestination = MainNavItem.MainView.route) {
composable(MainNavItem.MainView.route) {
displayUnderStatusBar.value = false
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(MaterialTheme.colorScheme.background, !isSystemInDarkTheme())
MainAppView(appNavController = navController)
}
composable(
@@ -38,8 +36,6 @@ fun MainNavigationRoutes(navController: NavHostController, displayUnderStatusBar
)
) { navBackStackEntry ->
displayUnderStatusBar.value = true
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(Color.Transparent, !isSystemInDarkTheme())
val args = navBackStackEntry.arguments
DetailView(
appNavController = navController,

View File

@@ -1,7 +1,13 @@
package com.owenlejeune.tvtime.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Icon
@@ -16,17 +22,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.navigation.NavController
import coil.compose.rememberImagePainter
import coil.transform.RoundedCornersTransformation
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.DetailService
import com.owenlejeune.tvtime.api.tmdb.MoviesService
import com.owenlejeune.tvtime.api.tmdb.TmdbUtils
import com.owenlejeune.tvtime.api.tmdb.TvService
import com.owenlejeune.tvtime.api.tmdb.model.CastAndCrew
import com.owenlejeune.tvtime.api.tmdb.model.DetailedItem
import com.owenlejeune.tvtime.api.tmdb.model.Image
import com.owenlejeune.tvtime.api.tmdb.model.ImageCollection
import com.owenlejeune.tvtime.extensions.dpToPx
import com.owenlejeune.tvtime.ui.components.BackdropImage
import com.owenlejeune.tvtime.ui.components.PosterItem
import kotlinx.coroutines.CoroutineScope
@@ -48,24 +60,32 @@ fun DetailView(
val mediaItem = remember { mutableStateOf<DetailedItem?>(null) }
itemId?.let {
fetchMediaItem(itemId, service, mediaItem)
if (mediaItem.value == null) {
fetchMediaItem(itemId, service, mediaItem)
}
}
val images = remember { mutableStateOf<ImageCollection?>(null) }
itemId?.let {
fetchImages(itemId, service, images)
if (images.value == null) {
fetchImages(itemId, service, images)
}
}
val scrollState = rememberScrollState()
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.background)
.verticalScroll(state = scrollState)
) {
val (
backButton,
backdropImage,
posterImage,
title
titleText,
contentColumn
) = createRefs()
BackdropImage(
@@ -75,18 +95,18 @@ fun DetailView(
start.linkTo(parent.start)
}
.fillMaxWidth()
.size(0.dp, 280.dp),
.height(280.dp),
imageUrl = TmdbUtils.getFullBackdropPath(mediaItem.value),
collection = images.value
// collection = images.value
)
PosterItem(
mediaItem = mediaItem.value,
modifier = Modifier
.constrainAs(posterImage) {
bottom.linkTo(title.top, margin = 8.dp)
bottom.linkTo(backdropImage.bottom)
start.linkTo(parent.start, margin = 16.dp)
top.linkTo(backButton.bottom, margin = 8.dp)
top.linkTo(backButton.bottom)
}
)
@@ -94,14 +114,14 @@ fun DetailView(
text = mediaItem.value?.title ?: "",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.constrainAs(title) {
bottom.linkTo(backdropImage.bottom, margin = 8.dp)
start.linkTo(parent.start, margin = 20.dp)
.constrainAs(titleText) {
bottom.linkTo(posterImage.bottom)
start.linkTo(posterImage.end, margin = 8.dp)
end.linkTo(parent.end, margin = 16.dp)
}
.padding(start = 16.dp, end = 16.dp)
.fillMaxWidth(),
style = MaterialTheme.typography.titleLarge,
.fillMaxWidth(.6f),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Start,
softWrap = true
)
@@ -110,10 +130,18 @@ fun DetailView(
onClick = { appNavController.popBackStack() },
modifier = Modifier
.constrainAs(backButton) {
top.linkTo(parent.top, 16.dp)
top.linkTo(parent.top)//, 8.dp)
start.linkTo(parent.start, 12.dp)
bottom.linkTo(posterImage.top)
}
.background(brush = Brush.radialGradient(colors = listOf(Color.Black, Color.Transparent)))
.background(
brush = Brush.radialGradient(
colors = listOf(
Color.Black,
Color.Transparent
)
)
)
.wrapContentSize()
) {
Icon(
@@ -122,6 +150,108 @@ fun DetailView(
tint = MaterialTheme.colorScheme.primary
)
}
val castAndCrew = remember { mutableStateOf<CastAndCrew?>(null) }
itemId?.let {
if (castAndCrew.value == null) {
fetchCastAndCrew(itemId, service, castAndCrew)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp)
.constrainAs(contentColumn) {
top.linkTo(backdropImage.bottom, margin = 8.dp)
}
) {
Card(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(bottom = 12.dp),
shape = RoundedCornerShape(10.dp),
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
elevation = 8.dp
) {
Text(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(vertical = 12.dp, horizontal = 16.dp),
text = mediaItem.value?.overview ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
shape = RoundedCornerShape(10.dp),
backgroundColor = MaterialTheme.colorScheme.primary,
elevation = 8.dp
) {
LazyRow(modifier = Modifier
.fillMaxSize()
.padding(12.dp)
) {
items(castAndCrew.value?.cast?.size ?: 0) { i ->
val castMember = castAndCrew.value!!.cast[i]
Column(
modifier = Modifier
.width(124.dp)
.wrapContentHeight()
.padding(end = 12.dp)
) {
Image(
modifier = Modifier
.size(width = 120.dp, height = 180.dp),
painter = rememberImagePainter(
data = TmdbUtils.getFullPersonImagePath(castMember),
builder = {
transformations(RoundedCornersTransformation(5f.dpToPx(context)))
placeholder(R.drawable.placeholder)
}
),
contentDescription = ""
)
val nameLineHeight = MaterialTheme.typography.bodyMedium.fontSize*4/3
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
.sizeIn(
minHeight = with(LocalDensity.current) {
(nameLineHeight * 2).toDp()
}
),
text = castMember.name,
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.bodyMedium,
lineHeight = nameLineHeight
)
val characterLineHeight = MaterialTheme.typography.bodySmall.fontSize*4/3
Text(
modifier = Modifier
.fillMaxWidth()
.sizeIn(
minHeight = with(LocalDensity.current) {
(characterLineHeight * 2).toDp()
}
),
text = castMember.character,
style = MaterialTheme.typography.bodySmall,
lineHeight = characterLineHeight
)
}
}
}
}
}
}
}
@@ -147,6 +277,17 @@ private fun fetchImages(id: Int, service: DetailService, images: MutableState<Im
}
}
private fun fetchCastAndCrew(id: Int, service: DetailService, castAndCrew: MutableState<CastAndCrew?>) {
CoroutineScope(Dispatchers.IO).launch {
val response = service.getCastAndCrew(id)
if (response.isSuccessful) {
withContext(Dispatchers.Main) {
castAndCrew.value = response.body()
}
}
}
}
enum class DetailViewType {
MOVIE,
TV

View File

@@ -14,6 +14,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.components.PaletteView
@@ -67,7 +68,7 @@ private fun PaletteDialog(showDialog: MutableState<Boolean>) {
modifier = Modifier.fillMaxWidth(),
onClick = { showDialog.value = false }
) {
Text(text = "Dismiss")
Text(text = "Dismiss", color = Color.White)
}
},
confirmButton = {},

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
<corners android:radius="20dp" />
<solid android:color="@android:color/darker_gray" />
</shape>