move full actor credits list to separate screen

This commit is contained in:
Owen LeJeune
2023-06-26 21:45:26 -04:00
parent ef11a88a90
commit ca878e3577
18 changed files with 538 additions and 122 deletions

View File

@@ -0,0 +1,48 @@
package com.owenlejeune.tvtime.api
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class DateTypeAdapter: TypeAdapter<Date>() {
companion object {
private val acceptedDateFormats: List<String> = listOf(
"yyyy-MM-dd",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
)
}
override fun read(jrIn: JsonReader): Date? {
if (jrIn.peek() == JsonToken.NULL) {
jrIn.nextNull()
throw Exception("JSON must not be null")
}
val dateFields = jrIn.nextString()
if (dateFields.isEmpty()) {
return null
}
for (dateFormat in acceptedDateFormats) {
try {
val formatter = SimpleDateFormat(dateFormat, Locale.getDefault())
return formatter.parse(dateFields) ?: throw Exception("Parsed date cannot be null")
} catch (e: Exception) {
continue
}
}
throw Exception("No accepted date format to match date string \"$dateFields\"")
}
override fun write(jrOut: JsonWriter, value: Date?) {
value?.let {
jrOut.value(it.toString())
} ?: run {
jrOut.nullValue()
}
}
}

View File

@@ -0,0 +1,25 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer
import com.google.gson.JsonObject
import com.google.gson.JsonParseException
import com.owenlejeune.tvtime.api.tmdb.api.BaseDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCast
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCast
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCast
import com.owenlejeune.tvtime.utils.types.MediaViewType
class DetailCastDeserializer: BaseDeserializer<DetailCast>() {
override fun processJson(obj: JsonObject): DetailCast {
if (obj.has(MediaViewType.JSON_KEY)) {
val typeStr = obj.get(MediaViewType.JSON_KEY).asString
return when (gson.fromJson(typeStr, MediaViewType::class.java)) {
MediaViewType.MOVIE -> gson.fromJson(obj.toString(), MovieCast::class.java)
MediaViewType.TV -> gson.fromJson(obj.toString(), TvCast::class.java)
else -> throw JsonParseException("Not a valid MediaViewType: $typeStr")
}
}
throw JsonParseException("JSON object has no property ${MediaViewType.JSON_KEY}")
}
}

View File

@@ -0,0 +1,25 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer
import com.google.gson.JsonObject
import com.google.gson.JsonParseException
import com.owenlejeune.tvtime.api.tmdb.api.BaseDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCrew
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCrew
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCrew
import com.owenlejeune.tvtime.utils.types.MediaViewType
class DetailCrewDeserializer: BaseDeserializer<DetailCrew>() {
override fun processJson(obj: JsonObject): DetailCrew {
if (obj.has(MediaViewType.JSON_KEY)) {
val typeStr = obj.get(MediaViewType.JSON_KEY).asString
return when (gson.fromJson(typeStr, MediaViewType::class.java)) {
MediaViewType.MOVIE -> gson.fromJson(obj.toString(), MovieCrew::class.java)
MediaViewType.TV -> gson.fromJson(obj.toString(), TvCrew::class.java)
else -> throw JsonParseException("Not a valid MediaViewType: $typeStr")
}
}
throw JsonParseException("JSON object has no property ${MediaViewType.JSON_KEY}")
}
}

View File

@@ -0,0 +1,23 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName
import com.owenlejeune.tvtime.utils.types.MediaViewType
import java.util.Date
abstract class CastCrew(
@SerializedName("id") val id: Int,
@SerializedName("backdrop_path") val backdropPath: String?,
@SerializedName("genre_ids") val genreIds: List<Int>,
@SerializedName("original_language") val originalLanguage: String,
@SerializedName("original_title", alternate = ["original_name"]) val originalTitle: String,
@SerializedName("overview") val overview: String,
@SerializedName("popularity") val popularity: Float,
@SerializedName("poster_path") val posterPath: String?,
@SerializedName("release_date", alternate = ["first_air_date"]) val releaseDate: Date?,
@SerializedName("title", alternate = ["name"]) val title: String,
@SerializedName("vote_average") val voteAverage: Float,
@SerializedName("vote_count") val voteCount: Int,
@SerializedName("credit_id") val creditId: String,
@SerializedName("adult") val isAdult: Boolean,
@SerializedName("media_type") val mediaType: MediaViewType
)

View File

@@ -2,16 +2,66 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import java.util.Date
class DetailCast( abstract class DetailCast(
@SerializedName("id") val id: Int, id: Int,
@SerializedName("episode_count") val episodeCount: Int, backdropPath: String?,
@SerializedName("overview") val overview: String, genreIds: List<Int>,
@SerializedName("name", alternate = ["title"]) val name: String, originalLanguage: String,
@SerializedName("media_type") val mediaType: MediaViewType, originalTitle: String,
@SerializedName("poster_path") val posterPath: String?, overview: String,
@SerializedName("first_air_date") val firstAirDate: String, popularity: Float,
@SerializedName("character") val character: String, posterPath: String?,
@SerializedName("adult") val isAdult: Boolean, releaseDate: Date?,
@SerializedName("release_date") val releaseDate: String title: String,
) voteAverage: Float,
voteCount: Int,
creditId: String,
isAdult: Boolean,
mediaType: MediaViewType,
@SerializedName("character") val character: String
): CastCrew(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity,
posterPath, releaseDate, title, voteAverage, voteCount, creditId, isAdult, mediaType)
class MovieCast(
id: Int,
isAdult: Boolean,
backdropPath: String?,
genreIds: List<Int>,
originalLanguage: String,
originalTitle: String,
overview: String,
popularity: Float,
posterPath: String?,
releaseDate: Date?,
title: String,
voteAverage: Float,
voteCount: Int,
creditId: String,
character: String,
@SerializedName("video") val isVideo: Boolean,
@SerializedName("order") val order: Int
): DetailCast(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity,
posterPath, releaseDate, title, voteAverage, voteCount, creditId, isAdult, MediaViewType.MOVIE, character)
class TvCast(
id: Int,
isAdult: Boolean,
backdropPath: String?,
genreIds: List<Int>,
originalLanguage: String,
originalTitle: String,
overview: String,
popularity: Float,
posterPath: String?,
releaseDate: Date?,
title: String,
voteAverage: Float,
voteCount: Int,
creditId: String,
character: String,
@SerializedName("origin_country") val originCountry: List<String>,
@SerializedName("episode_count") val episodeCount: Int
): DetailCast(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity,
posterPath, releaseDate, title, voteAverage, voteCount, creditId, isAdult, MediaViewType.TV, character)

View File

@@ -2,18 +2,68 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import java.util.Date
class DetailCrew( abstract class DetailCrew(
@SerializedName("id") val id: Int, id: Int,
backdropPath: String?,
genreIds: List<Int>,
originalLanguage: String,
originalTitle: String,
overview: String,
popularity: Float,
posterPath: String?,
releaseDate: Date?,
title: String,
voteAverage: Float,
voteCount: Int,
creditId: String,
isAdult: Boolean,
mediaType: MediaViewType,
@SerializedName("department") val department: String, @SerializedName("department") val department: String,
@SerializedName("episode_count") val episodeCount: Int,
@SerializedName("job") val job: String, @SerializedName("job") val job: String,
@SerializedName("overview") val overview: String, ): CastCrew(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity,
@SerializedName("name") val name: String?, posterPath, releaseDate, title, voteAverage, voteCount, creditId, isAdult, mediaType)
@SerializedName("media_type") val mediaType: MediaViewType,
@SerializedName("first_air_date") val firstAirDate: String, class MovieCrew(
@SerializedName("poster_path") val posterPath: String, id: Int,
@SerializedName("title") val title: String?, backdropPath: String?,
@SerializedName("adult") val isAdult: Boolean, genreIds: List<Int>,
@SerializedName("release_date") val releaseDate: String originalLanguage: String,
) isAdult: Boolean,
originalTitle: String,
overview: String,
popularity: Float,
posterPath: String?,
releaseDate: Date?,
title: String,
voteAverage: Float,
voteCount: Int,
creditId: String,
department: String,
job: String,
@SerializedName("video") val isVideo: Boolean
): DetailCrew(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity, posterPath,
releaseDate, title, voteAverage, voteCount, creditId, isAdult, MediaViewType.MOVIE, department, job)
class TvCrew(
id: Int,
backdropPath: String?,
genreIds: List<Int>,
originalLanguage: String,
isAdult: Boolean,
originalTitle: String,
overview: String,
popularity: Float,
posterPath: String?,
releaseDate: Date?,
title: String,
voteAverage: Float,
voteCount: Int,
creditId: String,
department: String,
job: String,
@SerializedName("original_country") val originalCountry: List<String>,
@SerializedName("episode_count") val episodeCount: Int
): DetailCrew(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity, posterPath,
releaseDate, title, voteAverage, voteCount, creditId, isAdult, MediaViewType.TV, department, job)

View File

@@ -2,6 +2,7 @@ package com.owenlejeune.tvtime.di.modules
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonDeserializer import com.google.gson.JsonDeserializer
import com.google.gson.TypeAdapter
import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.BuildConfig
import com.owenlejeune.tvtime.api.* import com.owenlejeune.tvtime.api.*
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.TmdbClient
@@ -13,9 +14,13 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.PeopleService
import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchService import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchService
import com.owenlejeune.tvtime.api.tmdb.api.v3.TvService import com.owenlejeune.tvtime.api.tmdb.api.v3.TvService
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.AccountStatesDeserializer import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.AccountStatesDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.DetailCastDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.DetailCrewDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.KnownForDeserializer import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.KnownForDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.SortableSearchResultDeserializer import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.SortableSearchResultDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCast
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCrew
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KnownFor import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KnownFor
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Service import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Service
@@ -29,6 +34,7 @@ import com.owenlejeune.tvtime.ui.viewmodel.SettingsViewModel
import com.owenlejeune.tvtime.utils.ResourceUtils import com.owenlejeune.tvtime.utils.ResourceUtils
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import java.util.Date
val networkModule = module { val networkModule = module {
single { if (BuildConfig.DEBUG) DebugHttpClient() else ProdHttpClient() } single { if (BuildConfig.DEBUG) DebugHttpClient() else ProdHttpClient() }
@@ -58,18 +64,21 @@ val networkModule = module {
single { AuthenticationV4Service() } single { AuthenticationV4Service() }
single { ListV4Service() } single { ListV4Service() }
single<Map<Class<*>, JsonDeserializer<*>>> { single<Map<Class<*>, Any>> {
mapOf( mapOf(
ListItem::class.java to ListItemDeserializer(), ListItem::class.java to ListItemDeserializer(),
KnownFor::class.java to KnownForDeserializer(), KnownFor::class.java to KnownForDeserializer(),
SortableSearchResult::class.java to SortableSearchResultDeserializer(), SortableSearchResult::class.java to SortableSearchResultDeserializer(),
AccountStates::class.java to AccountStatesDeserializer() AccountStates::class.java to AccountStatesDeserializer(),
DetailCast::class.java to DetailCastDeserializer(),
DetailCrew::class.java to DetailCrewDeserializer(),
Date::class.java to DateTypeAdapter()
) )
} }
single { single {
GsonBuilder().apply { GsonBuilder().apply {
get<Map<Class<*>, JsonDeserializer<*>>>().forEach { des -> get<Map<Class<*>, Any>>().forEach { des ->
registerTypeAdapter(des.key, des.value) registerTypeAdapter(des.key, des.value)
} }
}.create() }.create()

View File

@@ -0,0 +1,10 @@
package com.owenlejeune.tvtime.extensions
import java.util.Calendar
import java.util.Date
fun Date.getCalendarYear(): Int {
return Calendar.getInstance().apply {
time = this@getCalendarYear
}.get(Calendar.YEAR)
}

View File

@@ -7,3 +7,15 @@ fun <T> List<T>.lastOr(provider: () -> T): T {
provider() provider()
} }
} }
fun <T> List<T>.bringToFront(predicate: (T) -> Boolean): List<T> {
val orig = toMutableList()
val frontItems = emptyList<T>().toMutableList()
forEach { i ->
if (predicate(i)) {
frontItems.add(i)
orig.remove(i)
}
}
return frontItems.plus(orig)
}

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -172,23 +173,43 @@ fun TwoLineImageTextCard(
elevation = 0.dp, elevation = 0.dp,
overrideShowTitle = false overrideShowTitle = false
) )
MinLinesText( Text(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 5.dp), .padding(top = 5.dp),
minLines = 2,
text = title, text = title,
minLines = 2,
maxLines = 2,
color = titleTextColor, color = titleTextColor,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis
) )
subtitle?.let { Text(
MinLinesText(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minLines = 2, minLines = 2,
text = subtitle, maxLines = 2,
text = subtitle ?: "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = subtitleTextColor color = subtitleTextColor,
overflow = TextOverflow.Ellipsis
) )
} // MinLinesText(
// modifier = Modifier
// .fillMaxWidth()
// .padding(top = 5.dp),
// minLines = 2,
// text = title,
// color = titleTextColor,
// style = MaterialTheme.typography.bodyMedium
// )
// subtitle?.let {
// MinLinesText(
// modifier = Modifier.fillMaxWidth(),
// minLines = 2,
// text = subtitle,
// style = MaterialTheme.typography.bodySmall,
// color = subtitleTextColor
// )
// }
} }
} }

View File

@@ -21,6 +21,7 @@ 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.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
@@ -51,7 +52,8 @@ fun DetailHeader(
backdropContentDescription: String? = null, backdropContentDescription: String? = null,
posterContentDescription: String? = null, posterContentDescription: String? = null,
rating: Float? = null, rating: Float? = null,
pagerState: PagerState? = null pagerState: PagerState? = null,
elevation: Dp = 20.dp
) { ) {
ConstraintLayout(modifier = modifier ConstraintLayout(modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -90,7 +92,7 @@ fun DetailHeader(
}, },
url = posterUrl, url = posterUrl,
title = posterContentDescription, title = posterContentDescription,
elevation = 20.dp, elevation = elevation,
overrideShowTitle = false, overrideShowTitle = false,
enabled = false enabled = false
) )
@@ -208,29 +210,6 @@ fun BackdropGallery(
} }
} }
@Composable
fun RatingView(
progress: Float,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(CircleShape)
.size(60.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant)
) {
RatingRing(
modifier = Modifier.padding(5.dp),
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
progress = progress,
textSize = 14.sp,
ringColor = MaterialTheme.colorScheme.primary,
ringStrokeWidth = 4.dp,
size = 50.dp
)
}
}
@Composable @Composable
fun ExternalIdsArea( fun ExternalIdsArea(
externalIds: ExternalIds, externalIds: ExternalIds,

View File

@@ -24,7 +24,6 @@ import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MediaResultCard( fun MediaResultCard(
appNavController: NavController, appNavController: NavController,
@@ -34,13 +33,14 @@ fun MediaResultCard(
posterPath: Any?, posterPath: Any?,
title: String, title: String,
additionalDetails: List<String>, additionalDetails: List<String>,
modifier: Modifier = Modifier,
rating: Float? = null rating: Float? = null
) { ) {
Card( Card(
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp), elevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
modifier = Modifier colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
.background(color = MaterialTheme.colorScheme.surface) modifier = modifier.then(Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable( .clickable(
onClick = { onClick = {
@@ -49,6 +49,7 @@ fun MediaResultCard(
) )
} }
) )
)
) { ) {
Box( Box(
modifier = Modifier.height(112.dp) modifier = Modifier.height(112.dp)

View File

@@ -451,8 +451,8 @@ fun RatingRing(
progress: Float = 0f, progress: Float = 0f,
size: Dp = 60.dp, size: Dp = 60.dp,
ringStrokeWidth: Dp = 4.dp, ringStrokeWidth: Dp = 4.dp,
ringColor: Color = MaterialTheme.colorScheme.primary, ringColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
trackColor: Color = MaterialTheme.colorScheme.tertiaryContainer, trackColor: Color = MaterialTheme.colorScheme.tertiary,
textColor: Color = Color.White, textColor: Color = Color.White,
textSize: TextUnit = 14.sp textSize: TextUnit = 14.sp
) { ) {
@@ -478,6 +478,29 @@ fun RatingRing(
} }
} }
@Composable
fun RatingView(
progress: Float,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(CircleShape)
.size(60.dp)
.background(color = MaterialTheme.colorScheme.secondary)
) {
RatingRing(
modifier = Modifier.padding(5.dp),
textColor = MaterialTheme.colorScheme.onSecondary,
progress = progress,
textSize = 14.sp,
ringStrokeWidth = 4.dp,
size = 50.dp
)
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RoundedTextField( fun RoundedTextField(

View File

@@ -22,6 +22,7 @@ import com.owenlejeune.tvtime.ui.screens.AboutScreen
import com.owenlejeune.tvtime.ui.screens.AccountScreen import com.owenlejeune.tvtime.ui.screens.AccountScreen
import com.owenlejeune.tvtime.ui.screens.HomeScreen import com.owenlejeune.tvtime.ui.screens.HomeScreen
import com.owenlejeune.tvtime.ui.screens.KeywordResultsScreen import com.owenlejeune.tvtime.ui.screens.KeywordResultsScreen
import com.owenlejeune.tvtime.ui.screens.KnownForScreen
import com.owenlejeune.tvtime.ui.screens.ListDetailScreen 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
@@ -176,6 +177,16 @@ fun AppNavigationHost(
appNavController = appNavController 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)!!
KnownForScreen(appNavController = appNavController, id = id)
}
} }
} }
@@ -203,5 +214,8 @@ sealed class AppNavItem(val route: String) {
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") {
fun withArgs(id: Int) = route.plus("/$id")
}
} }

View File

@@ -0,0 +1,143 @@
package com.owenlejeune.tvtime.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCrew
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCast
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCast
import com.owenlejeune.tvtime.extensions.bringToFront
import com.owenlejeune.tvtime.extensions.getCalendarYear
import com.owenlejeune.tvtime.ui.components.MediaResultCard
import com.owenlejeune.tvtime.ui.components.SelectableTextChip
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KnownForScreen(
appNavController: NavController,
id: Int
) {
val mainViewModel = viewModel<MainViewModel>()
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background)
systemUiController.setNavigationBarColor(color = MaterialTheme.colorScheme.background)
val peopleMap = remember { mainViewModel.peopleMap }
val person = peopleMap[id]
val topAppBarScrollState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarScrollState)
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults
.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.primary
),
title = { Text(text = person?.name ?: "") },
navigationIcon = {
IconButton(onClick = { appNavController.popBackStack() }) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.content_description_back_button),
tint = MaterialTheme.colorScheme.primary
)
}
}
)
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
val castCreditsMap = remember { mainViewModel.peopleCastMap }
val crewCreditsMap = remember { mainViewModel.peopleCrewMap }
val castCredits = castCreditsMap[id]?.sortedByDescending { it.releaseDate }?.bringToFront { it.releaseDate == null } ?: emptyList()
val crewCredits = crewCreditsMap[id]?.sortedByDescending { it.releaseDate }?.bringToFront { it.releaseDate == null } ?: emptyList()
var actorSelected by remember { mutableStateOf(true) }
val items = if (actorSelected) castCredits else crewCredits
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Row(
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SelectableTextChip(
selected = actorSelected,
onSelected = { actorSelected = true },
text = stringResource(id = R.string.actor_label),
selectedColor = MaterialTheme.colorScheme.tertiary,
unselectedColor = MaterialTheme.colorScheme.background
)
SelectableTextChip(
selected = !actorSelected,
onSelected = { actorSelected = false },
text = stringResource(id = R.string.production_label),
selectedColor = MaterialTheme.colorScheme.tertiary,
unselectedColor = MaterialTheme.colorScheme.background
)
}
}
items(items) { item ->
val additionalDetails = emptyList<String>().toMutableList()
when (item) {
is MovieCast -> additionalDetails.add(stringResource(id = R.string.cast_character_template, item.character))
is TvCast -> additionalDetails.add(stringResource(id = R.string.cast_tv_character_template, item.character, item.episodeCount))
is DetailCrew -> additionalDetails.add(stringResource(id = R.string.crew_template, item.job))
}
val releaseYear = item.releaseDate?.getCalendarYear() ?: ""
MediaResultCard(
appNavController = appNavController,
mediaViewType = item.mediaType,
id = item.id,
backdropPath = TmdbUtils.getFullBackdropPath(item.backdropPath),
posterPath = TmdbUtils.getFullPosterPath(item.posterPath),
title = "${item.title}$releaseYear",
additionalDetails = additionalDetails,
modifier = Modifier.padding(horizontal = 12.dp)
)
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
package com.owenlejeune.tvtime.ui.screens package com.owenlejeune.tvtime.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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
@@ -30,6 +31,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow 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 com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.ExperimentalPagerApi
@@ -45,6 +47,9 @@ 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
import java.lang.Integer.min
private const val TAG = "PeopleDetailScreen"
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class)
@Composable @Composable
@@ -102,11 +107,10 @@ fun PersonDetailScreen(
) { ) {
DetailHeader( DetailHeader(
posterUrl = TmdbUtils.getFullPersonImagePath(person?.profilePath), posterUrl = TmdbUtils.getFullPersonImagePath(person?.profilePath),
posterContentDescription = person?.profilePath posterContentDescription = person?.profilePath,
elevation = 0.dp
) )
BiographyCard(person = person)
val externalIdsMap = remember { mainViewModel.peopleExternalIdsMap } val externalIdsMap = remember { mainViewModel.peopleExternalIdsMap }
val externalIds = externalIdsMap[personId] val externalIds = externalIdsMap[personId]
externalIds?.let { externalIds?.let {
@@ -116,8 +120,10 @@ fun PersonDetailScreen(
) )
} }
BiographyCard(person = person)
val creditsMap = remember { mainViewModel.peopleCastMap } val creditsMap = remember { mainViewModel.peopleCastMap }
val credits = creditsMap[personId] val credits = creditsMap[personId] ?: emptyList()
ContentCard( ContentCard(
title = stringResource(R.string.known_for_label) title = stringResource(R.string.known_for_label)
@@ -129,11 +135,11 @@ fun PersonDetailScreen(
.padding(12.dp), .padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(credits?.size ?: 0) { i -> items(min(credits.size, 15)) { i ->
val content = credits!![i] val content = credits[i]
TwoLineImageTextCard( TwoLineImageTextCard(
title = content.name, title = content.title,
titleTextColor = MaterialTheme.colorScheme.primary, titleTextColor = MaterialTheme.colorScheme.primary,
subtitle = content.character, subtitle = content.character,
modifier = Modifier modifier = Modifier
@@ -148,58 +154,21 @@ fun PersonDetailScreen(
) )
} }
} }
}
val crewMap = remember { mainViewModel.peopleCrewMap } Text(
val crewCredits = crewMap[personId] text = stringResource(id = R.string.expand_see_all),
val departments = crewCredits?.map { it.department }?.toSet() ?: emptySet() color = MaterialTheme.colorScheme.onSurfaceVariant,
if (departments.isNotEmpty()) { fontSize = 12.sp,
ContentCard(title = stringResource(R.string.also_known_for_label)) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .padding(start = 16.dp, bottom = 16.dp)
.wrapContentHeight() .clickable {
.padding(12.dp), appNavController.navigate(AppNavItem.KnownForView.withArgs(personId))
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
departments.forEach { department ->
Text(text = department, color = MaterialTheme.colorScheme.primary)
LazyRow(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
val jobsInDepartment = crewCredits!!.filter { it.department == department }
items(jobsInDepartment.size) { i ->
val content = jobsInDepartment[i]
val title = if (content.mediaType == MediaViewType.MOVIE) {
content.title ?: ""
} else {
content.name ?: ""
}
TwoLineImageTextCard(
title = title,
subtitle = content.job,
modifier = Modifier
.width(124.dp)
.wrapContentHeight(),
imageUrl = TmdbUtils.getFullPosterPath(content.posterPath),
onItemClicked = {
appNavController.navigate(
AppNavItem.DetailView.withArgs(content.mediaType, content.id)
)
} }
) )
} }
} }
} }
} }
}
}
}
}
}
} }
@Composable @Composable

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.utils package com.owenlejeune.tvtime.utils
import android.nfc.FormatException
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountDetails import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountDetails
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AuthorDetails import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AuthorDetails
@@ -16,6 +17,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvContentRatings
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date
object TmdbUtils { object TmdbUtils {
@@ -251,4 +253,9 @@ object TmdbUtils {
return origFormat.parse(inDate)?.let { outFormat.format(it) } return origFormat.parse(inDate)?.let { outFormat.format(it) }
} }
fun toDate(releaseDate: String): Date {
val format = SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault())
return format.parse(releaseDate) ?: throw FormatException("Expected date format \"yyyy-MM-dd\", got $releaseDate")
}
} }

View File

@@ -35,6 +35,7 @@
<string name="expandable_see_more">See more</string> <string name="expandable_see_more">See more</string>
<string name="expandable_see_less">See less</string> <string name="expandable_see_less">See less</string>
<string name="expand_see_all">See all</string>
<string name="search_placeholder">Search %1$s</string> <string name="search_placeholder">Search %1$s</string>
@@ -230,4 +231,10 @@
<string name="streaming_label">Streaming</string> <string name="streaming_label">Streaming</string>
<string name="rent_label">Rent</string> <string name="rent_label">Rent</string>
<string name="buy_label">Buy</string> <string name="buy_label">Buy</string>
<string name="actor_label">Actor</string>
<string name="production_label">Production</string>
<string name="cast_character_template">as %1$s</string>
<string name="cast_tv_character_template">as %1$s (%2$d eps.)</string>
<string name="crew_template">… %1$s</string>
</resources> </resources>