MangaController overhaul (#7244)

This commit is contained in:
Ivan Iskandar
2022-06-25 22:03:48 +07:00
committed by GitHub
parent cf7ca5bd28
commit 33a778873a
57 changed files with 3701 additions and 2955 deletions

View File

@@ -0,0 +1,61 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
@Composable
fun ChapterHeader(
chapterCount: Int?,
isChapterFiltered: Boolean,
onFilterButtonClicked: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (chapterCount == null) {
stringResource(id = R.string.chapters)
} else {
quantityStringResource(id = R.plurals.manga_num_chapters, quantity = chapterCount)
},
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onBackground,
)
CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) {
IconButton(onClick = onFilterButtonClicked) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = stringResource(id = R.string.action_filter),
tint = if (isChapterFiltered) {
Color(LocalContext.current.getResourceColor(R.attr.colorFilterActive))
} else {
MaterialTheme.colorScheme.onBackground
},
)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package eu.kanade.presentation.manga.components
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun DotSeparatorText() {
Text(text = "")
}

View File

@@ -0,0 +1,197 @@
package eu.kanade.presentation.manga.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.tachiyomi.R
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@Composable
fun MangaBottomActionMenu(
visible: Boolean,
modifier: Modifier = Modifier,
onBookmarkClicked: (() -> Unit)?,
onRemoveBookmarkClicked: (() -> Unit)?,
onMarkAsReadClicked: (() -> Unit)?,
onMarkAsUnreadClicked: (() -> Unit)?,
onMarkPreviousAsReadClicked: (() -> Unit)?,
onDownloadClicked: (() -> Unit)?,
onDeleteClicked: (() -> Unit)?,
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(expandFrom = Alignment.Bottom),
exit = shrinkVertically(shrinkTowards = Alignment.Bottom),
) {
val scope = rememberCoroutineScope()
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large,
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(1000)
if (isActive) confirm[toConfirmIndex] = false
}
}
Row(
modifier = Modifier
.navigationBarsPadding()
.padding(horizontal = 8.dp, vertical = 12.dp),
) {
if (onBookmarkClicked != null) {
Button(
title = stringResource(id = R.string.action_bookmark),
icon = Icons.Default.BookmarkAdd,
toConfirm = confirm[0],
onLongClick = { onLongClickItem(0) },
onClick = onBookmarkClicked,
)
}
if (onRemoveBookmarkClicked != null) {
Button(
title = stringResource(id = R.string.action_remove_bookmark),
icon = Icons.Default.BookmarkRemove,
toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) },
onClick = onRemoveBookmarkClicked,
)
}
if (onMarkAsReadClicked != null) {
Button(
title = stringResource(id = R.string.action_mark_as_read),
icon = Icons.Default.DoneAll,
toConfirm = confirm[2],
onLongClick = { onLongClickItem(2) },
onClick = onMarkAsReadClicked,
)
}
if (onMarkAsUnreadClicked != null) {
Button(
title = stringResource(id = R.string.action_mark_as_unread),
icon = Icons.Default.RemoveDone,
toConfirm = confirm[3],
onLongClick = { onLongClickItem(3) },
onClick = onMarkAsUnreadClicked,
)
}
if (onMarkPreviousAsReadClicked != null) {
Button(
title = stringResource(id = 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(id = R.string.action_download),
icon = Icons.Default.Download,
toConfirm = confirm[5],
onLongClick = { onLongClickItem(5) },
onClick = onDownloadClicked,
)
}
if (onDeleteClicked != null) {
Button(
title = stringResource(id = R.string.action_delete),
icon = Icons.Default.Delete,
toConfirm = confirm[6],
onLongClick = { onLongClickItem(6) },
onClick = onDeleteClicked,
)
}
}
}
}
}
@Composable
private fun RowScope.Button(
title: String,
icon: ImageVector,
toConfirm: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
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,
)
}
}
}

View File

@@ -0,0 +1,139 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
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.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
@Composable
fun MangaChapterListItem(
modifier: Modifier = Modifier,
title: String,
date: String?,
readProgress: String?,
scanlator: String?,
read: Boolean,
bookmark: Boolean,
selected: Boolean,
downloadState: Download.State,
downloadProgress: Int,
onLongClick: () -> Unit,
onClick: () -> Unit,
onDownloadClick: ((ChapterDownloadAction) -> Unit)?,
) {
Row(
modifier = modifier
.background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
) {
Column(
modifier = Modifier
.weight(1f)
.alpha(if (read) ReadItemAlpha else 1f),
) {
val textColor = if (bookmark) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableStateOf(0) }
if (bookmark) {
Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = stringResource(id = R.string.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = textColor,
)
Spacer(modifier = Modifier.width(2.dp))
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium
.copy(color = textColor),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
)
}
Spacer(modifier = Modifier.height(6.dp))
Row {
ProvideTextStyle(
value = MaterialTheme.typography.bodyMedium
.copy(color = textColor, fontSize = 12.sp),
) {
if (date != null) {
Text(
text = date,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (readProgress != null || scanlator != null) DotSeparatorText()
}
if (readProgress != null) {
Text(
text = readProgress,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(ReadItemAlpha),
)
if (scanlator != null) DotSeparatorText()
}
if (scanlator != null) {
Text(
text = scanlator,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
// Download view
if (onDownloadClick != null) {
ChapterDownloadIndicator(
modifier = Modifier.padding(start = 4.dp),
downloadState = downloadState,
downloadProgress = downloadProgress,
onClick = onDownloadClick,
)
}
}
}
private const val ReadItemAlpha = .38f

View File

@@ -0,0 +1,616 @@
package eu.kanade.presentation.manga.components
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
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.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachMoney
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.util.clickableNoIndication
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlin.math.roundToInt
@Composable
fun MangaInfoHeader(
modifier: Modifier = Modifier,
windowWidthSizeClass: WindowWidthSizeClass,
appBarPadding: Dp,
title: String,
author: String?,
artist: String?,
description: String?,
tagsProvider: () -> List<String>?,
sourceName: String,
isStubSource: Boolean,
coverDataProvider: () -> Manga,
favorite: Boolean,
status: Long,
trackingCount: Int,
fromSource: Boolean,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onEditCategory: (() -> Unit)?,
onCoverClick: () -> Unit,
doSearch: (query: String, global: Boolean) -> Unit,
) {
val context = LocalContext.current
Column(modifier = modifier) {
Box {
// Backdrop
val backdropGradientColors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background,
)
AsyncImage(
model = coverDataProvider(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(colors = backdropGradientColors),
)
}
.alpha(.2f),
)
// Manga & source info
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
MangaAndSourceTitlesSmall(
appBarPadding = appBarPadding,
coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick,
title = title,
context = context,
doSearch = doSearch,
author = author,
artist = artist,
status = status,
sourceName = sourceName,
isStubSource = isStubSource,
)
} else {
MangaAndSourceTitlesLarge(
appBarPadding = appBarPadding,
coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick,
title = title,
context = context,
doSearch = doSearch,
author = author,
artist = artist,
status = status,
sourceName = sourceName,
isStubSource = isStubSource,
)
}
}
// Action buttons
Row(modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) {
val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
MangaActionButton(
title = if (favorite) {
stringResource(id = R.string.in_library)
} else {
stringResource(id = R.string.add_to_library)
},
icon = if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
color = if (favorite) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onAddToLibraryClicked,
onLongClick = onEditCategory,
)
if (onTrackingClicked != null) {
MangaActionButton(
title = if (trackingCount == 0) {
stringResource(id = R.string.manga_tracking_tab)
} else {
quantityStringResource(id = R.plurals.num_trackers, quantity = trackingCount, trackingCount)
},
icon = if (trackingCount == 0) Icons.Default.Sync else Icons.Default.Done,
color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary,
onClick = onTrackingClicked,
)
}
if (onWebViewClicked != null) {
MangaActionButton(
title = stringResource(id = R.string.action_web_view),
icon = Icons.Default.Public,
color = defaultActionButtonColor,
onClick = onWebViewClicked,
)
}
}
// Expandable description-tags
Column {
val (expanded, onExpanded) = rememberSaveable {
mutableStateOf(fromSource || windowWidthSizeClass != WindowWidthSizeClass.Compact)
}
if (!description.isNullOrBlank()) {
val trimmedDescription = remember(description) {
description
.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
}
MangaSummary(
expandedDescription = description,
shrunkDescription = trimmedDescription,
expanded = expanded,
modifier = Modifier
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
.clickableNoIndication(
onLongClick = { context.copyToClipboard(description, description) },
onClick = { onExpanded(!expanded) },
),
)
}
val tags = tagsProvider()
if (!tags.isNullOrEmpty()) {
Box(
modifier = Modifier
.padding(top = 8.dp)
.padding(vertical = 12.dp)
.animateContentSize(),
) {
if (expanded) {
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
mainAxisSpacing = 4.dp,
crossAxisSpacing = 8.dp,
) {
tags.forEach {
TagsChip(
text = it,
onClick = { onTagClicked(it) },
)
}
}
} else {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(items = tags) {
TagsChip(
text = it,
onClick = { onTagClicked(it) },
)
}
}
}
}
}
}
}
}
@Composable
private fun MangaAndSourceTitlesLarge(
appBarPadding: Dp,
coverDataProvider: () -> Manga,
onCoverClick: () -> Unit,
title: String,
context: Context,
doSearch: (query: String, global: Boolean) -> Unit,
author: String?,
artist: String?,
status: Long,
sourceName: String,
isStubSource: Boolean,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
MangaCover.Book(
modifier = Modifier.fillMaxWidth(0.4f),
data = coverDataProvider(),
onClick = onCoverClick,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication(
onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
onClick = { if (title.isNotBlank()) doSearch(title, true) },
),
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = author?.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown_author),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard(
author,
author,
)
},
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
),
textAlign = TextAlign.Center,
)
if (!artist.isNullOrBlank()) {
Text(
text = artist,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = { context.copyToClipboard(artist, artist) },
onClick = { doSearch(artist, true) },
),
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.secondaryItemAlpha(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = when (status) {
SManga.ONGOING.toLong() -> Icons.Default.Schedule
SManga.COMPLETED.toLong() -> Icons.Default.DoneAll
SManga.LICENSED.toLong() -> Icons.Default.AttachMoney
SManga.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
SManga.CANCELLED.toLong() -> Icons.Default.Close
SManga.ON_HIATUS.toLong() -> Icons.Default.Pause
else -> Icons.Default.Block
},
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
)
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
Text(
text = when (status) {
SManga.ONGOING.toLong() -> stringResource(id = R.string.ongoing)
SManga.COMPLETED.toLong() -> stringResource(id = R.string.completed)
SManga.LICENSED.toLong() -> stringResource(id = R.string.licensed)
SManga.PUBLISHING_FINISHED.toLong() -> stringResource(id = R.string.publishing_finished)
SManga.CANCELLED.toLong() -> stringResource(id = R.string.cancelled)
SManga.ON_HIATUS.toLong() -> stringResource(id = R.string.on_hiatus)
else -> stringResource(id = R.string.unknown)
},
)
DotSeparatorText()
if (isStubSource) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
Text(
text = sourceName,
modifier = Modifier.clickableNoIndication { doSearch(sourceName, false) },
)
}
}
}
}
@Composable
private fun MangaAndSourceTitlesSmall(
appBarPadding: Dp,
coverDataProvider: () -> Manga,
onCoverClick: () -> Unit,
title: String,
context: Context,
doSearch: (query: String, global: Boolean) -> Unit,
author: String?,
artist: String?,
status: Long,
sourceName: String,
isStubSource: Boolean,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover.Book(
modifier = Modifier.sizeIn(maxWidth = 100.dp),
data = coverDataProvider(),
onClick = onCoverClick,
)
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(
text = title.ifBlank { stringResource(id = R.string.unknown) },
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication(
onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
onClick = { if (title.isNotBlank()) doSearch(title, true) },
),
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = author?.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown_author),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard(
author,
author,
)
},
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
),
)
if (!artist.isNullOrBlank()) {
Text(
text = artist,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = { context.copyToClipboard(artist, artist) },
onClick = { doSearch(artist, true) },
),
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.secondaryItemAlpha(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = when (status) {
SManga.ONGOING.toLong() -> Icons.Default.Schedule
SManga.COMPLETED.toLong() -> Icons.Default.DoneAll
SManga.LICENSED.toLong() -> Icons.Default.AttachMoney
SManga.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
SManga.CANCELLED.toLong() -> Icons.Default.Close
SManga.ON_HIATUS.toLong() -> Icons.Default.Pause
else -> Icons.Default.Block
},
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
)
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
Text(
text = when (status) {
SManga.ONGOING.toLong() -> stringResource(id = R.string.ongoing)
SManga.COMPLETED.toLong() -> stringResource(id = R.string.completed)
SManga.LICENSED.toLong() -> stringResource(id = R.string.licensed)
SManga.PUBLISHING_FINISHED.toLong() -> stringResource(id = R.string.publishing_finished)
SManga.CANCELLED.toLong() -> stringResource(id = R.string.cancelled)
SManga.ON_HIATUS.toLong() -> stringResource(id = R.string.on_hiatus)
else -> stringResource(id = R.string.unknown)
},
)
DotSeparatorText()
if (isStubSource) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
Text(
text = sourceName,
modifier = Modifier.clickableNoIndication { doSearch(sourceName, false) },
)
}
}
}
}
}
@Composable
private fun MangaSummary(
expandedDescription: String,
shrunkDescription: String,
expanded: Boolean,
modifier: Modifier = Modifier,
) {
var expandedHeight by remember { mutableStateOf(0) }
var shrunkHeight by remember { mutableStateOf(0) }
val heightDelta = remember(expandedHeight, shrunkHeight) { expandedHeight - shrunkHeight }
val animProgress by animateFloatAsState(if (expanded) 1f else 0f)
val scrimHeight = with(LocalDensity.current) { remember { 24.sp.roundToPx() } }
SubcomposeLayout(modifier = modifier.clipToBounds()) { constraints ->
val shrunkPlaceable = subcompose("description-s") {
Text(
text = "\n\n", // Shows at least 3 lines
style = MaterialTheme.typography.bodyMedium,
)
}.map { it.measure(constraints) }
shrunkHeight = shrunkPlaceable.maxByOrNull { it.height }?.height ?: 0
val expandedPlaceable = subcompose("description-l") {
Text(
text = expandedDescription,
style = MaterialTheme.typography.bodyMedium,
)
}.map { it.measure(constraints) }
expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0
val actualPlaceable = subcompose("description") {
Text(
text = if (expanded) expandedDescription else shrunkDescription,
maxLines = Int.MAX_VALUE,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.secondaryItemAlpha(),
)
}.map { it.measure(constraints) }
val scrimPlaceable = subcompose("scrim") {
val colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background)
Box(
modifier = Modifier.background(Brush.verticalGradient(colors = colors)),
contentAlignment = Alignment.Center,
) {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
Icon(
painter = rememberAnimatedVectorPainter(image, !expanded),
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())),
)
}
}.map { it.measure(Constraints.fixed(width = constraints.maxWidth, height = scrimHeight)) }
val currentHeight = shrunkHeight + ((heightDelta + scrimHeight) * animProgress).roundToInt()
layout(constraints.maxWidth, currentHeight) {
actualPlaceable.forEach {
it.place(0, 0)
}
val scrimY = currentHeight - scrimHeight
scrimPlaceable.forEach {
it.place(0, scrimY)
}
}
}
}
@Composable
private fun TagsChip(
text: String,
onClick: () -> Unit,
) {
CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) {
SuggestionChip(
onClick = onClick,
label = { Text(text = text, style = MaterialTheme.typography.bodySmall) },
border = null,
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
@Composable
private fun RowScope.MangaActionButton(
title: String,
icon: ImageVector,
color: Color,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
) {
TextButton(
onClick = onClick,
modifier = Modifier.weight(1f),
onLongClick = onLongClick,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.height(4.dp))
Text(
text = title,
color = color,
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -0,0 +1,237 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.FlipToBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.R
@Composable
fun MangaSmallAppBar(
modifier: Modifier = Modifier,
title: String,
titleAlphaProvider: () -> Float,
backgroundAlphaProvider: () -> Float = titleAlphaProvider,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
onBackClicked: () -> Unit,
onShareClicked: (() -> Unit)?,
onDownloadClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
) {
val isActionMode = actionModeCounter > 0
val backgroundAlpha = if (isActionMode) 1f else backgroundAlphaProvider()
val backgroundColor by TopAppBarDefaults.centerAlignedTopAppBarColors().containerColor(1f)
Column(
modifier = modifier.drawBehind {
drawRect(backgroundColor.copy(alpha = backgroundAlpha))
},
) {
SmallTopAppBar(
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
title = {
Text(
text = if (isActionMode) actionModeCounter.toString() else title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(titleAlphaProvider()),
)
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
imageVector = if (isActionMode) Icons.Default.Close else Icons.Default.ArrowBack,
contentDescription = stringResource(id = R.string.abc_action_bar_up_description),
)
}
},
actions = {
if (isActionMode) {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(id = R.string.action_select_all),
)
}
IconButton(onClick = onInvertSelection) {
Icon(
imageVector = Icons.Default.FlipToBack,
contentDescription = stringResource(id = R.string.action_select_inverse),
)
}
} else {
if (onShareClicked != null) {
IconButton(onClick = onShareClicked) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(id = R.string.action_share),
)
}
}
if (onDownloadClicked != null) {
val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { onDownloadExpanded(!downloadExpanded) }) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(id = R.string.manga_download),
)
}
val onDismissRequest = { onDownloadExpanded(false) }
DropdownMenu(
expanded = downloadExpanded,
onDismissRequest = onDismissRequest,
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_1)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_1_CHAPTER)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_5)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_5_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_10)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_10_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_custom)) },
onClick = {
onDownloadClicked(DownloadAction.CUSTOM)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_unread)) },
onClick = {
onDownloadClicked(DownloadAction.UNREAD_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_all)) },
onClick = {
onDownloadClicked(DownloadAction.ALL_CHAPTERS)
onDismissRequest()
},
)
}
}
}
if (onEditCategoryClicked != null && onMigrateClicked != null) {
val (moreExpanded, onMoreExpanded) = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { onMoreExpanded(!moreExpanded) }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(id = R.string.abc_action_menu_overflow_description),
)
}
val onDismissRequest = { onMoreExpanded(false) }
DropdownMenu(
expanded = moreExpanded,
onDismissRequest = onDismissRequest,
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_edit_categories)) },
onClick = {
onEditCategoryClicked()
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_migrate)) },
onClick = {
onMigrateClicked()
onDismissRequest()
},
)
}
}
}
}
},
// Background handled by parent
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
)
if (downloadedOnlyMode) {
Text(
text = stringResource(id = R.string.label_downloaded_only),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.tertiary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}
if (incognitoMode) {
Text(
text = stringResource(id = R.string.pref_incognito_mode),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.primary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onPrimary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}
}
}

View File

@@ -0,0 +1,141 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.manga.DownloadAction
import kotlin.math.roundToInt
@Composable
fun MangaTopAppBar(
modifier: Modifier = Modifier,
title: String,
author: String?,
artist: String?,
description: String?,
tagsProvider: () -> List<String>?,
coverDataProvider: () -> Manga,
sourceName: String,
isStubSource: Boolean,
favorite: Boolean,
status: Long,
trackingCount: Int,
chapterCount: Int?,
chapterFiltered: Boolean,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
fromSource: Boolean,
onBackClicked: () -> Unit,
onCoverClick: () -> Unit,
onTagClicked: (String) -> Unit,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onFilterButtonClicked: () -> Unit,
onShareClicked: (() -> Unit)?,
onDownloadClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
doGlobalSearch: (query: String, global: Boolean) -> Unit,
scrollBehavior: TopAppBarScrollBehavior?,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
onSmallAppBarHeightChanged: (Int) -> Unit,
) {
val scrollPercentageProvider = { scrollBehavior?.scrollFraction?.coerceIn(0f, 1f) ?: 0f }
val inverseScrollPercentageProvider = { 1f - scrollPercentageProvider() }
Layout(
modifier = modifier,
content = {
val (smallHeightPx, onSmallHeightPxChanged) = remember { mutableStateOf(0) }
Column(modifier = Modifier.layoutId("mangaInfo")) {
MangaInfoHeader(
windowWidthSizeClass = WindowWidthSizeClass.Compact,
appBarPadding = with(LocalDensity.current) { smallHeightPx.toDp() },
title = title,
author = author,
artist = artist,
description = description,
tagsProvider = tagsProvider,
sourceName = sourceName,
isStubSource = isStubSource,
coverDataProvider = coverDataProvider,
favorite = favorite,
status = status,
trackingCount = trackingCount,
fromSource = fromSource,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onEditCategory = onEditCategoryClicked,
onCoverClick = onCoverClick,
doSearch = doGlobalSearch,
)
ChapterHeader(
chapterCount = chapterCount,
isChapterFiltered = chapterFiltered,
onFilterButtonClicked = onFilterButtonClicked,
)
}
MangaSmallAppBar(
modifier = Modifier
.layoutId("topBar")
.onSizeChanged {
onSmallHeightPxChanged(it.height)
onSmallAppBarHeightChanged(it.height)
},
title = title,
titleAlphaProvider = { if (actionModeCounter == 0) scrollPercentageProvider() else 1f },
incognitoMode = incognitoMode,
downloadedOnlyMode = downloadedOnlyMode,
onBackClicked = onBackClicked,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
actionModeCounter = actionModeCounter,
onSelectAll = onSelectAll,
onInvertSelection = onInvertSelection,
)
},
) { measurables, constraints ->
val mangaInfoPlaceable = measurables
.first { it.layoutId == "mangaInfo" }
.measure(constraints.copy(maxHeight = Constraints.Infinity))
val topBarPlaceable = measurables
.first { it.layoutId == "topBar" }
.measure(constraints)
val mangaInfoHeight = mangaInfoPlaceable.height
val topBarHeight = topBarPlaceable.height
val mangaInfoSansTopBarHeightPx = mangaInfoHeight - topBarHeight
val layoutHeight = topBarHeight +
(mangaInfoSansTopBarHeightPx * inverseScrollPercentageProvider()).roundToInt()
layout(constraints.maxWidth, layoutHeight) {
val mangaInfoY = (-mangaInfoSansTopBarHeightPx * scrollPercentageProvider()).roundToInt()
mangaInfoPlaceable.place(0, mangaInfoY)
topBarPlaceable.place(0, 0)
// Update offset limit
val offsetLimit = -mangaInfoSansTopBarHeightPx.toFloat()
if (scrollBehavior?.state?.offsetLimit != offsetLimit) {
scrollBehavior?.state?.offsetLimit = offsetLimit
}
}
}
}