diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/DateTypeAdapter.kt b/app/src/main/java/com/owenlejeune/tvtime/api/DateTypeAdapter.kt new file mode 100644 index 0000000..b18da74 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/DateTypeAdapter.kt @@ -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() { + + companion object { + private val acceptedDateFormats: List = 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() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/DetailCastDeserializer.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/DetailCastDeserializer.kt new file mode 100644 index 0000000..5615500 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/DetailCastDeserializer.kt @@ -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() { + + 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}") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/DetailCrewDeserializer.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/DetailCrewDeserializer.kt new file mode 100644 index 0000000..6e2fc20 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/DetailCrewDeserializer.kt @@ -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() { + + 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}") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastCrew.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastCrew.kt new file mode 100644 index 0000000..fcfa7db --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastCrew.kt @@ -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, + @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 +) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCast.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCast.kt index 21a60a5..51de437 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCast.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCast.kt @@ -2,16 +2,66 @@ 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 -class DetailCast( - @SerializedName("id") val id: Int, - @SerializedName("episode_count") val episodeCount: Int, - @SerializedName("overview") val overview: String, - @SerializedName("name", alternate = ["title"]) val name: String, - @SerializedName("media_type") val mediaType: MediaViewType, - @SerializedName("poster_path") val posterPath: String?, - @SerializedName("first_air_date") val firstAirDate: String, - @SerializedName("character") val character: String, - @SerializedName("adult") val isAdult: Boolean, - @SerializedName("release_date") val releaseDate: String -) +abstract class DetailCast( + id: Int, + backdropPath: String?, + genreIds: List, + 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("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, + 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, + 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, + @SerializedName("episode_count") val episodeCount: Int +): DetailCast(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity, + posterPath, releaseDate, title, voteAverage, voteCount, creditId, isAdult, MediaViewType.TV, character) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCrew.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCrew.kt index 920a3fd..8d91c5e 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCrew.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/DetailCrew.kt @@ -2,18 +2,68 @@ 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 -class DetailCrew( - @SerializedName("id") val id: Int, +abstract class DetailCrew( + id: Int, + backdropPath: String?, + genreIds: List, + 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("episode_count") val episodeCount: Int, @SerializedName("job") val job: String, - @SerializedName("overview") val overview: String, - @SerializedName("name") val name: String?, - @SerializedName("media_type") val mediaType: MediaViewType, - @SerializedName("first_air_date") val firstAirDate: String, - @SerializedName("poster_path") val posterPath: String, - @SerializedName("title") val title: String?, - @SerializedName("adult") val isAdult: Boolean, - @SerializedName("release_date") val releaseDate: String -) \ No newline at end of file +): CastCrew(id, backdropPath, genreIds, originalLanguage, originalTitle, overview, popularity, + posterPath, releaseDate, title, voteAverage, voteCount, creditId, isAdult, mediaType) + +class MovieCrew( + id: Int, + backdropPath: String?, + genreIds: List, + 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, + 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, + @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) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt index 62f0748..d811260 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt @@ -2,6 +2,7 @@ package com.owenlejeune.tvtime.di.modules import com.google.gson.GsonBuilder import com.google.gson.JsonDeserializer +import com.google.gson.TypeAdapter import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.api.* 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.TvService 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.SortableSearchResultDeserializer 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.SortableSearchResult 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 org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module +import java.util.Date val networkModule = module { single { if (BuildConfig.DEBUG) DebugHttpClient() else ProdHttpClient() } @@ -58,18 +64,21 @@ val networkModule = module { single { AuthenticationV4Service() } single { ListV4Service() } - single, JsonDeserializer<*>>> { + single, Any>> { mapOf( ListItem::class.java to ListItemDeserializer(), KnownFor::class.java to KnownForDeserializer(), 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 { GsonBuilder().apply { - get, JsonDeserializer<*>>>().forEach { des -> + get, Any>>().forEach { des -> registerTypeAdapter(des.key, des.value) } }.create() diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/DateExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/DateExtensions.kt new file mode 100644 index 0000000..7b6b304 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/DateExtensions.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt index c335b63..ae87372 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt @@ -6,4 +6,16 @@ fun List.lastOr(provider: () -> T): T { } else { provider() } +} + +fun List.bringToFront(predicate: (T) -> Boolean): List { + val orig = toMutableList() + val frontItems = emptyList().toMutableList() + forEach { i -> + if (predicate(i)) { + frontItems.add(i) + orig.remove(i) + } + } + return frontItems.plus(orig) } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt index 315e750..b2facfe 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -172,23 +173,43 @@ fun TwoLineImageTextCard( elevation = 0.dp, overrideShowTitle = false ) - MinLinesText( + Text( modifier = Modifier .fillMaxWidth() .padding(top = 5.dp), - minLines = 2, text = title, + minLines = 2, + maxLines = 2, color = titleTextColor, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis ) - subtitle?.let { - MinLinesText( - modifier = Modifier.fillMaxWidth(), - minLines = 2, - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = subtitleTextColor - ) - } + Text( + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 2, + text = subtitle ?: "", + style = MaterialTheme.typography.bodySmall, + 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 +// ) +// } } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt index e500ce1..8e74b20 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -51,7 +52,8 @@ fun DetailHeader( backdropContentDescription: String? = null, posterContentDescription: String? = null, rating: Float? = null, - pagerState: PagerState? = null + pagerState: PagerState? = null, + elevation: Dp = 20.dp ) { ConstraintLayout(modifier = modifier .fillMaxWidth() @@ -90,7 +92,7 @@ fun DetailHeader( }, url = posterUrl, title = posterContentDescription, - elevation = 20.dp, + elevation = elevation, overrideShowTitle = 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 fun ExternalIdsArea( externalIds: ExternalIds, diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/MediaViews.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/MediaViews.kt index c46b541..5c2d2f5 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/MediaViews.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/MediaViews.kt @@ -24,7 +24,6 @@ import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.utils.types.MediaViewType -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaResultCard( appNavController: NavController, @@ -34,13 +33,14 @@ fun MediaResultCard( posterPath: Any?, title: String, additionalDetails: List, + modifier: Modifier = Modifier, rating: Float? = null ) { Card( shape = RoundedCornerShape(10.dp), elevation = CardDefaults.cardElevation(defaultElevation = 10.dp), - modifier = Modifier - .background(color = MaterialTheme.colorScheme.surface) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = modifier.then(Modifier .fillMaxWidth() .clickable( onClick = { @@ -49,6 +49,7 @@ fun MediaResultCard( ) } ) + ) ) { Box( modifier = Modifier.height(112.dp) diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Widgets.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Widgets.kt index 28ed9fd..b310e71 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Widgets.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Widgets.kt @@ -451,8 +451,8 @@ fun RatingRing( progress: Float = 0f, size: Dp = 60.dp, ringStrokeWidth: Dp = 4.dp, - ringColor: Color = MaterialTheme.colorScheme.primary, - trackColor: Color = MaterialTheme.colorScheme.tertiaryContainer, + ringColor: Color = MaterialTheme.colorScheme.tertiaryContainer, + trackColor: Color = MaterialTheme.colorScheme.tertiary, textColor: Color = Color.White, 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) @Composable fun RoundedTextField( diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt index 9b5962e..25b4880 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt @@ -22,6 +22,7 @@ import com.owenlejeune.tvtime.ui.screens.AboutScreen import com.owenlejeune.tvtime.ui.screens.AccountScreen import com.owenlejeune.tvtime.ui.screens.HomeScreen import com.owenlejeune.tvtime.ui.screens.KeywordResultsScreen +import com.owenlejeune.tvtime.ui.screens.KnownForScreen import com.owenlejeune.tvtime.ui.screens.ListDetailScreen import com.owenlejeune.tvtime.ui.screens.MediaDetailScreen import com.owenlejeune.tvtime.ui.screens.PersonDetailScreen @@ -176,6 +177,16 @@ fun AppNavigationHost( 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") { 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") + } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/KnownForScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/KnownForScreen.kt new file mode 100644 index 0000000..3f37189 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/KnownForScreen.kt @@ -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() + + 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().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) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/PeopleDetailScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/PeopleDetailScreen.kt index ef4c8f4..826f656 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/PeopleDetailScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/PeopleDetailScreen.kt @@ -1,6 +1,7 @@ package com.owenlejeune.tvtime.ui.screens import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController 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.utils.TmdbUtils import com.owenlejeune.tvtime.utils.types.MediaViewType +import java.lang.Integer.min + +private const val TAG = "PeopleDetailScreen" @OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class) @Composable @@ -102,11 +107,10 @@ fun PersonDetailScreen( ) { DetailHeader( posterUrl = TmdbUtils.getFullPersonImagePath(person?.profilePath), - posterContentDescription = person?.profilePath + posterContentDescription = person?.profilePath, + elevation = 0.dp ) - BiographyCard(person = person) - val externalIdsMap = remember { mainViewModel.peopleExternalIdsMap } val externalIds = externalIdsMap[personId] externalIds?.let { @@ -116,8 +120,10 @@ fun PersonDetailScreen( ) } + BiographyCard(person = person) + val creditsMap = remember { mainViewModel.peopleCastMap } - val credits = creditsMap[personId] + val credits = creditsMap[personId] ?: emptyList() ContentCard( title = stringResource(R.string.known_for_label) @@ -129,11 +135,11 @@ fun PersonDetailScreen( .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - items(credits?.size ?: 0) { i -> - val content = credits!![i] + items(min(credits.size, 15)) { i -> + val content = credits[i] TwoLineImageTextCard( - title = content.name, + title = content.title, titleTextColor = MaterialTheme.colorScheme.primary, subtitle = content.character, modifier = Modifier @@ -148,54 +154,17 @@ fun PersonDetailScreen( ) } } - } - val crewMap = remember { mainViewModel.peopleCrewMap } - val crewCredits = crewMap[personId] - val departments = crewCredits?.map { it.department }?.toSet() ?: emptySet() - if (departments.isNotEmpty()) { - ContentCard(title = stringResource(R.string.also_known_for_label)) { - Column( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(12.dp), - 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) - ) - } - ) - } - } + Text( + text = stringResource(id = R.string.expand_see_all), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + modifier = Modifier + .padding(start = 16.dp, bottom = 16.dp) + .clickable { + appNavController.navigate(AppNavItem.KnownForView.withArgs(personId)) } - } - } + ) } } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt b/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt index b4324e4..17de5f6 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt @@ -1,5 +1,6 @@ package com.owenlejeune.tvtime.utils +import android.nfc.FormatException 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.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.ui.viewmodel.ConfigurationViewModel import java.text.SimpleDateFormat +import java.util.Date object TmdbUtils { @@ -251,4 +253,9 @@ object TmdbUtils { 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") + } + } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bdc411..f481c23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,7 @@ See more See less + See all Search %1$s @@ -230,4 +231,10 @@ Streaming Rent Buy + + Actor + Production + as %1$s + as %1$s (%2$d eps.) + … %1$s \ No newline at end of file