DiscoverScreen.kt

  1. package com.louisfn.somovie.feature.home.discover

  2. import androidx.compose.foundation.background
  3. import androidx.compose.foundation.layout.Box
  4. import androidx.compose.foundation.layout.BoxScope
  5. import androidx.compose.foundation.layout.WindowInsets
  6. import androidx.compose.foundation.layout.fillMaxSize
  7. import androidx.compose.foundation.layout.fillMaxWidth
  8. import androidx.compose.foundation.layout.padding
  9. import androidx.compose.foundation.layout.size
  10. import androidx.compose.foundation.layout.statusBars
  11. import androidx.compose.foundation.shape.CircleShape
  12. import androidx.compose.material.ExperimentalMaterialApi
  13. import androidx.compose.material.FractionalThreshold
  14. import androidx.compose.material.Icon
  15. import androidx.compose.material.MaterialTheme
  16. import androidx.compose.material.icons.Icons
  17. import androidx.compose.material.icons.filled.Add
  18. import androidx.compose.material.icons.filled.Clear
  19. import androidx.compose.runtime.Composable
  20. import androidx.compose.runtime.getValue
  21. import androidx.compose.runtime.mutableStateOf
  22. import androidx.compose.runtime.remember
  23. import androidx.compose.runtime.setValue
  24. import androidx.compose.ui.Alignment
  25. import androidx.compose.ui.Modifier
  26. import androidx.compose.ui.draw.clip
  27. import androidx.compose.ui.graphics.Color
  28. import androidx.compose.ui.graphics.vector.ImageVector
  29. import androidx.compose.ui.layout.ContentScale
  30. import androidx.compose.ui.res.stringResource
  31. import androidx.compose.ui.text.style.TextAlign
  32. import androidx.compose.ui.unit.Dp
  33. import androidx.compose.ui.unit.coerceAtMost
  34. import androidx.compose.ui.unit.dp
  35. import androidx.hilt.navigation.compose.hiltViewModel
  36. import coil.compose.AsyncImagePainter
  37. import com.louisfn.somovie.feature.home.discover.DiscoverUiState.Discover.LogInSnackbarState
  38. import com.louisfn.somovie.feature.home.discover.DiscoverUiState.MovieItem
  39. import com.louisfn.somovie.ui.common.extension.collectAsStateLifecycleAware
  40. import com.louisfn.somovie.ui.common.extension.pxToDp
  41. import com.louisfn.somovie.ui.common.extension.top
  42. import com.louisfn.somovie.ui.common.model.ImmutableList
  43. import com.louisfn.somovie.ui.common.modifier.shake
  44. import com.louisfn.somovie.ui.component.AutosizeText
  45. import com.louisfn.somovie.ui.component.DefaultAsyncImage
  46. import com.louisfn.somovie.ui.component.DefaultSnackbar
  47. import com.louisfn.somovie.ui.component.IndeterminateProgressIndicator
  48. import com.louisfn.somovie.ui.component.Retry
  49. import com.louisfn.somovie.ui.component.swipe.SwipeContainer
  50. import com.louisfn.somovie.ui.component.swipe.SwipeDirection
  51. import com.louisfn.somovie.ui.common.R as commonR

  52. private const val MaxMovieItemToPreload = 5
  53. private const val SwipeFractionalThreshold = 0.25f

  54. @Composable
  55. internal fun DiscoverScreen(
  56.     viewModel: DiscoverViewModel = hiltViewModel(),
  57.     showAccount: () -> Unit = {},
  58. ) {
  59.     val uiState by viewModel.uiState.collectAsStateLifecycleAware()

  60.     DiscoverScreen(
  61.         uiState = uiState,
  62.         onSwiped = viewModel::onMovieSwiped,
  63.         onDisappeared = viewModel::onMovieDisappeared,
  64.         retry = { viewModel.retry() },
  65.         onLogInSnackbarActionClicked = { showAccount() },
  66.     )
  67. }

  68. @Composable
  69. private fun DiscoverScreen(
  70.     uiState: DiscoverUiState,
  71.     onSwiped: (MovieItem, SwipeDirection) -> Unit,
  72.     onDisappeared: (MovieItem) -> Unit,
  73.     retry: () -> Unit,
  74.     onLogInSnackbarActionClicked: () -> Unit,
  75. ) {
  76.     Box(
  77.         modifier = Modifier.fillMaxSize(),
  78.     ) {
  79.         when (uiState) {
  80.             is DiscoverUiState.Discover -> DiscoverContent(
  81.                 items = uiState.items,
  82.                 logInSnackbarState = uiState.logInSnackbarState,
  83.                 onSwiped = onSwiped,
  84.                 onDisappeared = onDisappeared,
  85.                 onLogInSnackbarActionClicked = onLogInSnackbarActionClicked,
  86.             )
  87.             is DiscoverUiState.Retry -> Retry(
  88.                 modifier = Modifier.align(Alignment.Center),
  89.                 onClick = retry,
  90.             )
  91.             is DiscoverUiState.Loading -> IndeterminateProgressIndicator(
  92.                 modifier = Modifier.align(Alignment.Center),
  93.             )
  94.             is DiscoverUiState.None -> Unit
  95.         }
  96.     }
  97. }

  98. @Composable
  99. private fun BoxScope.DiscoverContent(
  100.     items: ImmutableList<MovieItem>,
  101.     logInSnackbarState: LogInSnackbarState,
  102.     onSwiped: (MovieItem, SwipeDirection) -> Unit,
  103.     onDisappeared: (MovieItem) -> Unit,
  104.     onLogInSnackbarActionClicked: () -> Unit,
  105. ) {
  106.     DiscoverSwipeContainer(
  107.         items = items,
  108.         onSwiped = onSwiped,
  109.         onDisappeared = onDisappeared,
  110.     )
  111.     if (logInSnackbarState != LogInSnackbarState.HIDDEN) {
  112.         DefaultSnackbar(
  113.             message = stringResource(id = commonR.string.discover_log_in_description),
  114.             actionLabel = stringResource(id = commonR.string.discover_log_in_action),
  115.             onActionClick = onLogInSnackbarActionClicked,
  116.             modifier = Modifier
  117.                 .shake(logInSnackbarState == LogInSnackbarState.SHAKING)
  118.                 .align(Alignment.BottomCenter),
  119.         )
  120.     }
  121. }

  122. @OptIn(ExperimentalMaterialApi::class)
  123. @Composable
  124. private fun BoxScope.DiscoverSwipeContainer(
  125.     items: ImmutableList<MovieItem>,
  126.     onSwiped: (MovieItem, SwipeDirection) -> Unit,
  127.     onDisappeared: (MovieItem) -> Unit,
  128. ) {
  129.     var draggingState by remember { mutableStateOf<DraggingState?>(null) }

  130.     SwipeContainer(
  131.         items = ImmutableList(items.take(MaxMovieItemToPreload)),
  132.         itemKey = MovieItem::id,
  133.         thresholdConfig = FractionalThreshold(SwipeFractionalThreshold),
  134.         onDragging = { _, direction, ratio ->
  135.             draggingState = DraggingState(direction, ratio)
  136.         },
  137.         onCanceled = { draggingState = null },
  138.         onSwiped = { item, direction ->
  139.             draggingState = null
  140.             onSwiped(item, direction)
  141.         },
  142.         onDisappeared = { item, _ -> onDisappeared(item) },
  143.     ) { item ->
  144.         DiscoverMovieItem(item)
  145.     }

  146.     draggingState?.let { DiscoverSwipeIcon(it) }
  147. }

  148. @Composable
  149. private fun BoxScope.DiscoverSwipeIcon(draggingState: DraggingState) {
  150.     Icon(
  151.         modifier = Modifier
  152.             .align(Alignment.Center)
  153.             .size(draggingState.iconSize)
  154.             .clip(CircleShape)
  155.             .background(draggingState.iconBackgroundColor),
  156.         imageVector = draggingState.icon,
  157.         contentDescription = null,
  158.     )
  159. }

  160. @Composable
  161. private fun DiscoverMovieItem(item: MovieItem) {
  162.     var isImageLoaded by remember { mutableStateOf(false) }

  163.     Box {
  164.         DefaultAsyncImage(
  165.             modifier = Modifier
  166.                 .fillMaxSize()
  167.                 .background(MaterialTheme.colors.background),
  168.             contentScale = ContentScale.Crop,
  169.             model = item.posterPath,
  170.             onState = { isImageLoaded = it is AsyncImagePainter.State.Success },
  171.         )
  172.         AutosizeText(
  173.             modifier = Modifier
  174.                 .fillMaxWidth()
  175.                 .background(MaterialTheme.colors.background.copy(alpha = 0.8f))
  176.                 .padding(top = WindowInsets.statusBars.top.pxToDp())
  177.                 .padding(horizontal = 24.dp, vertical = 8.dp),
  178.             text = item.title,
  179.             style = MaterialTheme.typography.h3,
  180.             textAlign = TextAlign.Center,
  181.             maxLines = 2,
  182.         )
  183.         if (!isImageLoaded) {
  184.             IndeterminateProgressIndicator(
  185.                 modifier = Modifier
  186.                     .size(32.dp)
  187.                     .align(Alignment.Center),
  188.             )
  189.         }
  190.     }
  191. }

  192. private data class DraggingState(
  193.     val direction: SwipeDirection,
  194.     val ratio: Float,
  195. ) {

  196.     val icon: ImageVector
  197.         @Composable
  198.         get() = when (direction) {
  199.             SwipeDirection.LEFT -> Icons.Default.Clear
  200.             SwipeDirection.RIGHT -> Icons.Default.Add
  201.         }

  202.     val iconBackgroundColor: Color
  203.         @Composable
  204.         get() = when (direction) {
  205.             SwipeDirection.LEFT -> MaterialTheme.colors.discoverDisliked
  206.             SwipeDirection.RIGHT -> MaterialTheme.colors.discoverLiked
  207.         }

  208.     val iconSize: Dp =
  209.         (MAX_ICON_SIZE * ratio / SwipeFractionalThreshold).coerceAtMost(MAX_ICON_SIZE)

  210.     companion object {
  211.         private val MAX_ICON_SIZE = 64.dp
  212.     }
  213. }