add more details for movies

This commit is contained in:
Owen LeJeune
2022-02-16 13:53:26 -05:00
parent 11e4f964be
commit 4104e68257
12 changed files with 276 additions and 64 deletions

View File

@@ -64,6 +64,7 @@ dependencies {
implementation "androidx.activity:activity-compose:${Versions.activity_compose}"
implementation "com.google.accompanist:accompanist-systemuicontroller:${Versions.compose_accompanist}"
implementation "com.google.accompanist:accompanist-pager:${Versions.compose_accompanist}"
implementation "com.google.accompanist:accompanist-flowlayout:${Versions.compose_accompanist}"
implementation "androidx.navigation:navigation-compose:${Versions.compose_navigation}"
implementation "androidx.paging:paging-compose:${Versions.compose_paging}"
implementation "androidx.constraintlayout:constraintlayout-compose:${Versions.compose_constraint_layout}"

View File

@@ -1,9 +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
import com.owenlejeune.tvtime.api.tmdb.model.*
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
@@ -23,4 +20,7 @@ interface MoviesApi {
@GET("movie/{id}/credits")
suspend fun getCastAndCrew(@Path("id") id: Int): Response<CastAndCrew>
@GET("movie/{id}/release_dates")
suspend fun getReleaseDates(@Path("id") id: Int): Response<MovieReleaseResults>
}

View File

@@ -1,8 +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 com.owenlejeune.tvtime.api.tmdb.model.*
import org.koin.core.component.KoinComponent
import retrofit2.Response
@@ -10,7 +8,13 @@ class MoviesService: KoinComponent, DetailService {
private val service by lazy { TmdbClient().createMovieService() }
suspend fun getPopularMovies(page: Int = 1) = service.getPopularMovies(page)
suspend fun getPopularMovies(page: Int = 1): Response<PopularMoviesResponse> {
return service.getPopularMovies(page)
}
suspend fun getReleaseDates(id: Int): Response<MovieReleaseResults> {
return service.getReleaseDates(id)
}
override suspend fun getById(id: Int): Response<out DetailedItem> {
return service.getMovieById(id)

View File

@@ -1,46 +0,0 @@
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}" }
}
fun getFullPosterPath(tmdbItem: TmdbItem?): String? {
return tmdbItem?.let { getFullPosterPath(tmdbItem.posterPath) }
}
fun getFullPosterPath(image: Image): String? {
return getFullPosterPath(image.filePath)
}
fun getFullBackdropPath(backdropPath: String?): String? {
return backdropPath?.let { "https://www.themoviedb.org/t/p/original${backdropPath}" }
}
fun getFullBackdropPath(detailItem: DetailedItem?): String? {
return detailItem?.let { getFullBackdropPath(detailItem.backdropPath) }
}
fun getFullBackdropPath(image: Image): String? {
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

@@ -15,6 +15,7 @@ class DetailedTv(
@SerializedName("vote_average") override val voteAverage: Float,
@SerializedName("created_by") val createdBy: List<Person>,
@SerializedName("first_air_date") val firstAirDate: String,
@SerializedName("last_air_date") val lastAirDate: String,
@SerializedName("in_production") val inProduction: Boolean,
@SerializedName("networks") val networks: List<Network>,
@SerializedName("number_of_episodes") val numberOfEpisodes: Int,

View File

@@ -0,0 +1,19 @@
package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
data class MovieReleaseResults(
@SerializedName("results") val releaseDates: List<ReleaseDateResult>
) {
inner class ReleaseDateResult(
@SerializedName("iso_3166_1") val region: String,
@SerializedName("release_dates") val releaseDates: List<ReleaseDate>
)
inner class ReleaseDate(
@SerializedName("certification") val certification: String,
@SerializedName("release_date") val releaseDate: String
)
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.owenlejeune.tvtime.extensions.listItems
import com.owenlejeune.tvtime.utils.ComposeUtils
@Composable
fun PaletteView() {
@@ -57,8 +58,9 @@ private fun PaletteItemView(surfaceColor: Color, textColor: Color, text: String)
shape = RoundedCornerShape(30.dp),
backgroundColor = surfaceColor
) {
val hexString = ComposeUtils.colorToHexString(surfaceColor)
Text(
text = text,
text = "${text}\n${hexString}",
color = textColor,
modifier = Modifier.padding(30.dp, 12.dp)
)

View File

@@ -30,12 +30,11 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.TmdbUtils
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.api.tmdb.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.model.TmdbItem
import com.owenlejeune.tvtime.extensions.dpToPx
import com.owenlejeune.tvtime.extensions.listItems
import kotlinx.coroutines.*
@OptIn(ExperimentalFoundationApi::class)
@Composable

View File

@@ -6,6 +6,8 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.icons.Icons
@@ -36,6 +38,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
@Composable
fun TopLevelSwitch(
@@ -206,4 +209,60 @@ fun MinLinesText(
onTextLayout = onTextLayout,
style = style
)
}
@Composable
fun Chip(
text: String,
style: TextStyle = MaterialTheme.typography.bodySmall,
isSelected: Boolean = true,
onSelectionChanged: (String) -> Unit = {}
) {
Surface(
modifier = Modifier.padding(4.dp),
shadowElevation = 8.dp,
shape = RoundedCornerShape(5.dp),
color = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondary
) {
Row(
modifier = Modifier
.toggleable(
value = isSelected,
onValueChange = {
onSelectionChanged(text)
}
)
) {
Text(
text = text,
style = style,
color = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSecondary,
modifier = Modifier.padding(8.dp)
)
}
}
}
@Composable
fun ChipGroup(
modifier: Modifier = Modifier,
chips: List<String> = emptyList(),
onSelectedChanged: (String) -> Unit = {},
) {
FlowRow(
modifier = modifier
) {
chips.forEach { chip ->
Chip(
text = chip,
onSelectionChanged = onSelectedChanged
)
}
}
}
@Preview
@Composable
fun ChipPreview() {
Chip("Test Chip")
}

View File

@@ -28,11 +28,12 @@ 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.utils.TmdbUtils
import com.owenlejeune.tvtime.api.tmdb.TvService
import com.owenlejeune.tvtime.api.tmdb.model.*
import com.owenlejeune.tvtime.extensions.dpToPx
import com.owenlejeune.tvtime.ui.components.BackdropImage
import com.owenlejeune.tvtime.ui.components.ChipGroup
import com.owenlejeune.tvtime.ui.components.MinLinesText
import com.owenlejeune.tvtime.ui.components.PosterItem
import kotlinx.coroutines.CoroutineScope
@@ -46,7 +47,6 @@ fun DetailView(
itemId: Int?,
type: DetailViewType
) {
val context = LocalContext.current
val service = when(type) {
DetailViewType.MOVIE -> MoviesService()
DetailViewType.TV -> TvService()
@@ -109,11 +109,12 @@ fun DetailView(
ContentColumn(
modifier = Modifier.constrainAs(contentColumn) {
top.linkTo(backdropImage.bottom, margin = 8.dp)
top.linkTo(backdropImage.bottom)//, margin = 8.dp)
},
itemId = itemId,
mediaItem = mediaItem,
service = service
service = service,
mediaType = type
)
}
}
@@ -176,27 +177,84 @@ private fun BackButton(modifier: Modifier, appNavController: NavController) {
private fun ContentColumn(modifier: Modifier,
itemId: Int?,
mediaItem: MutableState<DetailedItem?>,
service: DetailService
service: DetailService,
mediaType: DetailViewType
) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp)
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
) {
if (mediaType == DetailViewType.MOVIE) {
MiscMovieDetails(mediaItem = mediaItem, service as MoviesService)
} else {
MiscTvDetails(mediaItem = mediaItem)
}
OverviewCard(mediaItem = mediaItem)
CastCard(itemId = itemId, service = service)
}
}
@Composable
private fun MiscTvDetails(mediaItem: MutableState<DetailedItem?>) {
}
@Composable
private fun MiscMovieDetails(mediaItem: MutableState<DetailedItem?>, service: MoviesService) {
mediaItem.value?.let { mi ->
val movie = mi as DetailedMovie
val contentRating = remember { mutableStateOf("") }
fetchMovieContentRating(movie.id, service, contentRating)
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(start = 8.dp, end = 10.dp, bottom = 8.dp)
) {
val releaseYear = TmdbUtils.getMovieReleaseYear(movie)
Text(text = releaseYear, color = MaterialTheme.colorScheme.onBackground)
val runtime = TmdbUtils.convertRuntimeToHoursMinutes(movie)
Text(
text = runtime,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 12.dp)
)
Text(
text = contentRating.value,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 12.dp)
)
}
ChipGroup(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(bottom = 8.dp),
chips = movie.genres.map { it.name }
)
}
}
}
@Composable
private fun OverviewCard(mediaItem: MutableState<DetailedItem?>) {
Card(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(bottom = 12.dp),
.padding(bottom = 16.dp),
shape = RoundedCornerShape(10.dp),
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
elevation = 8.dp
@@ -320,6 +378,18 @@ private fun fetchCastAndCrew(id: Int, service: DetailService, castAndCrew: Mutab
}
}
private fun fetchMovieContentRating(id: Int, service: MoviesService, contentRating: MutableState<String>) {
CoroutineScope(Dispatchers.IO).launch {
val results = service.getReleaseDates(id)
if (results.isSuccessful) {
val cr = TmdbUtils.getMovieRating(results.body())
withContext(Dispatchers.Main) {
contentRating.value = cr
}
}
}
}
enum class DetailViewType {
MOVIE,
TV

View File

@@ -0,0 +1,16 @@
package com.owenlejeune.tvtime.utils
import androidx.compose.ui.graphics.Color
object ComposeUtils {
fun colorToHexString(color: Color): String {
val a = (color.alpha * 255f).toInt().toString(16).uppercase()
val r = (color.red * 255f).toInt().toString(16).uppercase()
val g = (color.green * 255f).toInt().toString(16).uppercase()
val b = (color.blue * 255f).toInt().toString(16).uppercase()
return "0x$a$r$g$b"
}
}

View File

@@ -0,0 +1,87 @@
package com.owenlejeune.tvtime.utils
import androidx.compose.ui.text.intl.Locale
import com.owenlejeune.tvtime.api.tmdb.model.*
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}" }
}
fun getFullPosterPath(tmdbItem: TmdbItem?): String? {
return tmdbItem?.let { getFullPosterPath(tmdbItem.posterPath) }
}
fun getFullPosterPath(image: Image): String? {
return getFullPosterPath(image.filePath)
}
fun getFullBackdropPath(backdropPath: String?): String? {
return backdropPath?.let { "https://www.themoviedb.org/t/p/original${backdropPath}" }
}
fun getFullBackdropPath(detailItem: DetailedItem?): String? {
return detailItem?.let { getFullBackdropPath(detailItem.backdropPath) }
}
fun getFullBackdropPath(image: Image): String? {
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)
}
fun getMovieReleaseYear(movie: DetailedMovie): String {
return movie.releaseDate.split("-")[0]
}
fun getTvStartYear(series: DetailedTv): String {
return series.firstAirDate.split("-")[0]
}
fun getTvEndYear(series: DetailedTv): String {
return series.lastAirDate.split("-")[0]
}
fun convertRuntimeToHoursMinutes(movie: DetailedMovie): String {
movie.runtime?.let { runtime ->
val hours = runtime / 60
val minutes = runtime % 60
return "${hours}h${minutes}"
}
return ""
}
fun getMovieRating(releases: MovieReleaseResults?): String {
if (releases == null) {
return ""
}
val defRegion = "US"
val currentRegion = Locale.current.language
val certifications = HashMap<String, String>()
releases.releaseDates.forEach { releaseDateResult ->
if (releaseDateResult.region == currentRegion || releaseDateResult.region == defRegion) {
val cert = releaseDateResult.releaseDates.firstOrNull { it.certification.isNotEmpty() }
if (cert != null) {
certifications[releaseDateResult.region] = cert.certification
}
}
}
if (certifications.isNotEmpty()) {
return certifications[currentRegion] ?: certifications[defRegion] ?: ""
}
return ""
}
}