Move more components to presentation-core module
This commit is contained in:
@@ -44,9 +44,9 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.kanade.presentation.util.runOnEnterKeyPressed
|
||||
import eu.kanade.presentation.util.secondaryItemAlpha
|
||||
import eu.kanade.tachiyomi.R
|
||||
import tachiyomi.presentation.core.util.runOnEnterKeyPressed
|
||||
import tachiyomi.presentation.core.util.secondaryItemAlpha
|
||||
|
||||
const val SEARCH_DEBOUNCE_MILLIS = 250L
|
||||
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.HoverInteraction
|
||||
import androidx.compose.foundation.interaction.Interaction
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ElevatedButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.util.animateElevation
|
||||
import androidx.compose.material3.ButtonDefaults as M3ButtonDefaults
|
||||
|
||||
/**
|
||||
* TextButton with additional onLongClick functionality.
|
||||
*
|
||||
* @see androidx.compose.material3.TextButton
|
||||
*/
|
||||
@Composable
|
||||
fun TextButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
elevation: ButtonElevation? = null,
|
||||
shape: Shape = M3ButtonDefaults.textShape,
|
||||
border: BorderStroke? = null,
|
||||
colors: ButtonColors = ButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
),
|
||||
contentPadding: PaddingValues = M3ButtonDefaults.TextButtonContentPadding,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) =
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
onLongClick = onLongClick,
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
elevation = elevation,
|
||||
shape = shape,
|
||||
border = border,
|
||||
colors = colors,
|
||||
contentPadding = contentPadding,
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* Button with additional onLongClick functionality.
|
||||
*
|
||||
* @see androidx.compose.material3.TextButton
|
||||
*/
|
||||
@Composable
|
||||
fun Button(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
|
||||
shape: Shape = M3ButtonDefaults.textShape,
|
||||
border: BorderStroke? = null,
|
||||
colors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
contentPadding: PaddingValues = M3ButtonDefaults.ContentPadding,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val containerColor = colors.containerColor(enabled).value
|
||||
val contentColor = colors.contentColor(enabled).value
|
||||
val shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp
|
||||
val tonalElevation = elevation?.tonalElevation(enabled, interactionSource)?.value ?: 0.dp
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
onLongClick = onLongClick,
|
||||
shape = shape,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
tonalElevation = tonalElevation,
|
||||
shadowElevation = shadowElevation,
|
||||
border = border,
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
ProvideTextStyle(value = MaterialTheme.typography.labelLarge) {
|
||||
Row(
|
||||
Modifier.defaultMinSize(
|
||||
minWidth = M3ButtonDefaults.MinWidth,
|
||||
minHeight = M3ButtonDefaults.MinHeight,
|
||||
)
|
||||
.padding(contentPadding),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ButtonDefaults {
|
||||
/**
|
||||
* Creates a [ButtonColors] that represents the default container and content colors used in a
|
||||
* [Button].
|
||||
*
|
||||
* @param containerColor the container color of this [Button] when enabled.
|
||||
* @param contentColor the content color of this [Button] when enabled.
|
||||
* @param disabledContainerColor the container color of this [Button] when not enabled.
|
||||
* @param disabledContentColor the content color of this [Button] when not enabled.
|
||||
*/
|
||||
@Composable
|
||||
fun buttonColors(
|
||||
containerColor: Color = MaterialTheme.colorScheme.primary,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledContainerColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||
disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
): ButtonColors = ButtonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = disabledContainerColor,
|
||||
disabledContentColor = disabledContentColor,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a [ButtonElevation] that will animate between the provided values according to the
|
||||
* Material specification for a [Button].
|
||||
*
|
||||
* @param defaultElevation the elevation used when the [Button] is enabled, and has no other
|
||||
* [Interaction]s.
|
||||
* @param pressedElevation the elevation used when this [Button] is enabled and pressed.
|
||||
* @param focusedElevation the elevation used when the [Button] is enabled and focused.
|
||||
* @param hoveredElevation the elevation used when the [Button] is enabled and hovered.
|
||||
* @param disabledElevation the elevation used when the [Button] is not enabled.
|
||||
*/
|
||||
@Composable
|
||||
fun buttonElevation(
|
||||
defaultElevation: Dp = 0.dp,
|
||||
pressedElevation: Dp = 0.dp,
|
||||
focusedElevation: Dp = 0.dp,
|
||||
hoveredElevation: Dp = 1.dp,
|
||||
disabledElevation: Dp = 0.dp,
|
||||
): ButtonElevation = ButtonElevation(
|
||||
defaultElevation = defaultElevation,
|
||||
pressedElevation = pressedElevation,
|
||||
focusedElevation = focusedElevation,
|
||||
hoveredElevation = hoveredElevation,
|
||||
disabledElevation = disabledElevation,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the elevation for a button in different states.
|
||||
*
|
||||
* - See [M3ButtonDefaults.buttonElevation] for the default elevation used in a [Button].
|
||||
* - See [M3ButtonDefaults.elevatedButtonElevation] for the default elevation used in a
|
||||
* [ElevatedButton].
|
||||
*/
|
||||
@Stable
|
||||
class ButtonElevation internal constructor(
|
||||
private val defaultElevation: Dp,
|
||||
private val pressedElevation: Dp,
|
||||
private val focusedElevation: Dp,
|
||||
private val hoveredElevation: Dp,
|
||||
private val disabledElevation: Dp,
|
||||
) {
|
||||
/**
|
||||
* Represents the tonal elevation used in a button, depending on its [enabled] state and
|
||||
* [interactionSource]. This should typically be the same value as the [shadowElevation].
|
||||
*
|
||||
* Tonal elevation is used to apply a color shift to the surface to give the it higher emphasis.
|
||||
* When surface's color is [ColorScheme.surface], a higher elevation will result in a darker
|
||||
* color in light theme and lighter color in dark theme.
|
||||
*
|
||||
* See [shadowElevation] which controls the elevation of the shadow drawn around the button.
|
||||
*
|
||||
* @param enabled whether the button is enabled
|
||||
* @param interactionSource the [InteractionSource] for this button
|
||||
*/
|
||||
@Composable
|
||||
internal fun tonalElevation(enabled: Boolean, interactionSource: InteractionSource): State<Dp> {
|
||||
return animateElevation(enabled = enabled, interactionSource = interactionSource)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the shadow elevation used in a button, depending on its [enabled] state and
|
||||
* [interactionSource]. This should typically be the same value as the [tonalElevation].
|
||||
*
|
||||
* Shadow elevation is used to apply a shadow around the button to give it higher emphasis.
|
||||
*
|
||||
* See [tonalElevation] which controls the elevation with a color shift to the surface.
|
||||
*
|
||||
* @param enabled whether the button is enabled
|
||||
* @param interactionSource the [InteractionSource] for this button
|
||||
*/
|
||||
@Composable
|
||||
internal fun shadowElevation(
|
||||
enabled: Boolean,
|
||||
interactionSource: InteractionSource,
|
||||
): State<Dp> {
|
||||
return animateElevation(enabled = enabled, interactionSource = interactionSource)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun animateElevation(
|
||||
enabled: Boolean,
|
||||
interactionSource: InteractionSource,
|
||||
): State<Dp> {
|
||||
val interactions = remember { mutableStateListOf<Interaction>() }
|
||||
LaunchedEffect(interactionSource) {
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is HoverInteraction.Enter -> {
|
||||
interactions.add(interaction)
|
||||
}
|
||||
is HoverInteraction.Exit -> {
|
||||
interactions.remove(interaction.enter)
|
||||
}
|
||||
is FocusInteraction.Focus -> {
|
||||
interactions.add(interaction)
|
||||
}
|
||||
is FocusInteraction.Unfocus -> {
|
||||
interactions.remove(interaction.focus)
|
||||
}
|
||||
is PressInteraction.Press -> {
|
||||
interactions.add(interaction)
|
||||
}
|
||||
is PressInteraction.Release -> {
|
||||
interactions.remove(interaction.press)
|
||||
}
|
||||
is PressInteraction.Cancel -> {
|
||||
interactions.remove(interaction.press)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val interaction = interactions.lastOrNull()
|
||||
|
||||
val target =
|
||||
if (!enabled) {
|
||||
disabledElevation
|
||||
} else {
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> pressedElevation
|
||||
is HoverInteraction.Enter -> hoveredElevation
|
||||
is FocusInteraction.Focus -> focusedElevation
|
||||
else -> defaultElevation
|
||||
}
|
||||
}
|
||||
|
||||
val animatable = remember { Animatable(target, Dp.VectorConverter) }
|
||||
|
||||
if (!enabled) {
|
||||
// No transition when moving to a disabled state
|
||||
LaunchedEffect(target) { animatable.snapTo(target) }
|
||||
} else {
|
||||
LaunchedEffect(target) {
|
||||
val lastInteraction = when (animatable.targetValue) {
|
||||
pressedElevation -> PressInteraction.Press(Offset.Zero)
|
||||
hoveredElevation -> HoverInteraction.Enter()
|
||||
focusedElevation -> FocusInteraction.Focus()
|
||||
else -> null
|
||||
}
|
||||
animatable.animateElevation(
|
||||
from = lastInteraction,
|
||||
to = interaction,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return animatable.asState()
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || other !is ButtonElevation) return false
|
||||
|
||||
if (defaultElevation != other.defaultElevation) return false
|
||||
if (pressedElevation != other.pressedElevation) return false
|
||||
if (focusedElevation != other.focusedElevation) return false
|
||||
if (hoveredElevation != other.hoveredElevation) return false
|
||||
if (disabledElevation != other.disabledElevation) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = defaultElevation.hashCode()
|
||||
result = 31 * result + pressedElevation.hashCode()
|
||||
result = 31 * result + focusedElevation.hashCode()
|
||||
result = 31 * result + hoveredElevation.hashCode()
|
||||
result = 31 * result + disabledElevation.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the container and content colors used in a button in different states.
|
||||
*
|
||||
* - See [M3ButtonDefaults.buttonColors] for the default colors used in a [Button].
|
||||
* - See [M3ButtonDefaults.elevatedButtonColors] for the default colors used in a [ElevatedButton].
|
||||
* - See [M3ButtonDefaults.textButtonColors] for the default colors used in a [TextButton].
|
||||
*/
|
||||
@Immutable
|
||||
class ButtonColors internal constructor(
|
||||
private val containerColor: Color,
|
||||
private val contentColor: Color,
|
||||
private val disabledContainerColor: Color,
|
||||
private val disabledContentColor: Color,
|
||||
) {
|
||||
/**
|
||||
* Represents the container color for this button, depending on [enabled].
|
||||
*
|
||||
* @param enabled whether the button is enabled
|
||||
*/
|
||||
@Composable
|
||||
internal fun containerColor(enabled: Boolean): State<Color> {
|
||||
return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the content color for this button, depending on [enabled].
|
||||
*
|
||||
* @param enabled whether the button is enabled
|
||||
*/
|
||||
@Composable
|
||||
internal fun contentColor(enabled: Boolean): State<Color> {
|
||||
return rememberUpdatedState(if (enabled) contentColor else disabledContentColor)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || other !is ButtonColors) return false
|
||||
|
||||
if (containerColor != other.containerColor) return false
|
||||
if (contentColor != other.contentColor) return false
|
||||
if (disabledContainerColor != other.disabledContainerColor) return false
|
||||
if (disabledContentColor != other.disabledContentColor) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = containerColor.hashCode()
|
||||
result = 31 * result + contentColor.hashCode()
|
||||
result = 31 * result + disabledContainerColor.hashCode()
|
||||
result = 31 * result + disabledContentColor.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
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
|
||||
import tachiyomi.presentation.core.components.material.IconButtonTokens
|
||||
|
||||
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(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)
|
||||
@@ -1,399 +0,0 @@
|
||||
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
|
||||
import tachiyomi.presentation.core.components.BadgeGroup
|
||||
|
||||
object CommonMangaItemDefaults {
|
||||
val GridHorizontalSpacer = 4.dp
|
||||
val GridVerticalSpacer = 4.dp
|
||||
|
||||
const val BrowseFavoriteCoverAlpha = 0.34f
|
||||
}
|
||||
|
||||
private val ContinueReadingButtonSize = 32.dp
|
||||
private val ContinueReadingButtonGridPadding = 6.dp
|
||||
private val ContinueReadingButtonListSpacing = 8.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: tachiyomi.domain.manga.model.MangaCover,
|
||||
coverAlpha: Float = 1f,
|
||||
coverBadgeStart: @Composable (RowScope.() -> Unit)? = null,
|
||||
coverBadgeEnd: @Composable (RowScope.() -> Unit)? = null,
|
||||
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,
|
||||
onClickContinueReading = onClickContinueReading,
|
||||
)
|
||||
} else if (onClickContinueReading != null) {
|
||||
ContinueReadingButton(
|
||||
modifier = Modifier
|
||||
.padding(ContinueReadingButtonGridPadding)
|
||||
.align(Alignment.BottomEnd),
|
||||
onClickContinueReading = onClickContinueReading,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title overlay for [MangaCompactGridItem]
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.CoverTextOverlay(
|
||||
title: String,
|
||||
onClickContinueReading: (() -> Unit)? = null,
|
||||
) {
|
||||
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),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
GridItemTitle(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(8.dp),
|
||||
title = title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
color = Color.White,
|
||||
shadow = Shadow(
|
||||
color = Color.Black,
|
||||
blurRadius = 4f,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (onClickContinueReading != null) {
|
||||
ContinueReadingButton(
|
||||
modifier = Modifier.padding(
|
||||
end = ContinueReadingButtonGridPadding,
|
||||
bottom = ContinueReadingButtonGridPadding,
|
||||
),
|
||||
onClickContinueReading = onClickContinueReading,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout of grid list item with title below the cover.
|
||||
*/
|
||||
@Composable
|
||||
fun MangaComfortableGridItem(
|
||||
isSelected: Boolean = false,
|
||||
title: String,
|
||||
coverData: tachiyomi.domain.manga.model.MangaCover,
|
||||
coverAlpha: Float = 1f,
|
||||
coverBadgeStart: (@Composable RowScope.() -> Unit)? = null,
|
||||
coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null,
|
||||
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,
|
||||
content = {
|
||||
if (onClickContinueReading != null) {
|
||||
ContinueReadingButton(
|
||||
modifier = Modifier
|
||||
.padding(ContinueReadingButtonGridPadding)
|
||||
.align(Alignment.BottomEnd),
|
||||
onClickContinueReading = 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,
|
||||
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(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(
|
||||
key = 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: tachiyomi.domain.manga.model.MangaCover,
|
||||
coverAlpha: Float = 1f,
|
||||
badge: @Composable (RowScope.() -> Unit),
|
||||
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 (onClickContinueReading != null) {
|
||||
ContinueReadingButton(
|
||||
modifier = Modifier.padding(start = ContinueReadingButtonListSpacing),
|
||||
onClickContinueReading = onClickContinueReading,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContinueReadingButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onClickContinueReading: () -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,10 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.theme.TachiyomiTheme
|
||||
import eu.kanade.presentation.util.ThemePreviews
|
||||
import eu.kanade.presentation.util.secondaryItemAlpha
|
||||
import eu.kanade.tachiyomi.R
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.util.ThemePreviews
|
||||
import tachiyomi.presentation.core.util.secondaryItemAlpha
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -27,10 +27,10 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import eu.kanade.presentation.theme.TachiyomiTheme
|
||||
import eu.kanade.presentation.util.ThemePreviews
|
||||
import eu.kanade.presentation.util.secondaryItemAlpha
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.util.ThemePreviews
|
||||
import tachiyomi.presentation.core.util.secondaryItemAlpha
|
||||
|
||||
@Composable
|
||||
fun InfoScaffold(
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.util.flingBehaviorIgnoringMotionScale
|
||||
|
||||
@Composable
|
||||
fun FastScrollLazyVerticalGrid(
|
||||
columns: GridCells,
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyGridState = rememberLazyGridState(),
|
||||
thumbAllowed: () -> Boolean = { true },
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
topContentPadding: Dp = Dp.Hairline,
|
||||
bottomContentPadding: Dp = Dp.Hairline,
|
||||
endContentPadding: Dp = Dp.Hairline,
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
flingBehavior: FlingBehavior = flingBehaviorIgnoringMotionScale(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyGridScope.() -> Unit,
|
||||
) {
|
||||
VerticalGridFastScroller(
|
||||
state = state,
|
||||
columns = columns,
|
||||
arrangement = horizontalArrangement,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
thumbAllowed = thumbAllowed,
|
||||
thumbColor = thumbColor,
|
||||
topContentPadding = topContentPadding,
|
||||
bottomContentPadding = bottomContentPadding,
|
||||
endContentPadding = endContentPadding,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = columns,
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
flingBehavior = flingBehavior,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.util.drawVerticalScrollbar
|
||||
import eu.kanade.presentation.util.flingBehaviorIgnoringMotionScale
|
||||
|
||||
/**
|
||||
* LazyColumn with fling animation fix
|
||||
*
|
||||
* @see flingBehaviorIgnoringMotionScale
|
||||
*/
|
||||
@Composable
|
||||
fun LazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
flingBehavior: FlingBehavior = flingBehaviorIgnoringMotionScale(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
androidx.compose.foundation.lazy.LazyColumn(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
flingBehavior = flingBehavior,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LazyColumn with scrollbar.
|
||||
*/
|
||||
@Composable
|
||||
fun ScrollbarLazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
flingBehavior: FlingBehavior = flingBehaviorIgnoringMotionScale(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val direction = LocalLayoutDirection.current
|
||||
val density = LocalDensity.current
|
||||
val positionOffset = remember(contentPadding) {
|
||||
with(density) { contentPadding.calculateEndPadding(direction).toPx() }
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.drawVerticalScrollbar(
|
||||
state = state,
|
||||
reverseScrolling = reverseLayout,
|
||||
positionOffsetPx = positionOffset,
|
||||
),
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
flingBehavior = flingBehavior,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LazyColumn with fast scroller.
|
||||
*/
|
||||
@Composable
|
||||
fun FastScrollLazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
flingBehavior: FlingBehavior = flingBehaviorIgnoringMotionScale(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
VerticalFastScroller(
|
||||
listState = state,
|
||||
modifier = modifier,
|
||||
topContentPadding = contentPadding.calculateTopPadding(),
|
||||
endContentPadding = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
flingBehavior = flingBehavior,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
||||
@Composable
|
||||
fun ListGroupHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier
|
||||
.padding(
|
||||
horizontal = MaterialTheme.padding.medium,
|
||||
vertical = MaterialTheme.padding.small,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.BookmarkAdd
|
||||
import androidx.compose.material.icons.outlined.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.DoneAll
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Label
|
||||
import androidx.compose.material.icons.outlined.RemoveDone
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.manga.DownloadAction
|
||||
import eu.kanade.tachiyomi.R
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun MangaBottomActionMenu(
|
||||
visible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onBookmarkClicked: (() -> Unit)? = null,
|
||||
onRemoveBookmarkClicked: (() -> Unit)? = null,
|
||||
onMarkAsReadClicked: (() -> Unit)? = null,
|
||||
onMarkAsUnreadClicked: (() -> Unit)? = null,
|
||||
onMarkPreviousAsReadClicked: (() -> Unit)? = null,
|
||||
onDownloadClicked: (() -> Unit)? = null,
|
||||
onDeleteClicked: (() -> Unit)? = null,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = expandVertically(expandFrom = Alignment.Bottom),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Bottom),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) }
|
||||
var resetJob: Job? = remember { null }
|
||||
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
(0 until 7).forEach { i -> confirm[i] = i == toConfirmIndex }
|
||||
resetJob?.cancel()
|
||||
resetJob = scope.launch {
|
||||
delay(1.seconds)
|
||||
if (isActive) confirm[toConfirmIndex] = false
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
WindowInsets.navigationBars
|
||||
.only(WindowInsetsSides.Bottom)
|
||||
.asPaddingValues(),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
) {
|
||||
if (onBookmarkClicked != null) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_bookmark),
|
||||
icon = Icons.Outlined.BookmarkAdd,
|
||||
toConfirm = confirm[0],
|
||||
onLongClick = { onLongClickItem(0) },
|
||||
onClick = onBookmarkClicked,
|
||||
)
|
||||
}
|
||||
if (onRemoveBookmarkClicked != null) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_remove_bookmark),
|
||||
icon = Icons.Outlined.BookmarkRemove,
|
||||
toConfirm = confirm[1],
|
||||
onLongClick = { onLongClickItem(1) },
|
||||
onClick = onRemoveBookmarkClicked,
|
||||
)
|
||||
}
|
||||
if (onMarkAsReadClicked != null) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_mark_as_read),
|
||||
icon = Icons.Outlined.DoneAll,
|
||||
toConfirm = confirm[2],
|
||||
onLongClick = { onLongClickItem(2) },
|
||||
onClick = onMarkAsReadClicked,
|
||||
)
|
||||
}
|
||||
if (onMarkAsUnreadClicked != null) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_mark_as_unread),
|
||||
icon = Icons.Outlined.RemoveDone,
|
||||
toConfirm = confirm[3],
|
||||
onLongClick = { onLongClickItem(3) },
|
||||
onClick = onMarkAsUnreadClicked,
|
||||
)
|
||||
}
|
||||
if (onMarkPreviousAsReadClicked != null) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_mark_previous_as_read),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_done_prev_24dp),
|
||||
toConfirm = confirm[4],
|
||||
onLongClick = { onLongClickItem(4) },
|
||||
onClick = onMarkPreviousAsReadClicked,
|
||||
)
|
||||
}
|
||||
if (onDownloadClicked != null) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_download),
|
||||
icon = Icons.Outlined.Download,
|
||||
toConfirm = confirm[5],
|
||||
onLongClick = { onLongClickItem(5) },
|
||||
onClick = onDownloadClicked,
|
||||
)
|
||||
}
|
||||
if (onDeleteClicked != null) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_delete),
|
||||
icon = Icons.Outlined.Delete,
|
||||
toConfirm = confirm[6],
|
||||
onLongClick = { onLongClickItem(6) },
|
||||
onClick = onDeleteClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.Button(
|
||||
title: String,
|
||||
icon: ImageVector,
|
||||
toConfirm: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
content: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val animatedWeight by animateFloatAsState(if (toConfirm) 2f else 1f)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.weight(animatedWeight)
|
||||
.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false),
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = toConfirm,
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
overflow = TextOverflow.Visible,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
content?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LibraryBottomActionMenu(
|
||||
visible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeCategoryClicked: () -> Unit,
|
||||
onMarkAsReadClicked: () -> Unit,
|
||||
onMarkAsUnreadClicked: () -> Unit,
|
||||
onDownloadClicked: ((DownloadAction) -> Unit)?,
|
||||
onDeleteClicked: () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = expandVertically(animationSpec = tween(delayMillis = 300)),
|
||||
exit = shrinkVertically(animationSpec = tween()),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val confirm = remember { mutableStateListOf(false, false, false, false, false) }
|
||||
var resetJob: Job? = remember { null }
|
||||
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
(0 until 5).forEach { i -> confirm[i] = i == toConfirmIndex }
|
||||
resetJob?.cancel()
|
||||
resetJob = scope.launch {
|
||||
delay(1.seconds)
|
||||
if (isActive) confirm[toConfirmIndex] = false
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.navigationBars
|
||||
.only(WindowInsetsSides.Bottom),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
) {
|
||||
Button(
|
||||
title = stringResource(R.string.action_move_category),
|
||||
icon = Icons.Outlined.Label,
|
||||
toConfirm = confirm[0],
|
||||
onLongClick = { onLongClickItem(0) },
|
||||
onClick = onChangeCategoryClicked,
|
||||
)
|
||||
Button(
|
||||
title = stringResource(R.string.action_mark_as_read),
|
||||
icon = Icons.Outlined.DoneAll,
|
||||
toConfirm = confirm[1],
|
||||
onLongClick = { onLongClickItem(1) },
|
||||
onClick = onMarkAsReadClicked,
|
||||
)
|
||||
Button(
|
||||
title = stringResource(R.string.action_mark_as_unread),
|
||||
icon = Icons.Outlined.RemoveDone,
|
||||
toConfirm = confirm[2],
|
||||
onLongClick = { onLongClickItem(2) },
|
||||
onClick = onMarkAsUnreadClicked,
|
||||
)
|
||||
if (onDownloadClicked != null) {
|
||||
var downloadExpanded by remember { mutableStateOf(false) }
|
||||
Button(
|
||||
title = stringResource(R.string.action_download),
|
||||
icon = Icons.Outlined.Download,
|
||||
toConfirm = confirm[3],
|
||||
onLongClick = { onLongClickItem(3) },
|
||||
onClick = { downloadExpanded = !downloadExpanded },
|
||||
) {
|
||||
val onDismissRequest = { downloadExpanded = false }
|
||||
DownloadDropdownMenu(
|
||||
expanded = downloadExpanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDownloadClicked = onDownloadClicked,
|
||||
includeDownloadAllOption = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
title = stringResource(R.string.action_delete),
|
||||
icon = Icons.Outlined.Delete,
|
||||
toConfirm = confirm[4],
|
||||
onLongClick = { onLongClickItem(4) },
|
||||
onClick = onDeleteClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import coil.compose.AsyncImage
|
||||
import eu.kanade.presentation.util.rememberResourceBitmapPainter
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
enum class MangaCover(val ratio: Float) {
|
||||
Square(1f / 1f),
|
||||
Book(2f / 3f),
|
||||
;
|
||||
|
||||
@Composable
|
||||
operator fun invoke(
|
||||
modifier: Modifier = Modifier,
|
||||
data: Any?,
|
||||
contentDescription: String = "",
|
||||
shape: Shape = MaterialTheme.shapes.extraSmall,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = data,
|
||||
placeholder = ColorPainter(CoverPlaceholderColor),
|
||||
error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier
|
||||
.aspectRatio(ratio)
|
||||
.clip(shape)
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(
|
||||
role = Role.Button,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val CoverPlaceholderColor = Color(0x1F888888)
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastMaxBy
|
||||
import androidx.compose.ui.util.fastSumBy
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import tachiyomi.presentation.core.components.LazyColumn
|
||||
|
||||
@Composable
|
||||
fun HorizontalPager(
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
||||
import tachiyomi.presentation.core.components.ListGroupHeader
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.LocalAbsoluteTonalElevation
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.util.minimumTouchTargetSize
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* Surface with additional onLongClick functionality.
|
||||
*
|
||||
* @see androidx.compose.material3.Surface
|
||||
*/
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun Surface(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape = RectangleShape,
|
||||
color: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = contentColorFor(color),
|
||||
tonalElevation: Dp = 0.dp,
|
||||
shadowElevation: Dp = 0.dp,
|
||||
border: BorderStroke? = null,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
LocalAbsoluteTonalElevation provides absoluteElevation,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.minimumTouchTargetSize()
|
||||
.surface(
|
||||
shape = shape,
|
||||
backgroundColor = surfaceColorAtElevation(
|
||||
color = color,
|
||||
elevation = absoluteElevation,
|
||||
),
|
||||
border = border,
|
||||
shadowElevation = shadowElevation,
|
||||
)
|
||||
.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = rememberRipple(),
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
),
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.surface(
|
||||
shape: Shape,
|
||||
backgroundColor: Color,
|
||||
border: BorderStroke?,
|
||||
shadowElevation: Dp,
|
||||
) = this
|
||||
.shadow(shadowElevation, shape, clip = false)
|
||||
.then(if (border != null) Modifier.border(border, shape) else Modifier)
|
||||
.background(color = backgroundColor, shape = shape)
|
||||
.clip(shape)
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color {
|
||||
return if (color == MaterialTheme.colorScheme.surface) {
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
private fun ColorScheme.surfaceColorAtElevation(
|
||||
elevation: Dp,
|
||||
): Color {
|
||||
if (elevation == 0.dp) return surface
|
||||
val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f
|
||||
return surfaceTint.copy(alpha = alpha).compositeOver(surface)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.util.fastForEachIndexed
|
||||
import eu.kanade.tachiyomi.R
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.presentation.core.components.material.Divider
|
||||
import tachiyomi.presentation.core.components.material.TabIndicator
|
||||
|
||||
object TabbedDialogPaddings {
|
||||
val Horizontal = 24.dp
|
||||
|
||||
@@ -22,6 +22,8 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.TabIndicator
|
||||
import tachiyomi.presentation.core.components.material.TabText
|
||||
|
||||
@Composable
|
||||
fun TabbedScreen(
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TabPosition
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import tachiyomi.presentation.core.components.Pill
|
||||
|
||||
@Composable
|
||||
fun TabIndicator(currentTabPosition: TabPosition) {
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier
|
||||
.tabIndicatorOffset(currentTabPosition)
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabText(
|
||||
text: String,
|
||||
badgeCount: Int? = null,
|
||||
) {
|
||||
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = text)
|
||||
if (badgeCount != null) {
|
||||
Pill(
|
||||
text = "$badgeCount",
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.util.clickableNoIndication
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
|
||||
@Composable
|
||||
fun TrackLogoIcon(
|
||||
service: TrackService,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val modifier = if (onClick != null) {
|
||||
Modifier.clickableNoIndication(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.background(color = Color(service.getLogoColor()), shape = MaterialTheme.shapes.medium)
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(service.getLogo()),
|
||||
contentDescription = stringResource(service.nameRes()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.systemGestureExclusion
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastMaxBy
|
||||
import eu.kanade.presentation.components.Scroller.STICKY_HEADER_KEY_PREFIX
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Draws vertical fast scroller to a lazy list
|
||||
*
|
||||
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||
*/
|
||||
@Composable
|
||||
fun VerticalFastScroller(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbAllowed: () -> Boolean = { true },
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
topContentPadding: Dp = Dp.Hairline,
|
||||
bottomContentPadding: Dp = Dp.Hairline,
|
||||
endContentPadding: Dp = Dp.Hairline,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
SubcomposeLayout(modifier = modifier) { constraints ->
|
||||
val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
|
||||
val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
|
||||
val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
|
||||
|
||||
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val scrollerPlaceable = subcompose("scroller") {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
|
||||
if (!showScroller) return@subcompose
|
||||
|
||||
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
|
||||
var thumbOffsetY by remember(thumbTopPadding) { mutableStateOf(thumbTopPadding) }
|
||||
|
||||
val dragInteractionSource = remember { MutableInteractionSource() }
|
||||
val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
|
||||
val scrolled = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
}
|
||||
|
||||
val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
|
||||
val heightPx = contentHeight.toFloat() - thumbTopPadding - thumbBottomPadding - listState.layoutInfo.afterContentPadding
|
||||
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
|
||||
val trackHeightPx = heightPx - thumbHeightPx
|
||||
|
||||
// When thumb dragged
|
||||
LaunchedEffect(thumbOffsetY) {
|
||||
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
|
||||
val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
|
||||
val scrollItem = layoutInfo.totalItemsCount * scrollRatio
|
||||
val scrollItemRounded = scrollItem.roundToInt()
|
||||
val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0
|
||||
val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded)
|
||||
listState.scrollToItem(index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt())
|
||||
scrolled.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// When list scrolled
|
||||
LaunchedEffect(listState.firstVisibleItemScrollOffset) {
|
||||
if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
|
||||
val scrollOffset = computeScrollOffset(state = listState)
|
||||
val scrollRange = computeScrollRange(state = listState)
|
||||
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
|
||||
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
|
||||
scrolled.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// Thumb alpha
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val isThumbVisible = alpha.value > 0f
|
||||
LaunchedEffect(scrolled, alpha) {
|
||||
scrolled.collectLatest {
|
||||
if (thumbAllowed()) {
|
||||
alpha.snapTo(1f)
|
||||
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
|
||||
} else {
|
||||
alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, thumbOffsetY.roundToInt()) }
|
||||
.then(
|
||||
// Recompose opts
|
||||
if (isThumbVisible && !listState.isScrollInProgress) {
|
||||
Modifier.draggable(
|
||||
interactionSource = dragInteractionSource,
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
val newOffsetY = thumbOffsetY + delta
|
||||
thumbOffsetY = newOffsetY.coerceIn(
|
||||
thumbTopPadding,
|
||||
thumbTopPadding + trackHeightPx,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
// Exclude thumb from gesture area only when needed
|
||||
if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) {
|
||||
Modifier.systemGestureExclusion()
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.height(ThumbLength)
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(end = endContentPadding)
|
||||
.width(ThumbThickness)
|
||||
.alpha(alpha.value)
|
||||
.background(color = thumbColor, shape = ThumbShape),
|
||||
)
|
||||
}.map { it.measure(scrollerConstraints) }
|
||||
val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
|
||||
|
||||
layout(contentWidth, contentHeight) {
|
||||
contentPlaceable.fastForEach {
|
||||
it.place(0, 0)
|
||||
}
|
||||
scrollerPlaceable.fastForEach {
|
||||
it.placeRelative(contentWidth - scrollerWidth, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberColumnWidthSums(
|
||||
columns: GridCells,
|
||||
horizontalArrangement: Arrangement.Horizontal,
|
||||
contentPadding: PaddingValues,
|
||||
) = remember<Density.(Constraints) -> List<Int>>(
|
||||
columns,
|
||||
horizontalArrangement,
|
||||
contentPadding,
|
||||
) {
|
||||
{ constraints ->
|
||||
require(constraints.maxWidth != Constraints.Infinity) {
|
||||
"LazyVerticalGrid's width should be bound by parent"
|
||||
}
|
||||
val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) +
|
||||
contentPadding.calculateEndPadding(LayoutDirection.Ltr)
|
||||
val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx()
|
||||
with(columns) {
|
||||
calculateCrossAxisCellSizes(
|
||||
gridWidth,
|
||||
horizontalArrangement.spacing.roundToPx(),
|
||||
).toMutableList().apply {
|
||||
for (i in 1 until size) {
|
||||
this[i] += this[i - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalGridFastScroller(
|
||||
state: LazyGridState,
|
||||
columns: GridCells,
|
||||
arrangement: Arrangement.Horizontal,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbAllowed: () -> Boolean = { true },
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
topContentPadding: Dp = Dp.Hairline,
|
||||
bottomContentPadding: Dp = Dp.Hairline,
|
||||
endContentPadding: Dp = Dp.Hairline,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val slotSizesSums = rememberColumnWidthSums(
|
||||
columns = columns,
|
||||
horizontalArrangement = arrangement,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
|
||||
SubcomposeLayout(modifier = modifier) { constraints ->
|
||||
val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
|
||||
val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
|
||||
val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
|
||||
|
||||
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val scrollerPlaceable = subcompose("scroller") {
|
||||
val layoutInfo = state.layoutInfo
|
||||
val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
|
||||
if (!showScroller) return@subcompose
|
||||
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
|
||||
var thumbOffsetY by remember(thumbTopPadding) { mutableStateOf(thumbTopPadding) }
|
||||
|
||||
val dragInteractionSource = remember { MutableInteractionSource() }
|
||||
val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
|
||||
val scrolled = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
}
|
||||
|
||||
val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
|
||||
val heightPx = contentHeight.toFloat() - thumbTopPadding - thumbBottomPadding - state.layoutInfo.afterContentPadding
|
||||
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
|
||||
val trackHeightPx = heightPx - thumbHeightPx
|
||||
|
||||
val columnCount = remember { slotSizesSums(constraints).size }
|
||||
|
||||
// When thumb dragged
|
||||
LaunchedEffect(thumbOffsetY) {
|
||||
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
|
||||
val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
|
||||
val scrollItem = layoutInfo.totalItemsCount * scrollRatio
|
||||
// I can't think of anything else rn but this'll do
|
||||
val scrollItemWhole = scrollItem.toInt()
|
||||
val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount
|
||||
val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole
|
||||
val offsetPerItem = 1f / columnCount
|
||||
val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1))
|
||||
|
||||
// TODO: Sometimes item height is not available when scrolling up
|
||||
val scrollItemSize = (1..columnCount).maxOf { num ->
|
||||
val actualIndex = if (num != columnNum) {
|
||||
scrollItemWhole + num - columnCount
|
||||
} else {
|
||||
scrollItemWhole
|
||||
}
|
||||
layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0
|
||||
}
|
||||
val scrollItemOffset = scrollItemSize * offsetRatio
|
||||
|
||||
state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt())
|
||||
scrolled.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// When list scrolled
|
||||
LaunchedEffect(state.firstVisibleItemScrollOffset) {
|
||||
if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
|
||||
val scrollOffset = computeScrollOffset(state = state)
|
||||
val scrollRange = computeScrollRange(state = state)
|
||||
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
|
||||
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
|
||||
scrolled.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// Thumb alpha
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val isThumbVisible = alpha.value > 0f
|
||||
LaunchedEffect(scrolled, alpha) {
|
||||
scrolled.collectLatest {
|
||||
if (thumbAllowed()) {
|
||||
alpha.snapTo(1f)
|
||||
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
|
||||
} else {
|
||||
alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, thumbOffsetY.roundToInt()) }
|
||||
.then(
|
||||
// Recompose opts
|
||||
if (isThumbVisible && !state.isScrollInProgress) {
|
||||
Modifier.draggable(
|
||||
interactionSource = dragInteractionSource,
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
val newOffsetY = thumbOffsetY + delta
|
||||
thumbOffsetY = newOffsetY.coerceIn(
|
||||
thumbTopPadding,
|
||||
thumbTopPadding + trackHeightPx,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
// Exclude thumb from gesture area only when needed
|
||||
if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) {
|
||||
Modifier.systemGestureExclusion()
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.height(ThumbLength)
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(end = endContentPadding)
|
||||
.width(ThumbThickness)
|
||||
.alpha(alpha.value)
|
||||
.background(color = thumbColor, shape = ThumbShape),
|
||||
)
|
||||
}.map { it.measure(scrollerConstraints) }
|
||||
val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
|
||||
|
||||
layout(contentWidth, contentHeight) {
|
||||
contentPlaceable.fastForEach {
|
||||
it.place(0, 0)
|
||||
}
|
||||
scrollerPlaceable.fastForEach {
|
||||
it.placeRelative(contentWidth - scrollerWidth, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeScrollOffset(state: LazyGridState): Int {
|
||||
if (state.layoutInfo.totalItemsCount == 0) return 0
|
||||
val visibleItems = state.layoutInfo.visibleItemsInfo
|
||||
val startChild = visibleItems.first()
|
||||
val endChild = visibleItems.last()
|
||||
val minPosition = min(startChild.index, endChild.index)
|
||||
val maxPosition = max(startChild.index, endChild.index)
|
||||
val itemsBefore = minPosition.coerceAtLeast(0)
|
||||
val startDecoratedTop = startChild.offset.y
|
||||
val laidOutArea = abs((endChild.offset.y + endChild.size.height) - startDecoratedTop)
|
||||
val itemRange = abs(minPosition - maxPosition) + 1
|
||||
val avgSizePerRow = laidOutArea.toFloat() / itemRange
|
||||
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
|
||||
}
|
||||
|
||||
private fun computeScrollRange(state: LazyGridState): Int {
|
||||
if (state.layoutInfo.totalItemsCount == 0) return 0
|
||||
val visibleItems = state.layoutInfo.visibleItemsInfo
|
||||
val startChild = visibleItems.first()
|
||||
val endChild = visibleItems.last()
|
||||
val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
|
||||
val laidOutRange = abs(startChild.index - endChild.index) + 1
|
||||
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
|
||||
}
|
||||
|
||||
private fun computeScrollOffset(state: LazyListState): Int {
|
||||
if (state.layoutInfo.totalItemsCount == 0) return 0
|
||||
val visibleItems = state.layoutInfo.visibleItemsInfo
|
||||
val startChild = visibleItems
|
||||
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
|
||||
val endChild = visibleItems.last()
|
||||
val minPosition = min(startChild.index, endChild.index)
|
||||
val maxPosition = max(startChild.index, endChild.index)
|
||||
val itemsBefore = minPosition.coerceAtLeast(0)
|
||||
val startDecoratedTop = startChild.top
|
||||
val laidOutArea = abs(endChild.bottom - startDecoratedTop)
|
||||
val itemRange = abs(minPosition - maxPosition) + 1
|
||||
val avgSizePerRow = laidOutArea.toFloat() / itemRange
|
||||
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
|
||||
}
|
||||
|
||||
private fun computeScrollRange(state: LazyListState): Int {
|
||||
if (state.layoutInfo.totalItemsCount == 0) return 0
|
||||
val visibleItems = state.layoutInfo.visibleItemsInfo
|
||||
val startChild = visibleItems
|
||||
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
|
||||
val endChild = visibleItems.last()
|
||||
val laidOutArea = endChild.bottom - startChild.top
|
||||
val laidOutRange = abs(startChild.index - endChild.index) + 1
|
||||
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
|
||||
}
|
||||
|
||||
object Scroller {
|
||||
const val STICKY_HEADER_KEY_PREFIX = "sticky:"
|
||||
}
|
||||
|
||||
private val ThumbLength = 48.dp
|
||||
private val ThumbThickness = 8.dp
|
||||
private val ThumbShape = RoundedCornerShape(ThumbThickness / 2)
|
||||
private val FadeOutAnimationSpec = tween<Float>(
|
||||
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||
delayMillis = 2000,
|
||||
)
|
||||
private val ImmediateFadeOutAnimationSpec = tween<Float>(
|
||||
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||
)
|
||||
|
||||
private val LazyListItemInfo.top: Int
|
||||
get() = offset
|
||||
|
||||
private val LazyListItemInfo.bottom: Int
|
||||
get() = offset + size
|
||||
@@ -1,286 +0,0 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import java.text.DateFormatSymbols
|
||||
import java.time.LocalDate
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun WheelPicker(
|
||||
modifier: Modifier = Modifier,
|
||||
startIndex: Int = 0,
|
||||
count: Int,
|
||||
size: DpSize = DpSize(128.dp, 128.dp),
|
||||
onSelectionChanged: (index: Int) -> Unit = {},
|
||||
backgroundContent: (@Composable (size: DpSize) -> Unit)? = {
|
||||
WheelPickerDefaults.Background(size = it)
|
||||
},
|
||||
itemContent: @Composable LazyItemScope.(index: Int) -> Unit,
|
||||
) {
|
||||
val lazyListState = rememberLazyListState(startIndex)
|
||||
|
||||
LaunchedEffect(lazyListState, onSelectionChanged) {
|
||||
snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
|
||||
.map { calculateSnappedItemIndex(lazyListState) }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
onSelectionChanged(it)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
backgroundContent?.invoke(size)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.height(size.height)
|
||||
.width(size.width),
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(vertical = size.height / RowCount * ((RowCount - 1) / 2)),
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState),
|
||||
) {
|
||||
items(count) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(size.height / RowCount)
|
||||
.width(size.width)
|
||||
.alpha(
|
||||
calculateAnimatedAlpha(
|
||||
lazyListState = lazyListState,
|
||||
index = index,
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
itemContent(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WheelTextPicker(
|
||||
modifier: Modifier = Modifier,
|
||||
startIndex: Int = 0,
|
||||
texts: List<String>,
|
||||
size: DpSize = DpSize(128.dp, 128.dp),
|
||||
onSelectionChanged: (index: Int) -> Unit = {},
|
||||
backgroundContent: (@Composable (size: DpSize) -> Unit)? = {
|
||||
WheelPickerDefaults.Background(size = it)
|
||||
},
|
||||
) {
|
||||
WheelPicker(
|
||||
modifier = modifier,
|
||||
startIndex = startIndex,
|
||||
count = remember(texts) { texts.size },
|
||||
size = size,
|
||||
onSelectionChanged = onSelectionChanged,
|
||||
backgroundContent = backgroundContent,
|
||||
) {
|
||||
WheelPickerDefaults.Item(text = texts[it])
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WheelDatePicker(
|
||||
modifier: Modifier = Modifier,
|
||||
startDate: LocalDate = LocalDate.now(),
|
||||
minDate: LocalDate? = null,
|
||||
maxDate: LocalDate? = null,
|
||||
size: DpSize = DpSize(256.dp, 128.dp),
|
||||
backgroundContent: (@Composable (size: DpSize) -> Unit)? = {
|
||||
WheelPickerDefaults.Background(size = it)
|
||||
},
|
||||
onSelectionChanged: (date: LocalDate) -> Unit = {},
|
||||
) {
|
||||
var internalSelection by remember { mutableStateOf(startDate) }
|
||||
val internalOnSelectionChange: (LocalDate) -> Unit = {
|
||||
internalSelection = it
|
||||
onSelectionChanged(internalSelection)
|
||||
}
|
||||
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
backgroundContent?.invoke(size)
|
||||
Row {
|
||||
val singularPickerSize = DpSize(
|
||||
width = size.width / 3,
|
||||
height = size.height,
|
||||
)
|
||||
|
||||
// Day
|
||||
val dayOfMonths = remember(internalSelection, minDate, maxDate) {
|
||||
if (minDate == null && maxDate == null) {
|
||||
1..internalSelection.lengthOfMonth()
|
||||
} else {
|
||||
val minDay = if (minDate?.month == internalSelection.month &&
|
||||
minDate?.year == internalSelection.year
|
||||
) {
|
||||
minDate.dayOfMonth
|
||||
} else {
|
||||
1
|
||||
}
|
||||
val maxDay = if (maxDate?.month == internalSelection.month &&
|
||||
maxDate?.year == internalSelection.year
|
||||
) {
|
||||
maxDate.dayOfMonth
|
||||
} else {
|
||||
31
|
||||
}
|
||||
minDay..maxDay.coerceAtMost(internalSelection.lengthOfMonth())
|
||||
}.toList()
|
||||
}
|
||||
WheelTextPicker(
|
||||
size = singularPickerSize,
|
||||
texts = dayOfMonths.map { it.toString() },
|
||||
backgroundContent = null,
|
||||
startIndex = dayOfMonths.indexOfFirst { it == startDate.dayOfMonth }.coerceAtLeast(0),
|
||||
onSelectionChanged = { index ->
|
||||
val newDayOfMonth = dayOfMonths[index]
|
||||
internalOnSelectionChange(internalSelection.withDayOfMonth(newDayOfMonth))
|
||||
},
|
||||
)
|
||||
|
||||
// Month
|
||||
val months = remember(internalSelection, minDate, maxDate) {
|
||||
val monthRange = if (minDate == null && maxDate == null) {
|
||||
1..12
|
||||
} else {
|
||||
val minMonth = if (minDate?.year == internalSelection.year) {
|
||||
minDate.monthValue
|
||||
} else {
|
||||
1
|
||||
}
|
||||
val maxMonth = if (maxDate?.year == internalSelection.year) {
|
||||
maxDate.monthValue
|
||||
} else {
|
||||
12
|
||||
}
|
||||
minMonth..maxMonth
|
||||
}
|
||||
val dateFormatSymbols = DateFormatSymbols()
|
||||
monthRange.map { it to dateFormatSymbols.months[it - 1] }
|
||||
}
|
||||
WheelTextPicker(
|
||||
size = singularPickerSize,
|
||||
texts = months.map { it.second },
|
||||
backgroundContent = null,
|
||||
startIndex = months.indexOfFirst { it.first == startDate.monthValue }.coerceAtLeast(0),
|
||||
onSelectionChanged = { index ->
|
||||
val newMonth = months[index].first
|
||||
internalOnSelectionChange(internalSelection.withMonth(newMonth))
|
||||
},
|
||||
)
|
||||
|
||||
// Year
|
||||
val years = remember(minDate, maxDate) {
|
||||
val minYear = minDate?.year?.coerceAtLeast(1900) ?: 1900
|
||||
val maxYear = maxDate?.year?.coerceAtMost(2100) ?: 2100
|
||||
val yearRange = minYear..maxYear
|
||||
yearRange.toList()
|
||||
}
|
||||
WheelTextPicker(
|
||||
size = singularPickerSize,
|
||||
texts = years.map { it.toString() },
|
||||
backgroundContent = null,
|
||||
startIndex = years.indexOfFirst { it == startDate.year }.coerceAtLeast(0),
|
||||
onSelectionChanged = { index ->
|
||||
val newYear = years[index]
|
||||
internalOnSelectionChange(internalSelection.withYear(newYear))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListState.snapOffsetForItem(itemInfo: LazyListItemInfo): Int {
|
||||
val startScrollOffset = 0
|
||||
val endScrollOffset = layoutInfo.let { it.viewportEndOffset - it.afterContentPadding }
|
||||
return startScrollOffset + (endScrollOffset - startScrollOffset - itemInfo.size) / 2
|
||||
}
|
||||
|
||||
private fun LazyListState.distanceToSnapForIndex(index: Int): Int {
|
||||
val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
|
||||
if (itemInfo != null) {
|
||||
return itemInfo.offset - snapOffsetForItem(itemInfo)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun calculateAnimatedAlpha(
|
||||
lazyListState: LazyListState,
|
||||
index: Int,
|
||||
): Float {
|
||||
val distanceToIndexSnap = lazyListState.distanceToSnapForIndex(index).absoluteValue
|
||||
val viewPortHeight = lazyListState.layoutInfo.viewportSize.height.toFloat()
|
||||
val singleViewPortHeight = viewPortHeight / RowCount
|
||||
return if (distanceToIndexSnap in 0..singleViewPortHeight.toInt()) {
|
||||
1.2f - (distanceToIndexSnap / singleViewPortHeight)
|
||||
} else {
|
||||
0.2f
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSnappedItemIndex(lazyListState: LazyListState): Int {
|
||||
return lazyListState.layoutInfo.visibleItemsInfo
|
||||
.maxBy { calculateAnimatedAlpha(lazyListState, it.index) }
|
||||
.index
|
||||
}
|
||||
|
||||
object WheelPickerDefaults {
|
||||
@Composable
|
||||
fun Background(size: DpSize) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier
|
||||
.size(size.width, size.height / RowCount),
|
||||
shape = RoundedCornerShape(MaterialTheme.padding.medium),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
|
||||
content = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Item(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val RowCount = 3
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.kanade.presentation.components
|
||||
package eu.kanade.presentation.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,6 +25,7 @@ import eu.kanade.core.prefs.CheckboxState
|
||||
import eu.kanade.presentation.category.visualName
|
||||
import eu.kanade.tachiyomi.R
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.presentation.core.components.material.TextButton
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
||||
@Composable
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.kanade.presentation.components
|
||||
package eu.kanade.presentation.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.kanade.presentation.components
|
||||
package eu.kanade.presentation.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
Reference in New Issue
Block a user