begin skeleton loading imeplementation

This commit is contained in:
Owen LeJeune
2023-07-14 23:15:25 -04:00
parent 862dfb2341
commit 7fb31cb041
12 changed files with 599 additions and 274 deletions

View File

@@ -1,6 +1,7 @@
package com.owenlejeune.tvtime.api
import androidx.compose.runtime.MutableState
import kotlinx.coroutines.delay
import retrofit2.Response
infix fun <T> Response<T>.storedIn(body: (T) -> Unit) {

View File

@@ -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<Genre> by lazy {
listOf<Genre>().toMutableList().apply {
for (i in 0 until 3) {
add(Genre(i, " "))
}
}
}
}
}

View File

@@ -8,4 +8,6 @@ fun Any.coroutineTask(runnable: suspend () -> Unit) {
CoroutineScope(Dispatchers.IO).launch { runnable() }
}
fun <T> anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate)
fun <T> anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate)
fun <T: Any> T.isIn(vararg items: T): Boolean = items.any { it == this }

View File

@@ -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,

View File

@@ -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

View File

@@ -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<MainViewModel>()
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)
)
}
}
}

View File

@@ -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)
)
)
}

View File

@@ -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<ChipInfo> {
return emptyList<ChipInfo>().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<ChipInfo> = 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)
}
}
}

View File

@@ -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<MainViewModel>()
val applicationViewModel = viewModel<ApplicationViewModel>()
@@ -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<Boolean>,
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<Genre>,
contentRating: String
contentRating: String,
type: MediaViewType
) {
val mainViewModel = viewModel<MainViewModel>()
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<Video>, type: Video.Type, title: String) {
val posterWidth = 120.dp
LazyRow(modifier = Modifier
.padding(vertical = 8.dp),
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
item {
@@ -1193,124 +1309,124 @@ private fun ReviewsCard(
val reviewsMap = remember { mainViewModel.produceReviewsFor(type) }
val reviews = reviewsMap[itemId]
ListContentCard(
modifier = modifier,
header = {
Column(
verticalArrangement = Arrangement.spacedBy(9.dp)
) {
Text(
text = stringResource(R.string.reviews_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ListContentCard(
modifier = modifier,
header = {
Column(
verticalArrangement = Arrangement.spacedBy(9.dp)
) {
Text(
text = stringResource(R.string.reviews_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (SessionManager.currentSession.value?.isAuthorized == true) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
var reviewTextState by remember { mutableStateOf("") }
if (SessionManager.currentSession.value?.isAuthorized == true) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
var reviewTextState by remember { mutableStateOf("") }
RoundedTextField(
modifier = Modifier
.height(40.dp)
.align(Alignment.CenterVertically)
.weight(1f),
value = reviewTextState,
onValueChange = { reviewTextState = it },
placeHolder = stringResource(R.string.add_a_review_hint),
backgroundColor = MaterialTheme.colorScheme.secondary,
placeHolderTextColor = MaterialTheme.colorScheme.background,
textColor = MaterialTheme.colorScheme.onSecondary
)
RoundedTextField(
modifier = Modifier
.height(40.dp)
.align(Alignment.CenterVertically)
.weight(1f),
value = reviewTextState,
onValueChange = { reviewTextState = it },
placeHolder = stringResource(R.string.add_a_review_hint),
backgroundColor = MaterialTheme.colorScheme.secondary,
placeHolderTextColor = MaterialTheme.colorScheme.background,
textColor = MaterialTheme.colorScheme.onSecondary
)
val context = LocalContext.current
CircleBackgroundColorImage(
modifier = Modifier
.align(Alignment.CenterVertically)
.clickable(
onClick = {
Toast
.makeText(context, "TODO", Toast.LENGTH_SHORT)
.show()
}
),
size = 40.dp,
backgroundColor = MaterialTheme.colorScheme.tertiary,
image = Icons.Filled.Send,
colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.surfaceVariant),
contentDescription = ""
)
}
}
}
},
) {
if (reviews?.isNotEmpty() == true) {
reviews.reversed().forEachIndexed { index, review ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = 16.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
AvatarImage(
size = 50.dp,
author = review.authorDetails
)
val context = LocalContext.current
CircleBackgroundColorImage(
modifier = Modifier
.align(Alignment.CenterVertically)
.clickable(
onClick = {
Toast
.makeText(context, "TODO", Toast.LENGTH_SHORT)
.show()
}
),
size = 40.dp,
backgroundColor = MaterialTheme.colorScheme.tertiary,
image = Icons.Filled.Send,
colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.surfaceVariant),
contentDescription = ""
)
}
}
}
},
) {
if (reviews?.isNotEmpty() == true) {
reviews.reversed().forEachIndexed { index, review ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = 16.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
AvatarImage(
size = 50.dp,
author = review.authorDetails
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = review.author,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Bold
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = review.author,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Bold
)
HtmlText(
text = review.content,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HtmlText(
text = review.content,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val createdAt = TmdbUtils.formatDate(review.createdAt)
val updatedAt = TmdbUtils.formatDate(review.updatedAt)
var timestamp = stringResource(id = R.string.created_at_label, createdAt)
if (updatedAt != createdAt) {
timestamp += "\n${stringResource(id = R.string.updated_at_label, updatedAt)}"
}
Text(
text = timestamp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp
)
}
}
if (index != reviews.size - 1) {
Divider(
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(vertical = 12.dp)
)
}
}
} else {
Text(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 24.dp, vertical = 22.dp),
text = stringResource(R.string.no_reviews_label),
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium
)
}
}
val createdAt = TmdbUtils.formatDate(review.createdAt)
val updatedAt = TmdbUtils.formatDate(review.updatedAt)
var timestamp = stringResource(id = R.string.created_at_label, createdAt)
if (updatedAt != createdAt) {
timestamp += "\n${stringResource(id = R.string.updated_at_label, updatedAt)}"
}
Text(
text = timestamp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp
)
}
}
if (index != reviews.size - 1) {
Divider(
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(vertical = 12.dp)
)
}
}
} else {
Text(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 24.dp, vertical = 22.dp),
text = stringResource(R.string.no_reviews_label),
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium
)
}
}
}
@Composable
@@ -1333,4 +1449,5 @@ fun DetailsFor(
mainViewModel = mainViewModel
)
}
}
}

View File

@@ -135,14 +135,11 @@ fun PersonDetailScreen(
elevation = 0.dp
)
val externalIdsMap = remember { mainViewModel.peopleExternalIdsMap }
val externalIds = externalIdsMap[personId]
externalIds?.let {
ExternalIdsArea(
externalIds = it,
modifier = Modifier.padding(start = 4.dp)
)
}
ExternalIdsArea(
modifier = Modifier.padding(start = 4.dp),
type = MediaViewType.PERSON,
itemId = personId
)
BiographyCard(person = person)

View File

@@ -176,7 +176,12 @@ class MainViewModel: ViewModel(), KoinComponent {
}
fun produceExternalIdsFor(type: MediaViewType): Map<Int, ExternalIds> {
return providesForType(type, { movieExternalIds }, { tvExternalIds })
return when (type) {
MediaViewType.MOVIE -> movieExternalIds
MediaViewType.TV -> tvExternalIds
MediaViewType.PERSON -> peopleExternalIdsMap
else -> throw ViewableMediaTypeException(type)
}
}
fun produceKeywordsFor(type: MediaViewType): Map<Int, List<Keyword>> {
@@ -263,6 +268,47 @@ class MainViewModel: ViewModel(), KoinComponent {
}
}
fun produceDetailsLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieDetailsLoadingState }, { tvDetailsLoadingState})
}
fun produceImagesLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieImagesLoadingState }, { tvImagesLoadingState })
}
fun produceCastCrewLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieCastCrewLoadingState }, { tvCastCrewLoadingState })
}
fun produceVideosLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieVideosLoadingState }, { tvVideosLoadingState })
}
fun produceReviewsLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieReviewsLoadingState }, { tvReviewsLoadingState })
}
fun produceKeywordsLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieKeywordsLoadingState }, { tvKeywordsLoadingState })
}
fun produceWatchProvidersLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieWatchProvidersLoadingState }, { tvWatchProvidersLoadingState })
}
fun produceExternalIdsLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return when (mediaType) {
MediaViewType.MOVIE -> movieExternalIdsLoadingState
MediaViewType.TV -> tvExternalIdsLoadingState
MediaViewType.PERSON -> peopleExternalIdsLoadingState
else -> throw ViewableMediaTypeException(mediaType)
}
}
fun produceAccountStatesLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieAccountStatesLoadingState }, { tvAccountStatesLoadingState })
}
suspend fun getById(id: Int, type: MediaViewType, force: Boolean = false) {
when (type) {
MediaViewType.MOVIE -> if (detailMovies[id] == null || force) movieService.getById(id, force)
@@ -453,4 +499,43 @@ class MainViewModel: ViewModel(), KoinComponent {
peopleExternalIds.value == LoadingState.REFRESHING
}
@SuppressLint("ComposableNaming")
@Composable
fun monitorDetailsLoading(loading: MutableState<Boolean>) {
val movieDetails = remember { movieDetailsLoadingState }
val movieImages = remember { movieImagesLoadingState }
val movieCastCrew = remember { movieCastCrewLoadingState }
val movieVideos = remember { movieVideosLoadingState }
val movieReviews = remember { movieReviewsLoadingState }
val movieKeywords = remember { movieKeywordsLoadingState }
val movieWatchProviders = remember { movieWatchProvidersLoadingState }
val movieExternalIds = remember { movieExternalIdsLoadingState }
val movieReleaseDates = remember { movieReleaseDatesLoadingState }
val movieAccountStates = remember { movieAccountStatesLoadingState }
val tvDetails = remember { tvDetailsLoadingState }
val tvImages = remember { tvImagesLoadingState }
val tvCastCrew = remember { tvCastCrewLoadingState }
val tvVideos = remember { tvVideosLoadingState }
val tvReviews = remember { tvReviewsLoadingState }
val tvKeywords = remember { tvKeywordsLoadingState }
val tvWatchProviders = remember { tvWatchProvidersLoadingState }
val tvExternalIds = remember { tvExternalIdsLoadingState }
val tvSeasons = remember { tvSeasonsLoadingState }
val tvContentRatings = remember { tvContentRatingsLoadingState }
val tvAccountStates = remember { tvAccountStatesLoadingState }
val peopleDetails = remember { peopleDetailsLoadingState }
val peopleCastCrew = remember { peopleCastCrewLoadingState }
val peopleImages = remember { peopleImagesLoadingState }
val peopleExternalIds = remember { peopleExternalIdsLoadingState }
loading.value = listOf(
movieDetails.value, movieImages.value, movieCastCrew.value, movieVideos.value, movieReviews.value,
movieKeywords.value, movieWatchProviders.value, movieExternalIds.value, movieReleaseDates.value,
movieAccountStates.value, tvDetails.value, tvImages.value, tvCastCrew.value, tvVideos.value,
tvReviews.value, tvKeywords.value, tvWatchProviders.value, tvExternalIds.value, tvSeasons.value,
tvContentRatings.value, tvAccountStates.value, peopleDetails.value, peopleCastCrew.value,
peopleImages.value, peopleExternalIds.value
).any { it == LoadingState.LOADING || it == LoadingState.REFRESHING }
}
}

View File

@@ -104,8 +104,8 @@ object TmdbUtils {
return "${startYear}-${endYear}"
}
fun convertRuntimeToHoursMinutes(movie: DetailedMovie): String {
movie.runtime?.let { runtime ->
fun convertRuntimeToHoursMinutes(movie: DetailedMovie?): String {
movie?.runtime?.let { runtime ->
return convertRuntimeToHoursAndMinutes(runtime)
}
return ""