package eu.kanade.presentation.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text 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.composed import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download enum class ChapterDownloadAction { START, START_NOW, CANCEL, DELETE, } @Composable fun ChapterDownloadIndicator( enabled: Boolean, modifier: Modifier = Modifier, downloadStateProvider: () -> Download.State, downloadProgressProvider: () -> Int, onClick: (ChapterDownloadAction) -> Unit, ) { when (val downloadState = downloadStateProvider()) { Download.State.NOT_DOWNLOADED -> NotDownloadedIndicator( enabled = enabled, modifier = modifier, onClick = onClick, ) Download.State.QUEUE, Download.State.DOWNLOADING -> DownloadingIndicator( enabled = enabled, modifier = modifier, downloadState = downloadState, downloadProgressProvider = downloadProgressProvider, onClick = onClick, ) Download.State.DOWNLOADED -> DownloadedIndicator( enabled = enabled, modifier = modifier, onClick = onClick, ) Download.State.ERROR -> ErrorIndicator( enabled = enabled, modifier = modifier, onClick = onClick, ) } } @Composable private fun NotDownloadedIndicator( enabled: Boolean, modifier: Modifier = Modifier, onClick: (ChapterDownloadAction) -> Unit, ) { Box( modifier = modifier .size(IconButtonTokens.StateLayerSize) .commonClickable( enabled = enabled, onLongClick = { onClick(ChapterDownloadAction.START_NOW) }, onClick = { onClick(ChapterDownloadAction.START) }, ) .secondaryItemAlpha(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(id = R.drawable.ic_download_chapter_24dp), contentDescription = stringResource(R.string.manga_download), modifier = Modifier.size(IndicatorSize), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable private fun DownloadingIndicator( enabled: Boolean, modifier: Modifier = Modifier, downloadState: Download.State, downloadProgressProvider: () -> Int, onClick: (ChapterDownloadAction) -> Unit, ) { var isMenuExpanded by remember { mutableStateOf(false) } Box( modifier = modifier .size(IconButtonTokens.StateLayerSize) .commonClickable( enabled = enabled, onLongClick = { onClick(ChapterDownloadAction.CANCEL) }, onClick = { isMenuExpanded = true }, ), contentAlignment = Alignment.Center, ) { val arrowColor: Color val strokeColor = MaterialTheme.colorScheme.onSurfaceVariant val downloadProgress = downloadProgressProvider() val indeterminate = downloadState == Download.State.QUEUE || (downloadState == Download.State.DOWNLOADING && downloadProgress == 0) if (indeterminate) { arrowColor = strokeColor CircularProgressIndicator( modifier = IndicatorModifier, color = strokeColor, strokeWidth = IndicatorStrokeWidth, ) } else { val animatedProgress by animateFloatAsState( targetValue = downloadProgress / 100f, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, ) arrowColor = if (animatedProgress < 0.5f) { strokeColor } else { MaterialTheme.colorScheme.background } CircularProgressIndicator( progress = animatedProgress, modifier = IndicatorModifier, color = strokeColor, strokeWidth = IndicatorSize / 2, ) } DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { DropdownMenuItem( text = { Text(text = stringResource(R.string.action_start_downloading_now)) }, onClick = { onClick(ChapterDownloadAction.START_NOW) isMenuExpanded = false }, ) DropdownMenuItem( text = { Text(text = stringResource(R.string.action_cancel)) }, onClick = { onClick(ChapterDownloadAction.CANCEL) isMenuExpanded = false }, ) } Icon( imageVector = Icons.Outlined.ArrowDownward, contentDescription = null, modifier = ArrowModifier, tint = arrowColor, ) } } @Composable private fun DownloadedIndicator( enabled: Boolean, modifier: Modifier = Modifier, onClick: (ChapterDownloadAction) -> Unit, ) { var isMenuExpanded by remember { mutableStateOf(false) } Box( modifier = modifier .size(IconButtonTokens.StateLayerSize) .commonClickable( enabled = enabled, onLongClick = { isMenuExpanded = true }, onClick = { isMenuExpanded = true }, ), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(IndicatorSize), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { DropdownMenuItem( text = { Text(text = stringResource(R.string.action_delete)) }, onClick = { onClick(ChapterDownloadAction.DELETE) isMenuExpanded = false }, ) } } } @Composable private fun ErrorIndicator( enabled: Boolean, modifier: Modifier = Modifier, onClick: (ChapterDownloadAction) -> Unit, ) { Box( modifier = modifier .size(IconButtonTokens.StateLayerSize) .commonClickable( enabled = enabled, onLongClick = { onClick(ChapterDownloadAction.START) }, onClick = { onClick(ChapterDownloadAction.START) }, ), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Outlined.ErrorOutline, contentDescription = stringResource(R.string.chapter_error), modifier = Modifier.size(IndicatorSize), tint = MaterialTheme.colorScheme.error, ) } } private fun Modifier.commonClickable( enabled: Boolean, onLongClick: () -> Unit, onClick: () -> Unit, ) = composed { val haptic = LocalHapticFeedback.current this.combinedClickable( enabled = enabled, onLongClick = { onLongClick() haptic.performHapticFeedback(HapticFeedbackType.LongPress) }, onClick = onClick, role = Role.Button, interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple( bounded = false, radius = IconButtonTokens.StateLayerSize / 2, ), ) } private val IndicatorSize = 26.dp private val IndicatorPadding = 2.dp // To match composable parameter name when used later private val IndicatorStrokeWidth = IndicatorPadding private val IndicatorModifier = Modifier .size(IndicatorSize) .padding(IndicatorPadding) private val ArrowModifier = Modifier .size(IndicatorSize - 7.dp)