DiscoverScreen.kt
- package com.louisfn.somovie.feature.home.discover
- import androidx.compose.foundation.background
- import androidx.compose.foundation.layout.Box
- import androidx.compose.foundation.layout.BoxScope
- import androidx.compose.foundation.layout.WindowInsets
- import androidx.compose.foundation.layout.fillMaxSize
- import androidx.compose.foundation.layout.fillMaxWidth
- import androidx.compose.foundation.layout.padding
- import androidx.compose.foundation.layout.size
- import androidx.compose.foundation.layout.statusBars
- import androidx.compose.foundation.shape.CircleShape
- import androidx.compose.material.ExperimentalMaterialApi
- import androidx.compose.material.FractionalThreshold
- import androidx.compose.material.Icon
- import androidx.compose.material.MaterialTheme
- import androidx.compose.material.icons.Icons
- import androidx.compose.material.icons.filled.Add
- import androidx.compose.material.icons.filled.Clear
- 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.Alignment
- import androidx.compose.ui.Modifier
- import androidx.compose.ui.draw.clip
- import androidx.compose.ui.graphics.Color
- import androidx.compose.ui.graphics.vector.ImageVector
- import androidx.compose.ui.layout.ContentScale
- import androidx.compose.ui.res.stringResource
- import androidx.compose.ui.text.style.TextAlign
- import androidx.compose.ui.unit.Dp
- import androidx.compose.ui.unit.coerceAtMost
- import androidx.compose.ui.unit.dp
- import androidx.hilt.navigation.compose.hiltViewModel
- import coil.compose.AsyncImagePainter
- import com.louisfn.somovie.feature.home.discover.DiscoverUiState.Discover.LogInSnackbarState
- import com.louisfn.somovie.feature.home.discover.DiscoverUiState.MovieItem
- import com.louisfn.somovie.ui.common.extension.collectAsStateLifecycleAware
- import com.louisfn.somovie.ui.common.extension.pxToDp
- import com.louisfn.somovie.ui.common.extension.top
- import com.louisfn.somovie.ui.common.model.ImmutableList
- import com.louisfn.somovie.ui.common.modifier.shake
- import com.louisfn.somovie.ui.component.AutosizeText
- import com.louisfn.somovie.ui.component.DefaultAsyncImage
- import com.louisfn.somovie.ui.component.DefaultSnackbar
- import com.louisfn.somovie.ui.component.IndeterminateProgressIndicator
- import com.louisfn.somovie.ui.component.Retry
- import com.louisfn.somovie.ui.component.swipe.SwipeContainer
- import com.louisfn.somovie.ui.component.swipe.SwipeDirection
- import com.louisfn.somovie.ui.common.R as commonR
- private const val MaxMovieItemToPreload = 5
- private const val SwipeFractionalThreshold = 0.25f
- @Composable
- internal fun DiscoverScreen(
- viewModel: DiscoverViewModel = hiltViewModel(),
- showAccount: () -> Unit = {},
- ) {
- val uiState by viewModel.uiState.collectAsStateLifecycleAware()
- DiscoverScreen(
- uiState = uiState,
- onSwiped = viewModel::onMovieSwiped,
- onDisappeared = viewModel::onMovieDisappeared,
- retry = { viewModel.retry() },
- onLogInSnackbarActionClicked = { showAccount() },
- )
- }
- @Composable
- private fun DiscoverScreen(
- uiState: DiscoverUiState,
- onSwiped: (MovieItem, SwipeDirection) -> Unit,
- onDisappeared: (MovieItem) -> Unit,
- retry: () -> Unit,
- onLogInSnackbarActionClicked: () -> Unit,
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- ) {
- when (uiState) {
- is DiscoverUiState.Discover -> DiscoverContent(
- items = uiState.items,
- logInSnackbarState = uiState.logInSnackbarState,
- onSwiped = onSwiped,
- onDisappeared = onDisappeared,
- onLogInSnackbarActionClicked = onLogInSnackbarActionClicked,
- )
- is DiscoverUiState.Retry -> Retry(
- modifier = Modifier.align(Alignment.Center),
- onClick = retry,
- )
- is DiscoverUiState.Loading -> IndeterminateProgressIndicator(
- modifier = Modifier.align(Alignment.Center),
- )
- is DiscoverUiState.None -> Unit
- }
- }
- }
- @Composable
- private fun BoxScope.DiscoverContent(
- items: ImmutableList<MovieItem>,
- logInSnackbarState: LogInSnackbarState,
- onSwiped: (MovieItem, SwipeDirection) -> Unit,
- onDisappeared: (MovieItem) -> Unit,
- onLogInSnackbarActionClicked: () -> Unit,
- ) {
- DiscoverSwipeContainer(
- items = items,
- onSwiped = onSwiped,
- onDisappeared = onDisappeared,
- )
- if (logInSnackbarState != LogInSnackbarState.HIDDEN) {
- DefaultSnackbar(
- message = stringResource(id = commonR.string.discover_log_in_description),
- actionLabel = stringResource(id = commonR.string.discover_log_in_action),
- onActionClick = onLogInSnackbarActionClicked,
- modifier = Modifier
- .shake(logInSnackbarState == LogInSnackbarState.SHAKING)
- .align(Alignment.BottomCenter),
- )
- }
- }
- @OptIn(ExperimentalMaterialApi::class)
- @Composable
- private fun BoxScope.DiscoverSwipeContainer(
- items: ImmutableList<MovieItem>,
- onSwiped: (MovieItem, SwipeDirection) -> Unit,
- onDisappeared: (MovieItem) -> Unit,
- ) {
- var draggingState by remember { mutableStateOf<DraggingState?>(null) }
- SwipeContainer(
- items = ImmutableList(items.take(MaxMovieItemToPreload)),
- itemKey = MovieItem::id,
- thresholdConfig = FractionalThreshold(SwipeFractionalThreshold),
- onDragging = { _, direction, ratio ->
- draggingState = DraggingState(direction, ratio)
- },
- onCanceled = { draggingState = null },
- onSwiped = { item, direction ->
- draggingState = null
- onSwiped(item, direction)
- },
- onDisappeared = { item, _ -> onDisappeared(item) },
- ) { item ->
- DiscoverMovieItem(item)
- }
- draggingState?.let { DiscoverSwipeIcon(it) }
- }
- @Composable
- private fun BoxScope.DiscoverSwipeIcon(draggingState: DraggingState) {
- Icon(
- modifier = Modifier
- .align(Alignment.Center)
- .size(draggingState.iconSize)
- .clip(CircleShape)
- .background(draggingState.iconBackgroundColor),
- imageVector = draggingState.icon,
- contentDescription = null,
- )
- }
- @Composable
- private fun DiscoverMovieItem(item: MovieItem) {
- var isImageLoaded by remember { mutableStateOf(false) }
- Box {
- DefaultAsyncImage(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colors.background),
- contentScale = ContentScale.Crop,
- model = item.posterPath,
- onState = { isImageLoaded = it is AsyncImagePainter.State.Success },
- )
- AutosizeText(
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colors.background.copy(alpha = 0.8f))
- .padding(top = WindowInsets.statusBars.top.pxToDp())
- .padding(horizontal = 24.dp, vertical = 8.dp),
- text = item.title,
- style = MaterialTheme.typography.h3,
- textAlign = TextAlign.Center,
- maxLines = 2,
- )
- if (!isImageLoaded) {
- IndeterminateProgressIndicator(
- modifier = Modifier
- .size(32.dp)
- .align(Alignment.Center),
- )
- }
- }
- }
- private data class DraggingState(
- val direction: SwipeDirection,
- val ratio: Float,
- ) {
- val icon: ImageVector
- @Composable
- get() = when (direction) {
- SwipeDirection.LEFT -> Icons.Default.Clear
- SwipeDirection.RIGHT -> Icons.Default.Add
- }
- val iconBackgroundColor: Color
- @Composable
- get() = when (direction) {
- SwipeDirection.LEFT -> MaterialTheme.colors.discoverDisliked
- SwipeDirection.RIGHT -> MaterialTheme.colors.discoverLiked
- }
- val iconSize: Dp =
- (MAX_ICON_SIZE * ratio / SwipeFractionalThreshold).coerceAtMost(MAX_ICON_SIZE)
- companion object {
- private val MAX_ICON_SIZE = 64.dp
- }
- }