|
@@ -0,0 +1,317 @@
|
|
|
+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.shape.RoundedCornerShape
|
|
|
+import androidx.compose.material3.LocalContentColor
|
|
|
+import androidx.compose.material3.MaterialTheme
|
|
|
+import androidx.compose.material3.Text
|
|
|
+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 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,
|
|
|
+ onLongClick: () -> Unit,
|
|
|
+ onClick: () -> Unit,
|
|
|
+) {
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Title overlay for [MangaCompactGridItem]
|
|
|
+ */
|
|
|
+@Composable
|
|
|
+private fun BoxScope.CoverTextOverlay(title: String) {
|
|
|
+ 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),
|
|
|
+ )
|
|
|
+ GridItemTitle(
|
|
|
+ modifier = Modifier
|
|
|
+ .padding(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,
|
|
|
+ onLongClick: () -> Unit,
|
|
|
+ onClick: () -> Unit,
|
|
|
+) {
|
|
|
+ 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,
|
|
|
+ )
|
|
|
+ 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,
|
|
|
+ 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,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@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(RoundedCornerShape(8.dp))
|
|
|
+ .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,
|
|
|
+ onLongClick: () -> Unit,
|
|
|
+ onClick: () -> Unit,
|
|
|
+) {
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+}
|