diff --git a/common/src/main/java/com/sample/tmdb/common/ui/component/TMDbProgressBar.kt b/common/src/main/java/com/sample/tmdb/common/ui/component/TMDbProgressBar.kt index c6fdb0f..4758ba6 100644 --- a/common/src/main/java/com/sample/tmdb/common/ui/component/TMDbProgressBar.kt +++ b/common/src/main/java/com/sample/tmdb/common/ui/component/TMDbProgressBar.kt @@ -28,7 +28,7 @@ fun TMDbProgressBar() { fun HorizontalDottedProgressBar() { val color = MaterialTheme.colors.primary - val infiniteTransition = rememberInfiniteTransition() + val infiniteTransition = rememberInfiniteTransition(label = "") val state = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 6f, @@ -37,7 +37,7 @@ fun HorizontalDottedProgressBar() { durationMillis = 700, easing = LinearEasing ) - ) + ), label = "" ) DrawCanvas(state = state.value, radius = 15.dp, color = color) diff --git a/features/feature-feed/src/main/java/com/sample/tmdb/feed/FeedScreen.kt b/features/feature-feed/src/main/java/com/sample/tmdb/feed/FeedScreen.kt index e29a2ac..5b6cc8d 100644 --- a/features/feature-feed/src/main/java/com/sample/tmdb/feed/FeedScreen.kt +++ b/features/feature-feed/src/main/java/com/sample/tmdb/feed/FeedScreen.kt @@ -1,7 +1,10 @@ package com.sample.tmdb.feed import androidx.annotation.StringRes +import androidx.compose.foundation.ExperimentalFoundationApi +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 import androidx.compose.foundation.layout.PaddingValues @@ -9,8 +12,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.wrapContentWidth @@ -18,35 +25,44 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import coil.compose.AsyncImage import com.sample.tmdb.common.MainDestinations import com.sample.tmdb.common.model.TMDbItem import com.sample.tmdb.common.ui.Content import com.sample.tmdb.common.ui.Dimens import com.sample.tmdb.common.ui.component.DestinationBar import com.sample.tmdb.common.ui.component.TMDbCard +import com.sample.tmdb.common.ui.theme.Teal200 import com.sample.tmdb.common.ui.theme.TmdbPagingComposeTheme import com.sample.tmdb.domain.model.FeedWrapper import com.sample.tmdb.domain.model.Movie import com.sample.tmdb.domain.model.SortType import com.sample.tmdb.domain.model.TVShow -import com.sample.tmdb.feed.utils.conditional +import com.sample.tmdb.feed.utils.pagerTransition import com.sample.tmdb.common.R as R1 @Composable fun MovieFeedScreen( - navController: NavController, - viewModel: MovieFeedViewModel = hiltViewModel() + navController: NavController, viewModel: MovieFeedViewModel = hiltViewModel() ) { FeedScreen( viewModel = viewModel, @@ -59,8 +75,7 @@ fun MovieFeedScreen( @Composable fun TVShowFeedScreen( - navController: NavController, - viewModel: TVShowFeedViewModel = hiltViewModel() + navController: NavController, viewModel: TVShowFeedViewModel = hiltViewModel() ) { FeedScreen( viewModel = viewModel, @@ -72,7 +87,7 @@ fun TVShowFeedScreen( } @Composable -private fun FeedScreen( +private fun FeedScreen( viewModel: BaseFeedViewModel, navController: NavController, onSearchClicked: () -> Unit, @@ -94,9 +109,7 @@ private fun FeedScreen( @Composable private fun FeedCollectionList( - navController: NavController, - collection: List, - onFeedClick: (TMDbItem) -> Unit + navController: NavController, collection: List, onFeedClick: (TMDbItem) -> Unit ) { LazyColumn { item { @@ -106,7 +119,14 @@ private fun FeedCollectionList( ) ) } - itemsIndexed(collection) { index, feedCollection -> + item { + PagerTMDbItemContainer( + item = collection.first(), + navController = navController, + onFeedClick = onFeedClick, + ) + } + itemsIndexed(collection.drop(1)) { index, feedCollection -> FeedCollection( navController = navController, feedCollection = feedCollection, @@ -117,102 +137,184 @@ private fun FeedCollectionList( } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun FeedCollection( - navController: NavController, - feedCollection: FeedWrapper, - onFeedClick: (TMDbItem) -> Unit, - index: Int, - modifier: Modifier = Modifier, +fun PagerTMDbItemContainer( + item: FeedWrapper, navController: NavController, onFeedClick: (TMDbItem) -> Unit ) { - Column(modifier = modifier.conditional(index != SortType.entries.lastIndex) { - padding(bottom = 32.dp) - }) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .heightIn(min = 36.dp) - .padding(start = Dimens.PaddingNormal) - ) { - Text( - text = stringResource(id = feedCollection.sortTypeResourceId), - maxLines = 1, - color = MaterialTheme.colors.onSurface, - modifier = Modifier - .weight(1f) - .wrapContentWidth(Alignment.Start) - ) - Text( - text = stringResource(R.string.more_item), - color = MaterialTheme.colors.onSurface, - modifier = Modifier - .align(Alignment.CenterVertically) - .clickable { - when (feedCollection.feeds.first()) { - is Movie -> { - when (feedCollection.sortType) { - SortType.TRENDING -> { - navController.navigate(MainDestinations.TMDB_TRENDING_MOVIES_ROUTE) - } + val pagerState = rememberPagerState(pageCount = { item.feeds.size }) - SortType.MOST_POPULAR -> { - navController.navigate(MainDestinations.TMDB_POPULAR_MOVIES_ROUTE) - } + Banner(titleId = item.sortTypeResourceId) { + when (item.feeds.first()) { + is Movie -> { + when (item.sortType) { + SortType.TRENDING -> navController.navigate(MainDestinations.TMDB_TRENDING_MOVIES_ROUTE) + else -> throw RuntimeException("Movie pager Sort type is not valid") + } + } - SortType.NOW_PLAYING -> { - navController.navigate(MainDestinations.TMDB_NOW_PLAYING_MOVIES_ROUTE) - } + is TVShow -> { + when (item.sortType) { + SortType.TRENDING -> navController.navigate(MainDestinations.TMDB_TRENDING_TV_SHOW_ROUTE) + else -> throw RuntimeException("TV show pager item Sort type is not valid") + } + } + } + } - SortType.UPCOMING -> { - navController.navigate(MainDestinations.TMDB_UPCOMING_MOVIES_ROUTE) - } + HorizontalPager( + state = pagerState, contentPadding = PaddingValues(horizontal = Dimens.PaddingLarge) + ) { page -> + with(item.feeds[page]) { + TrendingItem(modifier = Modifier.pagerTransition( + pagerState = pagerState, page = page + ), + title = name, + imageUrl = backdropUrl, + releaseDate = releaseDate, + onClick = { onFeedClick(this) }) + } + } - SortType.HIGHEST_RATED -> { - navController.navigate(MainDestinations.TMDB_TOP_RATED_MOVIES_ROUTE) - } + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) MaterialTheme.colors.primary else Teal200 + Box( + modifier = Modifier + .padding(Dimens.PaddingExtraSmall) + .clip(CircleShape) + .background(color) + .size(6.dp) + ) + } + } +} - SortType.DISCOVER -> { - navController.navigate(MainDestinations.TMDB_DISCOVER_MOVIES_ROUTE) - } - } - } +@Composable +private fun TrendingItem( + title: String, + imageUrl: String?, + releaseDate: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(10.dp)) + .then(Modifier.clickable(onClick = onClick)) + ) { + Box(modifier = Modifier.fillMaxSize()) { - is TVShow -> { - when (feedCollection.sortType) { - SortType.TRENDING -> { - navController.navigate(MainDestinations.TMDB_TRENDING_TV_SHOW_ROUTE) - } + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop + ) - SortType.MOST_POPULAR -> { - navController.navigate(MainDestinations.TMDB_POPULAR_TV_SHOW_ROUTE) - } + Column( + modifier = Modifier + .padding( + start = Dimens.PaddingNormal, bottom = Dimens.PaddingSmall + ) + .align(Alignment.BottomStart) + ) { + Text( + text = title, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) + Spacer(modifier = Modifier.height(6.dp)) + releaseDate?.let { releaseDate -> + Text( + text = releaseDate, + color = MaterialTheme.colors.onSurface, + ) + } + } + } + } +} - SortType.NOW_PLAYING -> { - navController.navigate(MainDestinations.TMDB_AIRING_TODAY_TV_SHOW_ROUTE) - } +@Composable +private fun FeedCollection( + navController: NavController, + feedCollection: FeedWrapper, + onFeedClick: (TMDbItem) -> Unit, + index: Int, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(top = 32.dp)) { + Banner(titleId = feedCollection.sortTypeResourceId) { + when (feedCollection.feeds.first()) { + is Movie -> { + when (feedCollection.sortType) { + SortType.MOST_POPULAR -> navController.navigate(MainDestinations.TMDB_POPULAR_MOVIES_ROUTE) + SortType.NOW_PLAYING -> navController.navigate(MainDestinations.TMDB_NOW_PLAYING_MOVIES_ROUTE) + SortType.UPCOMING -> navController.navigate(MainDestinations.TMDB_UPCOMING_MOVIES_ROUTE) + SortType.HIGHEST_RATED -> navController.navigate( + MainDestinations.TMDB_TOP_RATED_MOVIES_ROUTE + ) - SortType.UPCOMING -> { - navController.navigate(MainDestinations.TMDB_ON_THE_AIR_TV_SHOW_ROUTE) - } + SortType.DISCOVER -> navController.navigate(MainDestinations.TMDB_DISCOVER_MOVIES_ROUTE) + else -> throw RuntimeException("Movie feed item Sort type is not valid") + } + } - SortType.HIGHEST_RATED -> { - navController.navigate(MainDestinations.TMDB_TOP_RATED_TV_SHOW_ROUTE) - } + is TVShow -> { + when (feedCollection.sortType) { + SortType.MOST_POPULAR -> navController.navigate(MainDestinations.TMDB_POPULAR_TV_SHOW_ROUTE) + SortType.NOW_PLAYING -> navController.navigate(MainDestinations.TMDB_AIRING_TODAY_TV_SHOW_ROUTE) + SortType.UPCOMING -> navController.navigate(MainDestinations.TMDB_ON_THE_AIR_TV_SHOW_ROUTE) + SortType.HIGHEST_RATED -> navController.navigate( + MainDestinations.TMDB_TOP_RATED_TV_SHOW_ROUTE + ) - SortType.DISCOVER -> { - navController.navigate(MainDestinations.TMDB_DISCOVER_TV_SHOW_ROUTE) - } - } - } - } + SortType.DISCOVER -> navController.navigate(MainDestinations.TMDB_DISCOVER_TV_SHOW_ROUTE) + else -> throw RuntimeException("TV show feed item Sort type is not valid") } - .padding(Dimens.PaddingNormal) - ) + } + } } Feeds(feedCollection.feeds, onFeedClick, index) } } +@Composable +private fun Banner( + @StringRes titleId: Int, onMoreClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .heightIn(min = 36.dp) + .padding(start = Dimens.PaddingNormal) + ) { + Text( + text = stringResource(id = titleId), + maxLines = 1, + color = MaterialTheme.colors.onSurface, + modifier = Modifier + .weight(1f) + .wrapContentWidth(Alignment.Start) + ) + Text( + text = stringResource(R.string.more_item), + color = MaterialTheme.colors.onSurface, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(Dimens.PaddingNormal) + .clickable(onClick = onMoreClick) + ) + } +} + @Composable private fun Feeds( feeds: List, @@ -232,9 +334,7 @@ private fun Feeds( @Composable private fun TMDbItem( - tmdbItem: TMDbItem, - onFeedClick: (TMDbItem) -> Unit, - index: Int + tmdbItem: TMDbItem, onFeedClick: (TMDbItem) -> Unit, index: Int ) { val itemWidth: Dp val imageUrl: String? @@ -254,9 +354,7 @@ fun FeedCardPreview() { TmdbPagingComposeTheme { val movie = Movie(1, "", null, null, null, "Movie", 1.0, 2) TMDbItem( - tmdbItem = movie, - onFeedClick = {}, - 0 + tmdbItem = movie, onFeedClick = {}, 0 ) } } \ No newline at end of file diff --git a/features/feature-feed/src/main/java/com/sample/tmdb/feed/utils/ModifierExt.kt b/features/feature-feed/src/main/java/com/sample/tmdb/feed/utils/ModifierExt.kt index ad6f4c1..cb42bb1 100644 --- a/features/feature-feed/src/main/java/com/sample/tmdb/feed/utils/ModifierExt.kt +++ b/features/feature-feed/src/main/java/com/sample/tmdb/feed/utils/ModifierExt.kt @@ -1,10 +1,30 @@ package com.sample.tmdb.feed.utils +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.PagerState import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.util.lerp +import kotlin.math.absoluteValue -fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier = - if (condition) { - then(modifier(Modifier)) - } else { - this - } \ No newline at end of file +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.pagerTransition( + pagerState: PagerState, page: Int +) = graphicsLayer { + val pageOffset = pagerState.calculatePageOffset(page) + + lerp( + start = 0.85f, stop = 1f, fraction = 1f - pageOffset.coerceIn(0f, 1f) + ).also { scale -> + scaleX = scale + scaleY = scale + } + + alpha = lerp( + start = 0.5f, stop = 1f, fraction = 1f - pageOffset.coerceIn(0f, 1f) + ) +} + +@OptIn(ExperimentalFoundationApi::class) +private fun PagerState.calculatePageOffset(page: Int) = + ((currentPage - page) + currentPageOffsetFraction).absoluteValue \ No newline at end of file