From 7fb31cb041909eefb4f06413e51bc9ac96f5cb83 Mon Sep 17 00:00:00 2001 From: Owen LeJeune Date: Fri, 14 Jul 2023 23:15:25 -0400 Subject: [PATCH] begin skeleton loading imeplementation --- .../owenlejeune/tvtime/api/ServiceUtils.kt | 1 + .../tvtime/api/tmdb/api/v3/model/Genre.kt | 15 +- .../tvtime/extensions/AnyExtensions.kt | 4 +- .../tvtime/extensions/ModifierExtensions.kt | 9 +- .../tvtime/ui/components/Actions.kt | 4 + .../tvtime/ui/components/DetailViewCommon.kt | 73 ++- .../tvtime/ui/components/Posters.kt | 22 +- .../tvtime/ui/components/Widgets.kt | 40 +- .../tvtime/ui/screens/MediaDetailScreen.kt | 601 +++++++++++------- .../tvtime/ui/screens/PeopleDetailScreen.kt | 13 +- .../tvtime/ui/viewmodel/MainViewModel.kt | 87 ++- .../com/owenlejeune/tvtime/utils/TmdbUtils.kt | 4 +- 12 files changed, 599 insertions(+), 274 deletions(-) diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/ServiceUtils.kt b/app/src/main/java/com/owenlejeune/tvtime/api/ServiceUtils.kt index 63849a4..9e0cd31 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/ServiceUtils.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/ServiceUtils.kt @@ -1,6 +1,7 @@ package com.owenlejeune.tvtime.api import androidx.compose.runtime.MutableState +import kotlinx.coroutines.delay import retrofit2.Response infix fun Response.storedIn(body: (T) -> Unit) { diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Genre.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Genre.kt index a7d56ad..845ac27 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Genre.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Genre.kt @@ -5,4 +5,17 @@ import com.google.gson.annotations.SerializedName data class Genre( @SerializedName("id") val id: Int, @SerializedName("name") val name: String -) +) { + + companion object { + val placeholderData: List by lazy { + listOf().toMutableList().apply { + for (i in 0 until 3) { + add(Genre(i, " ")) + } + } + } + } + + +} diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt index 9e65b5a..b24fe7c 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt @@ -8,4 +8,6 @@ fun Any.coroutineTask(runnable: suspend () -> Unit) { CoroutineScope(Dispatchers.IO).launch { runnable() } } -fun anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate) \ No newline at end of file +fun anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate) + +fun T.isIn(vararg items: T): Boolean = items.any { it == this } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/ModifierExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/ModifierExtensions.kt index 0669da7..f07f12a 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/ModifierExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/ModifierExtensions.kt @@ -17,7 +17,10 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TileMode -fun Modifier.shimmerBackground(shape: Shape = RectangleShape): Modifier = composed { +fun Modifier.shimmerBackground( + shape: Shape = RectangleShape, + tint: Color = Color.LightGray +): Modifier = composed { val transition = rememberInfiniteTransition() val translateAnimation by transition.animateFloat( initialValue = 0f, @@ -28,8 +31,8 @@ fun Modifier.shimmerBackground(shape: Shape = RectangleShape): Modifier = compos ), ) val shimmerColors = listOf( - Color.LightGray.copy(alpha = 0.9f), - Color.LightGray.copy(alpha = 0.4f), + tint.copy(alpha = 0.9f), + tint.copy(alpha = 0.4f), ) val brush = Brush.linearGradient( colors = shimmerColors, diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Actions.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Actions.kt index 1f9371d..e6076a8 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Actions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Actions.kt @@ -29,10 +29,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.owenlejeune.tvtime.api.LoadingState +import com.owenlejeune.tvtime.extensions.isIn +import com.owenlejeune.tvtime.extensions.shimmerBackground import com.owenlejeune.tvtime.ui.theme.FavoriteSelected import com.owenlejeune.tvtime.ui.theme.RatingSelected import com.owenlejeune.tvtime.ui.theme.WatchlistSelected import com.owenlejeune.tvtime.ui.viewmodel.AccountViewModel +import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.utils.types.MediaViewType import kotlinx.coroutines.launch 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 a6e901e..a929994 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 @@ -6,12 +6,15 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter @@ -23,6 +26,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import coil.compose.rememberAsyncImagePainter import coil.request.CachePolicy @@ -32,9 +36,14 @@ import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.rememberPagerState import com.owenlejeune.tvtime.R +import com.owenlejeune.tvtime.api.LoadingState import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection +import com.owenlejeune.tvtime.extensions.shimmerBackground +import com.owenlejeune.tvtime.extensions.toDp +import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.utils.TmdbUtils +import com.owenlejeune.tvtime.utils.types.MediaViewType import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -54,7 +63,9 @@ fun DetailHeader( ) { Box( modifier = modifier.then( - Modifier.fillMaxWidth().wrapContentHeight() + Modifier + .fillMaxWidth() + .wrapContentHeight() ) ) { if (imageCollection != null) { @@ -119,7 +130,8 @@ private fun BackdropContainer( Box( modifier = Modifier - .matchParentSize() + .width(sizeImage.value.width.toDp()) + .height(sizeImage.value.height.toDp()) .background(gradient) ) } @@ -214,10 +226,35 @@ fun BackdropGallery( @Composable fun ExternalIdsArea( - externalIds: ExternalIds, + type: MediaViewType, + itemId: Int, modifier: Modifier = Modifier ) { - if (externalIds.hasExternalIds()) { + val mainViewModel = viewModel() + val loadingState = remember { mainViewModel.produceExternalIdsLoadingStateFor(type) } + + val externalIdsMap = remember { mainViewModel.produceExternalIdsFor(type) } + val externalIds = externalIdsMap[itemId] + + if (loadingState.value == LoadingState.LOADING || loadingState.value == LoadingState.REFRESHING) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + for (i in 0 until 4) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(5.dp)) + .size(28.dp) + .shimmerBackground( + RoundedCornerShape(5.dp), + tint = MaterialTheme.colorScheme.primary + ) + ) + } + } + } else if (externalIds?.hasExternalIds() == true) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, @@ -264,4 +301,32 @@ private fun ExternalIdLogo( modifier = Modifier.size(28.dp) ) } +} + +@Composable +fun PlaceholderDetailHeader() { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .aspectRatio(1.778f) + .shimmerBackground() + ) { + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.Bottom + ) { + PlaceholderPosterItem() + + Box( + modifier = Modifier + .clip(CircleShape) + .size(60.dp) + .shimmerBackground(tint = MaterialTheme.colorScheme.secondary) + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Posters.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Posters.kt index 696e472..eaa94b5 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Posters.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Posters.kt @@ -172,14 +172,15 @@ fun PosterItem( var sizeImage by remember { mutableStateOf(IntSize.Zero) } Card( elevation = CardDefaults.elevatedCardElevation(defaultElevation = elevation), - modifier = modifier + modifier = modifier.then(Modifier .width(width = width) .height(height = height) .wrapContentHeight() .clickable( enabled = enabled, onClick = onClick - ), + ) + ), shape = RoundedCornerShape(5.dp), colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { @@ -258,4 +259,21 @@ fun PosterItem( } } } +} + +@Composable +fun PlaceholderPosterItem( + modifier: Modifier = Modifier, + width: Dp = POSTER_WIDTH, + height: Dp = POSTER_HEIGHT, + tint: Color = Color.LightGray +) { + Box( + modifier = modifier.then(Modifier + .width(width) + .height(height) + .clip(RoundedCornerShape(5.dp)) + .shimmerBackground(RoundedCornerShape(5.dp), tint) + ) + ) } \ No newline at end of file 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 cbdba10..0e7cb41 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 @@ -68,6 +68,7 @@ import coil.request.ImageRequest import com.google.accompanist.flowlayout.FlowRow import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AuthorDetails +import com.owenlejeune.tvtime.extensions.shimmerBackground import com.owenlejeune.tvtime.extensions.toDp import com.owenlejeune.tvtime.extensions.unlessEmpty import com.owenlejeune.tvtime.preferences.AppPreferences @@ -285,7 +286,17 @@ class ChipInfo( val text: String, val enabled: Boolean = true, val id: Int? = null -) +) { + companion object { + fun generatePlaceholders(size: Int): List { + return emptyList().toMutableList().apply { + for (i in 0 until size) { + add(ChipInfo("")) + } + } + } + } +} sealed class ChipStyle(val mainAxisSpacing: Dp, val crossAxisSpacing: Dp) { object Boxy: ChipStyle(8.dp, 4.dp) @@ -338,15 +349,17 @@ fun BoxyChip( style: TextStyle = MaterialTheme.typography.bodySmall, isSelected: Boolean = true, onSelectionChanged: (ChipInfo) -> Unit = {}, - colors: ChipColors = ChipDefaults.boxyChipColors() + colors: ChipColors = ChipDefaults.boxyChipColors(), + isLoading: Boolean = false ) { + val shimmerModifier = if (isLoading) Modifier.shimmerBackground(shape = RoundedCornerShape(5.dp)) else Modifier Surface( shadowElevation = 2.dp, shape = RoundedCornerShape(5.dp), color = if (isSelected) colors.selectedContainerColor() else colors.unselectedContainerColor() ) { Row( - modifier = Modifier + modifier = shimmerModifier .toggleable( value = isSelected, onValueChange = { @@ -367,21 +380,25 @@ fun BoxyChip( @Composable fun RoundedChip( + modifier: Modifier = Modifier, chipInfo: ChipInfo, style: TextStyle = MaterialTheme.typography.bodySmall, isSelected: Boolean = false, onSelectionChanged: (ChipInfo) -> Unit = {}, - colors: ChipColors = ChipDefaults.roundedChipColors() + colors: ChipColors = ChipDefaults.roundedChipColors(), + isLoading: Boolean = false ) { val borderColor = if (isSelected) colors.selectedContainerColor() else colors.unselectedContainerColor() val radius = style.fontSize.value.dp * 2 Surface( + modifier = modifier, border = BorderStroke(width = 1.dp, borderColor), shape = RoundedCornerShape(radius), color = MaterialTheme.colorScheme.surfaceVariant ) { + val shimmerModifier = if(isLoading) Modifier.shimmerBackground(RoundedCornerShape(radius)) else Modifier Row( - modifier = Modifier + modifier = shimmerModifier .toggleable( value = isSelected, onValueChange = { @@ -403,6 +420,7 @@ fun RoundedChip( @Composable fun ChipGroup( modifier: Modifier = Modifier, + isLoading: Boolean = false, chips: List = emptyList(), onSelectedChanged: (ChipInfo) -> Unit = {}, chipStyle: ChipStyle = ChipStyle.Boxy, @@ -411,24 +429,26 @@ fun ChipGroup( ) { @Composable - fun DrawChip(chipStyle: ChipStyle, chip: ChipInfo) { + fun DrawChip(chipStyle: ChipStyle, chip: ChipInfo, isLoading: Boolean) { when (chipStyle) { ChipStyle.Boxy -> { BoxyChip( chipInfo = chip, onSelectionChanged = onSelectedChanged, - colors = boxyChipColors + colors = boxyChipColors, + isLoading = isLoading ) } ChipStyle.Rounded -> { RoundedChip( chipInfo = chip, onSelectionChanged = onSelectedChanged, - colors = roundedChipColors + colors = roundedChipColors, + isLoading = isLoading ) } is ChipStyle.Mixed -> { - DrawChip(chipStyle = chipStyle.predicate(chip), chip = chip) + DrawChip(chipStyle = chipStyle.predicate(chip), chip = chip, isLoading = isLoading) } } } @@ -439,7 +459,7 @@ fun ChipGroup( mainAxisSpacing = chipStyle.mainAxisSpacing ) { chips.forEach { chip -> - DrawChip(chipStyle = chipStyle, chip = chip) + DrawChip(chipStyle = chipStyle, chip = chip, isLoading = isLoading) } } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt index 5bd1045..1b4fbd0 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -73,6 +72,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.rememberPagerState import com.owenlejeune.tvtime.R +import com.owenlejeune.tvtime.api.LoadingState import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedItem import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedMovie import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedTv @@ -89,8 +89,10 @@ import com.owenlejeune.tvtime.extensions.DateFormat import com.owenlejeune.tvtime.extensions.WindowSizeClass import com.owenlejeune.tvtime.extensions.format import com.owenlejeune.tvtime.extensions.getCalendarYear +import com.owenlejeune.tvtime.extensions.isIn import com.owenlejeune.tvtime.extensions.lazyPagingItems import com.owenlejeune.tvtime.extensions.listItems +import com.owenlejeune.tvtime.extensions.shimmerBackground import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.ui.components.ActionsView import com.owenlejeune.tvtime.ui.components.AvatarImage @@ -109,6 +111,8 @@ import com.owenlejeune.tvtime.ui.components.HtmlText import com.owenlejeune.tvtime.ui.components.ImageGalleryOverlay import com.owenlejeune.tvtime.ui.components.ListContentCard import com.owenlejeune.tvtime.ui.components.PillSegmentedControl +import com.owenlejeune.tvtime.ui.components.PlaceholderDetailHeader +import com.owenlejeune.tvtime.ui.components.PlaceholderPosterItem import com.owenlejeune.tvtime.ui.components.PosterItem import com.owenlejeune.tvtime.ui.components.RoundedChip import com.owenlejeune.tvtime.ui.components.RoundedTextField @@ -121,31 +125,36 @@ import com.owenlejeune.tvtime.ui.viewmodel.SpecialFeaturesViewModel import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.types.MediaViewType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.koin.java.KoinJavaComponent.get +import org.koin.java.KoinJavaComponent -private suspend fun fetchData( +private fun fetchData( mainViewModel: MainViewModel, itemId: Int, type: MediaViewType, force: Boolean = false ) { - mainViewModel.getById(itemId, type, force) - mainViewModel.getImages(itemId, type, force) - mainViewModel.getExternalIds(itemId, type, force) - mainViewModel.getKeywords(itemId, type, force) - mainViewModel.getCastAndCrew(itemId, type, force) - mainViewModel.getSimilar(itemId, type) - mainViewModel.getVideos(itemId, type, force) - mainViewModel.getWatchProviders(itemId, type, force) - mainViewModel.getReviews(itemId, type, force) + val scope = CoroutineScope(Dispatchers.IO) + + scope.launch { mainViewModel.getById(itemId, type, force) } + scope.launch { mainViewModel.getImages(itemId, type, force) } + scope.launch { mainViewModel.getExternalIds(itemId, type, force) } + scope.launch { mainViewModel.getKeywords(itemId, type, force) } + scope.launch { mainViewModel.getCastAndCrew(itemId, type, force) } + scope.launch { mainViewModel.getSimilar(itemId, type) } + scope.launch { mainViewModel.getVideos(itemId, type, force) } + scope.launch { mainViewModel.getWatchProviders(itemId, type, force) } + scope.launch { mainViewModel.getReviews(itemId, type, force) } + scope.launch { mainViewModel.getAccountStates(itemId, type) } when (type) { MediaViewType.MOVIE -> { - mainViewModel.getReleaseDates(itemId, force) + scope.launch { mainViewModel.getReleaseDates(itemId, force) } } MediaViewType.TV -> { - mainViewModel.getContentRatings(itemId, force) + scope.launch { mainViewModel.getContentRatings(itemId, force) } } else -> {} } @@ -159,8 +168,6 @@ fun MediaDetailScreen( type: MediaViewType, windowSize: WindowSizeClass ) { - val scope = rememberCoroutineScope() - val mainViewModel = viewModel() val applicationViewModel = viewModel() @@ -193,12 +200,11 @@ fun MediaDetailScreen( val isRefreshing = remember { mutableStateOf(false) } mainViewModel.monitorDetailsLoadingRefreshing(refreshing = isRefreshing) + val pullRefreshState = rememberPullRefreshState( refreshing = isRefreshing.value, onRefresh = { - scope.launch { - fetchData(mainViewModel, itemId, type, true) - } + fetchData(mainViewModel, itemId, type, true) } ) @@ -232,7 +238,7 @@ fun MediaDetailScreen( windowSize = windowSize, showImageGallery = showGalleryOverlay, pagerState = pagerState, - mainViewModel = mainViewModel + mainViewModel = mainViewModel ) PullRefreshIndicator( refreshing = isRefreshing.value, @@ -254,9 +260,9 @@ fun MediaDetailScreen( } } -@OptIn(ExperimentalPagerApi::class, ExperimentalMaterialApi::class) +@OptIn(ExperimentalPagerApi::class) @Composable -private fun MediaViewContent( +fun MediaViewContent( appNavController: NavController, mainViewModel: MainViewModel, itemId: Int, @@ -266,7 +272,7 @@ private fun MediaViewContent( windowSize: WindowSizeClass, showImageGallery: MutableState, pagerState: PagerState, - preferences: AppPreferences = get(AppPreferences::class.java) + preferences: AppPreferences = KoinJavaComponent.get(AppPreferences::class.java) ) { Row( modifier = Modifier @@ -280,15 +286,22 @@ private fun MediaViewContent( .verticalScroll(state = rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - DetailHeader( - posterUrl = TmdbUtils.getFullPosterPath(mediaItem?.posterPath), - posterContentDescription = mediaItem?.title, - backdropUrl = TmdbUtils.getFullBackdropPath(mediaItem?.backdropPath), - rating = mediaItem?.voteAverage?.let { it / 10 }, - imageCollection = images, - showGalleryOverlay = showImageGallery, - pagerState = pagerState - ) + val detailsLoadingState = remember { mainViewModel.produceDetailsLoadingStateFor(type) } + val isLoading = detailsLoadingState.value.isIn(LoadingState.LOADING, LoadingState.REFRESHING) + + if (isLoading) { + PlaceholderDetailHeader() + } else { + DetailHeader( + posterUrl = TmdbUtils.getFullPosterPath(mediaItem?.posterPath), + posterContentDescription = mediaItem?.title, + backdropUrl = TmdbUtils.getFullBackdropPath(mediaItem?.backdropPath), + rating = mediaItem?.voteAverage?.let { it / 10 }, + imageCollection = images, + showGalleryOverlay = showImageGallery, + pagerState = pagerState + ) + } Column( verticalArrangement = Arrangement.spacedBy(16.dp) @@ -300,13 +313,11 @@ private fun MediaViewContent( itemId = itemId ) - val externalIdsMap = remember { mainViewModel.produceExternalIdsFor(type) } - externalIdsMap[itemId]?.let { - ExternalIdsArea( - externalIds = it, - modifier = Modifier.padding(start = 20.dp) - ) - } + ExternalIdsArea( + modifier = Modifier.padding(start = 20.dp), + type = type, + itemId = itemId + ) val currentSession = remember { SessionManager.currentSession } currentSession.value?.let { @@ -409,7 +420,8 @@ private fun MiscTvDetails( year = TmdbUtils.getSeriesRun(series), runtime = TmdbUtils.convertRuntimeToHoursMinutes(series), genres = series.genres, - contentRating = contentRating + contentRating = contentRating, + type = MediaViewType.TV ) } } @@ -420,23 +432,22 @@ private fun MiscMovieDetails( mediaItem: DetailedItem?, mainViewModel: MainViewModel ) { - mediaItem?.let { mi -> - val movie = mi as DetailedMovie + val movie = mediaItem as? DetailedMovie - val contentRatingsMap = remember { mainViewModel.movieReleaseDates } - val contentRating = TmdbUtils.getMovieRating(contentRatingsMap[itemId]) + val contentRatingsMap = remember { mainViewModel.movieReleaseDates } + val contentRating = TmdbUtils.getMovieRating(contentRatingsMap[itemId]) - MiscDetails( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(horizontal = 16.dp), - year = movie.releaseDate?.getCalendarYear()?.toString() ?: "", - runtime = TmdbUtils.convertRuntimeToHoursMinutes(movie), - genres = movie.genres, - contentRating = contentRating - ) - } + MiscDetails( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp), + year = movie?.releaseDate?.getCalendarYear()?.toString() ?: "", + runtime = TmdbUtils.convertRuntimeToHoursMinutes(movie), + genres = movie?.genres ?: emptyList(), + contentRating = contentRating, + type = MediaViewType.MOVIE + ) } @Composable @@ -445,8 +456,19 @@ private fun MiscDetails( year: String, runtime: String, genres: List, - contentRating: String + contentRating: String, + type: MediaViewType ) { + val mainViewModel = viewModel() + val detailsLoadingState = remember { mainViewModel.produceDetailsLoadingStateFor(type) } + val isLoading = detailsLoadingState.value.isIn(LoadingState.LOADING, LoadingState.REFRESHING) + val shimmerModifier = if (isLoading) { + Modifier + .shimmerBackground(RoundedCornerShape(5.dp)) + .width(40.dp) + } else { + Modifier + } Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) @@ -454,28 +476,35 @@ private fun MiscDetails( Row( modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .wrapContentHeight(), + horizontalArrangement = Arrangement.spacedBy(if (isLoading) 6.dp else 0.dp) ) { - Text(text = year, color = MaterialTheme.colorScheme.onBackground) - if (runtime != "0m") { + Text( + text = year.takeUnless { isLoading } ?: "", + color = MaterialTheme.colorScheme.onBackground, + modifier = shimmerModifier + ) + if (runtime != "0m" || isLoading) { Text( - text = runtime, + text = runtime.takeUnless { isLoading } ?: "", color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 12.dp) + modifier = shimmerModifier.padding(start = 12.dp) ) } Text( - text = contentRating, + text = contentRating.takeUnless { isLoading } ?: "", color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 12.dp) + modifier = shimmerModifier.padding(start = 12.dp) ) } + val chips = if (isLoading) Genre.placeholderData else genres ChipGroup( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), - chips = genres.map { ChipInfo(it.name, false) } + chips = chips.map { ChipInfo(it.name, false) }, + isLoading = isLoading ) } } @@ -492,62 +521,127 @@ private fun OverviewCard( val keywordsMap = remember { mainViewModel.produceKeywordsFor(type) } val keywords = keywordsMap[itemId] - mediaItem?.let { mi -> - if (!mi.tagline.isNullOrEmpty() || keywords?.isNotEmpty() == true || !mi.overview.isNullOrEmpty()) { - ContentCard( - modifier = modifier + val detailsLoadingState = remember { mainViewModel.produceAccountStatesLoadingStateFor(type) } + val isLoading = detailsLoadingState.value.isIn(LoadingState.LOADING, LoadingState.REFRESHING) + + if (isLoading) { + ContentCard( + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Column( + Spacer(modifier = Modifier.height(8.dp)) + Text( modifier = Modifier + .padding(horizontal = 16.dp) + .width(150.dp) + .shimmerBackground(RoundedCornerShape(10.dp)), + text = "", + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + ) + Text( + modifier = Modifier + .padding(horizontal = 16.dp) .fillMaxWidth() - .wrapContentHeight(), - verticalArrangement = Arrangement.spacedBy(8.dp) + .height(100.dp) + .shimmerBackground(RoundedCornerShape(10.dp)), + text = "", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ChipStyle.Rounded.mainAxisSpacing) ) { - Spacer(modifier = Modifier.height(8.dp)) - mi.tagline?.let { tagline -> - if (tagline.isNotEmpty()) { - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = tagline, - color = MaterialTheme.colorScheme.tertiary - , - style = MaterialTheme.typography.bodyLarge, - fontStyle = FontStyle.Italic, - ) - } + Spacer(modifier = Modifier.width(8.dp)) + ChipInfo.generatePlaceholders(4).forEach { keywordChipInfo -> + RoundedChip( + modifier = Modifier.width(60.dp), + isLoading = true, + chipInfo = keywordChipInfo, + colors = ChipDefaults.roundedChipColors( + unselectedContainerColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.primary + ), + onSelectionChanged = {} + ) } - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = mi.overview ?: "", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) + } - - keywords?.let { keywords -> - val keywordsChipInfo = keywords.map { ChipInfo(it.name, true, it.id) } - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(ChipStyle.Rounded.mainAxisSpacing) - ) { - Spacer(modifier = Modifier.width(8.dp)) - keywordsChipInfo.forEach { keywordChipInfo -> - RoundedChip( - chipInfo = keywordChipInfo, - colors = ChipDefaults.roundedChipColors( - unselectedContainerColor = MaterialTheme.colorScheme.primary, - unselectedContentColor = MaterialTheme.colorScheme.primary - ), - onSelectionChanged = { chip -> - appNavController.navigate(AppNavItem.KeywordsView.withArgs(type, chip.text, chip.id!!)) - } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } else { + mediaItem?.let { mi -> + if (!mi.tagline.isNullOrEmpty() || keywords?.isNotEmpty() == true || !mi.overview.isNullOrEmpty()) { + ContentCard( + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) + mi.tagline?.let { tagline -> + if (tagline.isNotEmpty()) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = tagline, + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, ) } - Spacer(modifier = Modifier.width(8.dp)) } - } + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = mi.overview ?: "", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) - Spacer(modifier = Modifier.height(8.dp)) + + keywords?.let { keywords -> + val keywordsChipInfo = keywords.map { ChipInfo(it.name, true, it.id) } + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ChipStyle.Rounded.mainAxisSpacing) + ) { + Spacer(modifier = Modifier.width(8.dp)) + keywordsChipInfo.forEach { keywordChipInfo -> + RoundedChip( + chipInfo = keywordChipInfo, + colors = ChipDefaults.roundedChipColors( + unselectedContainerColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.primary + ), + onSelectionChanged = { chip -> + appNavController.navigate( + AppNavItem.KeywordsView.withArgs( + type, + chip.text, + chip.id!! + ) + ) + } + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } } } } @@ -702,6 +796,9 @@ private fun CastCard( val castMap = remember { mainViewModel.produceCastFor(type) } val cast = castMap[itemId] + val loadingState = remember { mainViewModel.produceDetailsLoadingStateFor(type) } + val isLoading = loadingState.value.isIn(LoadingState.LOADING, LoadingState.REFRESHING) + ContentCard( modifier = modifier, title = stringResource(R.string.cast_label), @@ -718,9 +815,15 @@ private fun CastCard( item { Spacer(modifier = Modifier.width(8.dp)) } - items(cast?.size ?: 0) { i -> - cast?.get(i)?.let { - CastCrewCard(appNavController = appNavController, person = it) + if (isLoading) { + items(5) { + PlaceholderPosterItem() + } + } else { + items(cast?.size ?: 0) { i -> + cast?.get(i)?.let { + CastCrewCard(appNavController = appNavController, person = it) + } } } item { @@ -728,27 +831,40 @@ private fun CastCard( } } - Text( - text = stringResource(R.string.see_all_cast_and_crew), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.inversePrimary, - modifier = Modifier - .padding(start = 12.dp, bottom = 12.dp) - .clickable { - appNavController.navigate( - AppNavItem.CastCrewListView.withArgs( - type, - itemId + if (isLoading) { + Text( + text = "", + modifier = Modifier + .padding(start = 12.dp, bottom = 12.dp) + .width(80.dp) + .shimmerBackground(RoundedCornerShape(10.dp)) + ) + } else { + Text( + text = stringResource(R.string.see_all_cast_and_crew), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.inversePrimary, + modifier = Modifier + .padding(start = 12.dp, bottom = 12.dp) + .clickable { + appNavController.navigate( + AppNavItem.CastCrewListView.withArgs( + type, + itemId + ) ) - ) - } - ) + } + ) + } } } } @Composable -private fun CastCrewCard(appNavController: NavController, person: Person) { +private fun CastCrewCard( + appNavController: NavController, + person: Person +) { TwoLineImageTextCard( title = person.name, modifier = Modifier @@ -952,7 +1068,7 @@ private fun VideoGroup(results: List