package eu.kanade.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.modifierElementOf import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import eu.kanade.presentation.util.selectedBackground object CommonMangaItemDefaults { val GridHorizontalSpacer = 4.dp val GridVerticalSpacer = 4.dp const val BrowseFavoriteCoverAlpha = 0.34f } private val ContinueReadingButtonSize = 32.dp private const val GridSelectedCoverAlpha = 0.76f /** * Layout of grid list item with title overlaying the cover. * Accepts null [title] for a cover-only view. */ @Composable fun MangaCompactGridItem( isSelected: Boolean = false, title: String? = null, coverData: eu.kanade.domain.manga.model.MangaCover, coverAlpha: Float = 1f, coverBadgeStart: (@Composable RowScope.() -> Unit)? = null, coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null, showContinueReadingButton: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit, onClickContinueReading: (() -> Unit)? = null, ) { GridItemSelectable( isSelected = isSelected, onClick = onClick, onLongClick = onLongClick, ) { MangaGridCover( cover = { MangaCover.Book( modifier = Modifier .fillMaxWidth() .alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha), data = coverData, ) }, badgesStart = coverBadgeStart, badgesEnd = coverBadgeEnd, content = { if (title != null) { CoverTextOverlay(title = title, showContinueReadingButton) } }, continueReadingButton = { if (showContinueReadingButton && onClickContinueReading != null) { ContinueReadingButton(onClickContinueReading) } }, ) } } /** * Title overlay for [MangaCompactGridItem] */ @Composable private fun BoxScope.CoverTextOverlay( title: String, showContinueReadingButton: Boolean = false, ) { Box( modifier = Modifier .clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp)) .background( Brush.verticalGradient( 0f to Color.Transparent, 1f to Color(0xAA000000), ), ) .fillMaxHeight(0.33f) .fillMaxWidth() .align(Alignment.BottomCenter), ) val endPadding = if (showContinueReadingButton) ContinueReadingButtonSize else 0.dp GridItemTitle( modifier = Modifier .padding(start = 8.dp, top = 8.dp, end = endPadding + 8.dp, bottom = 8.dp) .align(Alignment.BottomStart), title = title, style = MaterialTheme.typography.titleSmall.copy( color = Color.White, shadow = Shadow( color = Color.Black, blurRadius = 4f, ), ), ) } /** * Layout of grid list item with title below the cover. */ @Composable fun MangaComfortableGridItem( isSelected: Boolean = false, title: String, coverData: eu.kanade.domain.manga.model.MangaCover, coverAlpha: Float = 1f, coverBadgeStart: (@Composable RowScope.() -> Unit)? = null, coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null, showContinueReadingButton: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit, onClickContinueReading: (() -> Unit)? = null, ) { GridItemSelectable( isSelected = isSelected, onClick = onClick, onLongClick = onLongClick, ) { Column { MangaGridCover( cover = { MangaCover.Book( modifier = Modifier .fillMaxWidth() .alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha), data = coverData, ) }, badgesStart = coverBadgeStart, badgesEnd = coverBadgeEnd, continueReadingButton = { if (showContinueReadingButton && onClickContinueReading != null) { ContinueReadingButton(onClickContinueReading) } }, ) GridItemTitle( modifier = Modifier.padding(4.dp), title = title, style = MaterialTheme.typography.titleSmall, ) } } } /** * Common cover layout to add contents to be drawn on top of the cover. */ @Composable private fun MangaGridCover( modifier: Modifier = Modifier, cover: @Composable BoxScope.() -> Unit = {}, badgesStart: (@Composable RowScope.() -> Unit)? = null, badgesEnd: (@Composable RowScope.() -> Unit)? = null, continueReadingButton: (@Composable BoxScope.() -> Unit)? = null, content: @Composable (BoxScope.() -> Unit)? = null, ) { Box( modifier = modifier .fillMaxWidth() .aspectRatio(MangaCover.Book.ratio), ) { cover() content?.invoke(this) if (badgesStart != null) { BadgeGroup( modifier = Modifier .padding(4.dp) .align(Alignment.TopStart), content = badgesStart, ) } if (badgesEnd != null) { BadgeGroup( modifier = Modifier .padding(4.dp) .align(Alignment.TopEnd), content = badgesEnd, ) } continueReadingButton?.invoke(this) } } @Composable private fun GridItemTitle( modifier: Modifier, title: String, style: TextStyle, ) { Text( modifier = modifier, text = title, fontSize = 12.sp, lineHeight = 18.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, style = style, ) } /** * Wrapper for grid items to handle selection state, click and long click. */ @Composable private fun GridItemSelectable( modifier: Modifier = Modifier, isSelected: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit, ) { Box( modifier = modifier .clip(MaterialTheme.shapes.small) .combinedClickable( onClick = onClick, onLongClick = onLongClick, ) .selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary) .padding(4.dp), ) { val contentColor = if (isSelected) { MaterialTheme.colorScheme.onSecondary } else { LocalContentColor.current } CompositionLocalProvider(LocalContentColor provides contentColor) { content() } } } /** * @see GridItemSelectable */ private fun Modifier.selectedOutline( isSelected: Boolean, color: Color, ): Modifier { class SelectedOutlineNode(var selected: Boolean, var color: Color) : DrawModifierNode, Modifier.Node() { override fun ContentDrawScope.draw() { if (selected) drawRect(color) drawContent() } } return this then modifierElementOf( params = isSelected.hashCode() + color.hashCode(), create = { SelectedOutlineNode(isSelected, color) }, update = { it.selected = isSelected it.color = color }, definitions = { name = "selectionOutline" properties["isSelected"] = isSelected properties["color"] = color }, ) } /** * Layout of list item. */ @Composable fun MangaListItem( isSelected: Boolean = false, title: String, coverData: eu.kanade.domain.manga.model.MangaCover, coverAlpha: Float = 1f, badge: @Composable RowScope.() -> Unit, showContinueReadingButton: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit, onClickContinueReading: (() -> Unit)? = null, ) { Row( modifier = Modifier .selectedBackground(isSelected) .height(56.dp) .combinedClickable( onClick = onClick, onLongClick = onLongClick, ) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { MangaCover.Square( modifier = Modifier .fillMaxHeight() .alpha(coverAlpha), data = coverData, ) Text( text = title, modifier = Modifier .padding(horizontal = 16.dp) .weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, ) BadgeGroup(content = badge) if (showContinueReadingButton && onClickContinueReading != null) { Box { ContinueReadingButton(onClickContinueReading) } } } } @Composable private fun BoxScope.ContinueReadingButton( onClickContinueReading: () -> Unit, ) { Box( modifier = Modifier .align(Alignment.BottomEnd) .padding(horizontal = 4.dp, vertical = 8.dp), ) { FilledIconButton( onClick = onClickContinueReading, modifier = Modifier.size(ContinueReadingButtonSize), shape = MaterialTheme.shapes.small, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), contentColor = contentColorFor(MaterialTheme.colorScheme.primaryContainer), ), ) { Icon( imageVector = Icons.Filled.PlayArrow, contentDescription = "", modifier = Modifier.size(16.dp), ) } } }