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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 3701 additions and 2955 deletions

View file

@ -150,13 +150,16 @@ dependencies {
implementation(compose.activity) implementation(compose.activity)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.material3.core) implementation(compose.material3.core)
implementation(compose.material3.windowsizeclass)
implementation(compose.material3.adapter) implementation(compose.material3.adapter)
implementation(compose.material.icons) implementation(compose.material.icons)
implementation(compose.animation) implementation(compose.animation)
implementation(compose.animation.graphics)
implementation(compose.ui.tooling) implementation(compose.ui.tooling)
implementation(compose.ui.util) implementation(compose.ui.util)
implementation(compose.accompanist.webview) implementation(compose.accompanist.webview)
implementation(compose.accompanist.swiperefresh) implementation(compose.accompanist.swiperefresh)
implementation(compose.accompanist.flowlayout)
implementation(androidx.paging.runtime) implementation(androidx.paging.runtime)
implementation(androidx.paging.compose) implementation(androidx.paging.compose)
@ -299,7 +302,9 @@ tasks {
"-opt-in=coil.annotation.ExperimentalCoilApi", "-opt-in=coil.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi" "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
) )
} }

View file

@ -22,6 +22,10 @@ class MangaRepositoryImpl(
return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) } return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) }
} }
override suspend fun getMangaByIdAsFlow(id: Long): Flow<Manga> {
return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) }
}
override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> { override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> {
return handler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) } return handler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) }
} }

View file

@ -33,6 +33,7 @@ import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId
import eu.kanade.domain.manga.interactor.GetMangaById import eu.kanade.domain.manga.interactor.GetMangaById
import eu.kanade.domain.manga.interactor.GetMangaWithChapters import eu.kanade.domain.manga.interactor.GetMangaWithChapters
import eu.kanade.domain.manga.interactor.ResetViewerFlags import eu.kanade.domain.manga.interactor.ResetViewerFlags
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.repository.MangaRepository import eu.kanade.domain.manga.repository.MangaRepository
import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetEnabledSources
@ -71,6 +72,7 @@ class DomainModule : InjektModule {
addFactory { GetMangaById(get()) } addFactory { GetMangaById(get()) }
addFactory { GetNextChapter(get()) } addFactory { GetNextChapter(get()) }
addFactory { ResetViewerFlags(get()) } addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) }
addFactory { UpdateManga(get()) } addFactory { UpdateManga(get()) }
addFactory { MoveMangaToCategories(get()) } addFactory { MoveMangaToCategories(get()) }

View file

@ -20,4 +20,8 @@ class GetMangaWithChapters(
Pair(manga, chapters) Pair(manga, chapters)
} }
} }
suspend fun awaitManga(id: Long): Manga {
return mangaRepository.getMangaById(id)
}
} }

View file

@ -0,0 +1,95 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.repository.MangaRepository
class SetMangaChapterFlags(private val mangaRepository: MangaRepository) {
suspend fun awaitSetDownloadedFilter(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_DOWNLOADED_MASK),
),
)
}
suspend fun awaitSetUnreadFilter(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_UNREAD_MASK),
),
)
}
suspend fun awaitSetBookmarkFilter(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_BOOKMARKED_MASK),
),
)
}
suspend fun awaitSetDisplayMode(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_DISPLAY_MASK),
),
)
}
suspend fun awaitSetSortingModeOrFlipOrder(manga: Manga, flag: Long): Boolean {
val newFlags = manga.chapterFlags.let {
if (manga.sorting == flag) {
// Just flip the order
val orderFlag = if (manga.sortDescending()) {
Manga.CHAPTER_SORT_ASC
} else {
Manga.CHAPTER_SORT_DESC
}
it.setFlag(orderFlag, Manga.CHAPTER_SORT_DIR_MASK)
} else {
// Set new flag with ascending order
it
.setFlag(flag, Manga.CHAPTER_SORTING_MASK)
.setFlag(Manga.CHAPTER_SORT_ASC, Manga.CHAPTER_SORT_DIR_MASK)
}
}
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = newFlags,
),
)
}
suspend fun awaitSetAllFlags(
mangaId: Long,
unreadFilter: Long,
downloadedFilter: Long,
bookmarkedFilter: Long,
sortingMode: Long,
sortingDirection: Long,
displayMode: Long,
): Boolean {
return mangaRepository.update(
MangaUpdate(
id = mangaId,
chapterFlags = 0L.setFlag(unreadFilter, Manga.CHAPTER_UNREAD_MASK)
.setFlag(downloadedFilter, Manga.CHAPTER_DOWNLOADED_MASK)
.setFlag(bookmarkedFilter, Manga.CHAPTER_BOOKMARKED_MASK)
.setFlag(sortingMode, Manga.CHAPTER_SORTING_MASK)
.setFlag(sortingDirection, Manga.CHAPTER_SORT_DIR_MASK)
.setFlag(displayMode, Manga.CHAPTER_DISPLAY_MASK),
),
)
}
private fun Long.setFlag(flag: Long, mask: Long): Long {
return this and mask.inv() or (flag and mask)
}
}

View file

@ -8,6 +8,8 @@ import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.manga.repository.MangaRepository import eu.kanade.domain.manga.repository.MangaRepository
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import tachiyomi.source.model.MangaInfo import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date import java.util.Date
class UpdateManga( class UpdateManga(
@ -22,7 +24,7 @@ class UpdateManga(
localManga: Manga, localManga: Manga,
remoteManga: MangaInfo, remoteManga: MangaInfo,
manualFetch: Boolean, manualFetch: Boolean,
coverCache: CoverCache, coverCache: CoverCache = Injekt.get(),
): Boolean { ): Boolean {
// if the manga isn't a favorite, set its title from source and update in db // if the manga isn't a favorite, set its title from source and update in db
val title = if (!localManga.favorite) remoteManga.title else null val title = if (!localManga.favorite) remoteManga.title else null
@ -66,4 +68,14 @@ class UpdateManga(
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean { suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
return mangaRepository.update(MangaUpdate(id = mangaId, coverLastModified = Date().time)) return mangaRepository.update(MangaUpdate(id = mangaId, coverLastModified = Date().time))
} }
suspend fun awaitUpdateFavorite(mangaId: Long, favorite: Boolean): Boolean {
val dateAdded = when (favorite) {
true -> Date().time
false -> 0
}
return mangaRepository.update(
MangaUpdate(id = mangaId, favorite = favorite, dateAdded = dateAdded),
)
}
} }

View file

@ -10,6 +10,8 @@ interface MangaRepository {
suspend fun subscribeMangaById(id: Long): Flow<Manga> suspend fun subscribeMangaById(id: Long): Flow<Manga>
suspend fun getMangaByIdAsFlow(id: Long): Flow<Manga>
fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>>
suspend fun getDuplicateLibraryManga(title: String, sourceId: Long): Manga? suspend fun getDuplicateLibraryManga(title: String, sourceId: Long): Manga?

View file

@ -0,0 +1,101 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
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.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ButtonElevation
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun TextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = null,
shape: Shape = Shapes.Full,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.textButtonColors(),
contentPadding: PaddingValues = ButtonDefaults.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,
)
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
shape: Shape = Shapes.Full,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.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 = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight,
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
}
}
}

View file

@ -0,0 +1,111 @@
package eu.kanade.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.FloatingActionButtonElevation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun ExtendedFloatingActionButton(
text: @Composable () -> Unit,
icon: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
expanded: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = MaterialTheme.shapes.large,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
) {
val minWidth by animateDpAsState(if (expanded) ExtendedFabMinimumWidth else FabContainerWidth)
FloatingActionButton(
modifier = modifier.sizeIn(minWidth = minWidth),
onClick = onClick,
interactionSource = interactionSource,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
elevation = elevation,
) {
val startPadding by animateDpAsState(if (expanded) ExtendedFabIconSize / 2 else 0.dp)
val endPadding by animateDpAsState(if (expanded) ExtendedFabTextPadding else 0.dp)
Row(
modifier = Modifier.padding(start = startPadding, end = endPadding),
verticalAlignment = Alignment.CenterVertically,
) {
icon()
AnimatedVisibility(
visible = expanded,
enter = ExtendedFabExpandAnimation,
exit = ExtendedFabCollapseAnimation,
) {
Row {
Spacer(Modifier.width(ExtendedFabIconPadding))
text()
}
}
}
}
}
private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
private val ExtendedFabMinimumWidth = 80.dp
private val ExtendedFabIconSize = 24.0.dp
private val ExtendedFabIconPadding = 12.dp
private val ExtendedFabTextPadding = 20.dp
private val ExtendedFabCollapseAnimation = fadeOut(
animationSpec = tween(
durationMillis = 100,
easing = EasingLinearCubicBezier,
),
) + shrinkHorizontally(
animationSpec = tween(
durationMillis = 500,
easing = EasingEmphasizedCubicBezier,
),
shrinkTowards = Alignment.Start,
)
private val ExtendedFabExpandAnimation = fadeIn(
animationSpec = tween(
durationMillis = 200,
delayMillis = 100,
easing = EasingLinearCubicBezier,
),
) + expandHorizontally(
animationSpec = tween(
durationMillis = 500,
easing = EasingEmphasizedCubicBezier,
),
expandFrom = Alignment.Start,
)
private val FabContainerWidth = 56.0.dp

View file

@ -0,0 +1,108 @@
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.Shapes
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.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
@Composable
@NonRestartableComposable
fun Surface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: Shape = Shapes.None,
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)
}

View file

@ -0,0 +1,803 @@
package eu.kanade.presentation.manga
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberTopAppBarScrollState
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.manga.model.Manga.Companion.CHAPTER_DISPLAY_NUMBER
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.components.VerticalFastScroller
import eu.kanade.presentation.manga.components.ChapterHeader
import eu.kanade.presentation.manga.components.MangaBottomActionMenu
import eu.kanade.presentation.manga.components.MangaChapterListItem
import eu.kanade.presentation.manga.components.MangaInfoHeader
import eu.kanade.presentation.manga.components.MangaSmallAppBar
import eu.kanade.presentation.manga.components.MangaTopAppBar
import eu.kanade.presentation.util.ExitUntilCollapsedScrollBehavior
import eu.kanade.presentation.util.isScrolledToEnd
import eu.kanade.presentation.util.isScrollingUp
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.ui.manga.ChapterItem
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.lang.toRelativeString
import kotlinx.coroutines.runBlocking
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Date
private val chapterDecimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
@Composable
fun MangaScreen(
state: MangaScreenState.Success,
snackbarHostState: SnackbarHostState,
windowWidthSizeClass: WindowWidthSizeClass,
onBackClicked: () -> Unit,
onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
onCoverClicked: () -> Unit,
// For top action menu
onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onMultiDeleteClicked: (List<Chapter>) -> Unit,
) {
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
MangaScreenSmallImpl(
state = state,
snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked,
onChapterClicked = onChapterClicked,
onDownloadChapter = onDownloadChapter,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked,
onRefresh = onRefresh,
onContinueReading = onContinueReading,
onSearch = onSearch,
onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked,
onDownloadActionClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
)
} else {
MangaScreenLargeImpl(
state = state,
windowWidthSizeClass = windowWidthSizeClass,
snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked,
onChapterClicked = onChapterClicked,
onDownloadChapter = onDownloadChapter,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked,
onRefresh = onRefresh,
onContinueReading = onContinueReading,
onSearch = onSearch,
onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked,
onDownloadActionClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
)
}
}
@Composable
private fun MangaScreenSmallImpl(
state: MangaScreenState.Success,
snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit,
onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
onCoverClicked: () -> Unit,
// For top action menu
onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onMultiDeleteClicked: (List<Chapter>) -> Unit,
) {
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
val haptic = LocalHapticFeedback.current
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val scrollBehavior = ExitUntilCollapsedScrollBehavior(rememberTopAppBarScrollState(), decayAnimationSpec)
val chapterListState = rememberLazyListState()
SideEffect {
if (chapterListState.firstVisibleItemIndex > 0 || chapterListState.firstVisibleItemScrollOffset > 0) {
// Should go here after a configuration change
// Safe to say that the app bar is fully scrolled
scrollBehavior.state.offset = scrollBehavior.state.offsetLimit
}
}
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(1) }
SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
onRefresh = onRefresh,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(LocalDensity.current) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
val chapters = remember(state) { state.processedChapters.toList() }
val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(insetPadding),
topBar = {
MangaTopAppBar(
modifier = Modifier
.scrollable(
state = rememberScrollableState {
var consumed = runBlocking { chapterListState.scrollBy(-it) } * -1
if (consumed == 0f) {
// Pass scroll to app bar if we're on the top of the list
val newOffset =
(scrollBehavior.state.offset + it).coerceIn(scrollBehavior.state.offsetLimit, 0f)
consumed = newOffset - scrollBehavior.state.offset
scrollBehavior.state.offset = newOffset
}
consumed
},
orientation = Orientation.Vertical,
interactionSource = chapterListState.interactionSource as MutableInteractionSource,
),
title = state.manga.title,
author = state.manga.author,
artist = state.manga.artist,
description = state.manga.description,
tagsProvider = { state.manga.genre },
coverDataProvider = { state.manga },
sourceName = remember { state.source.getNameForMangaInfo() },
isStubSource = remember { state.source is SourceManager.StubSource },
favorite = state.manga.favorite,
status = state.manga.status,
trackingCount = state.trackingCount,
chapterCount = chapters.size,
chapterFiltered = state.manga.chaptersFiltered(),
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
fromSource = state.isFromSource,
onBackClicked = internalOnBackPressed,
onCoverClick = onCoverClicked,
onTagClicked = onTagClicked,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onFilterButtonClicked = onFilterButtonClicked,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
doGlobalSearch = onSearch,
scrollBehavior = scrollBehavior,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(chapters)
},
onInvertSelection = {
val toSelect = chapters - selected
selected.clear()
selected.addAll(toSelect)
},
onSmallAppBarHeightChanged = onTopBarHeightChanged,
)
},
bottomBar = {
MangaBottomActionMenu(
visible = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.bookmark } },
onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.all { it.chapter.bookmark } },
onMarkAsReadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.read } },
onMarkAsUnreadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.any { it.chapter.read } },
onMarkPreviousAsReadClicked = {
onMarkPreviousAsReadClicked(selected[0].chapter)
selected.clear()
}.takeIf { selected.size == 1 },
onDownloadClicked = {
onDownloadChapter!!(selected, ChapterDownloadAction.START)
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
},
onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.chapter })
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
},
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
AnimatedVisibility(
visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (chapters.any { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
}
Text(text = stringResource(id = id))
},
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
)
}
},
) { contentPadding ->
val withNavBarContentPadding = contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
VerticalFastScroller(
listState = chapterListState,
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(LocalLayoutDirection.current),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = chapterListState,
contentPadding = withNavBarContentPadding,
) {
items(items = chapters) { chapterItem ->
val (chapter, downloadState, downloadProgress) = chapterItem
val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
} else {
chapter.name
}
}
val date = remember(chapter.dateUpload) {
chapter.dateUpload
.takeIf { it > 0 }
?.let { Date(it).toRelativeString(context, state.dateRelativeTime, state.dateFormat) }
}
val lastPageRead = remember(chapter.lastPageRead) {
chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
}
val scanlator = remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
MangaChapterListItem(
title = chapterTitle,
date = date,
readProgress = lastPageRead?.let { stringResource(id = R.string.chapter_progress, it + 1) },
scanlator = scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
selected = selected.contains(chapterItem),
downloadState = downloadState,
downloadProgress = downloadProgress,
onLongClick = {
val dispatched = onChapterItemLongClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
)
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = {
onChapterItemClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
onChapterClicked = onChapterClicked,
)
},
onDownloadClick = if (onDownloadChapter != null) {
{ onDownloadChapter(listOf(chapterItem), it) }
} else null,
)
}
}
}
}
}
}
@Composable
fun MangaScreenLargeImpl(
state: MangaScreenState.Success,
windowWidthSizeClass: WindowWidthSizeClass,
snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit,
onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
onCoverClicked: () -> Unit,
// For top action menu
onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onMultiDeleteClicked: (List<Chapter>) -> Unit,
) {
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current
val haptic = LocalHapticFeedback.current
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(0) }
SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
onRefresh = onRefresh,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
clipIndicatorToPadding = true,
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
val chapterListState = rememberLazyListState()
val chapters = remember(state) { state.processedChapters.toList() }
val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
Scaffold(
modifier = Modifier.padding(insetPadding),
topBar = {
MangaSmallAppBar(
modifier = Modifier.onSizeChanged { onTopBarHeightChanged(it.height) },
title = state.manga.title,
titleAlphaProvider = { if (selected.isEmpty()) 0f else 1f },
backgroundAlphaProvider = { 1f },
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
onBackClicked = internalOnBackPressed,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(chapters)
},
onInvertSelection = {
val toSelect = chapters - selected
selected.clear()
selected.addAll(toSelect)
},
)
},
bottomBar = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
MangaBottomActionMenu(
visible = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth(0.5f),
onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.bookmark } },
onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.all { it.chapter.bookmark } },
onMarkAsReadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.read } },
onMarkAsUnreadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.any { it.chapter.read } },
onMarkPreviousAsReadClicked = {
onMarkPreviousAsReadClicked(selected[0].chapter)
selected.clear()
}.takeIf { selected.size == 1 },
onDownloadClicked = {
onDownloadChapter!!(selected, ChapterDownloadAction.START)
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
},
onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.chapter })
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
},
)
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
AnimatedVisibility(
visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (chapters.any { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
}
Text(text = stringResource(id = id))
},
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
)
}
},
) { contentPadding ->
Row {
val withNavBarContentPadding = contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
MangaInfoHeader(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(bottom = withNavBarContentPadding.calculateBottomPadding()),
windowWidthSizeClass = WindowWidthSizeClass.Expanded,
appBarPadding = contentPadding.calculateTopPadding(),
title = state.manga.title,
author = state.manga.author,
artist = state.manga.artist,
description = state.manga.description,
tagsProvider = { state.manga.genre },
sourceName = remember { state.source.getNameForMangaInfo() },
isStubSource = remember { state.source is SourceManager.StubSource },
coverDataProvider = { state.manga },
favorite = state.manga.favorite,
status = state.manga.status,
trackingCount = state.trackingCount,
fromSource = state.isFromSource,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onEditCategory = onEditCategoryClicked,
onCoverClick = onCoverClicked,
doSearch = onSearch,
)
val chaptersWeight = if (windowWidthSizeClass == WindowWidthSizeClass.Medium) 1f else 2f
VerticalFastScroller(
listState = chapterListState,
modifier = Modifier.weight(chaptersWeight),
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(layoutDirection),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = chapterListState,
contentPadding = withNavBarContentPadding,
) {
item(contentType = "header") {
ChapterHeader(
chapterCount = chapters.size,
isChapterFiltered = state.manga.chaptersFiltered(),
onFilterButtonClicked = onFilterButtonClicked,
)
}
items(items = chapters) { chapterItem ->
val (chapter, downloadState, downloadProgress) = chapterItem
val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
} else {
chapter.name
}
}
val date = remember(chapter.dateUpload) {
chapter.dateUpload
.takeIf { it > 0 }
?.let {
Date(it).toRelativeString(
context,
state.dateRelativeTime,
state.dateFormat,
)
}
}
val lastPageRead = remember(chapter.lastPageRead) {
chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
}
val scanlator =
remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
MangaChapterListItem(
title = chapterTitle,
date = date,
readProgress = lastPageRead?.let {
stringResource(
id = R.string.chapter_progress,
it + 1,
)
},
scanlator = scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
selected = selected.contains(chapterItem),
downloadState = downloadState,
downloadProgress = downloadProgress,
onLongClick = {
val dispatched = onChapterItemLongClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
)
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = {
onChapterItemClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
onChapterClicked = onChapterClicked,
)
},
onDownloadClick = if (onDownloadChapter != null) {
{ onDownloadChapter(listOf(chapterItem), it) }
} else null,
)
}
}
}
}
}
}
}
private fun onChapterItemLongClick(
chapterItem: ChapterItem,
selected: MutableList<ChapterItem>,
chapters: List<ChapterItem>,
selectedPositions: Array<Int>,
): Boolean {
if (!selected.contains(chapterItem)) {
val selectedIndex = chapters.indexOf(chapterItem)
if (selected.isEmpty()) {
selected.add(chapterItem)
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
return true
}
// Try to select the items in-between when possible
val range: IntRange
if (selectedIndex < selectedPositions[0]) {
range = selectedIndex until selectedPositions[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = selectedIndex..selectedIndex
}
range.forEach {
val toAdd = chapters[it]
if (!selected.contains(toAdd)) {
selected.add(toAdd)
}
}
return true
}
return false
}
fun onChapterItemClick(
chapterItem: ChapterItem,
selected: MutableList<ChapterItem>,
chapters: List<ChapterItem>,
selectedPositions: Array<Int>,
onChapterClicked: (Chapter) -> Unit,
) {
val selectedIndex = chapters.indexOf(chapterItem)
when {
selected.contains(chapterItem) -> {
val removedIndex = chapters.indexOf(chapterItem)
selected.remove(chapterItem)
if (removedIndex == selectedPositions[0]) {
selectedPositions[0] = chapters.indexOfFirst { selected.contains(it) }
} else if (removedIndex == selectedPositions[1]) {
selectedPositions[1] = chapters.indexOfLast { selected.contains(it) }
}
}
selected.isNotEmpty() -> {
if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
selectedPositions[1] = selectedIndex
}
selected.add(chapterItem)
}
else -> onChapterClicked(chapterItem.chapter)
}
}

View file

@ -1,8 +1,12 @@
package eu.kanade.presentation.manga package eu.kanade.presentation.manga
enum class EditCoverAction { enum class DownloadAction {
EDIT, NEXT_1_CHAPTER,
DELETE, NEXT_5_CHAPTERS,
NEXT_10_CHAPTERS,
CUSTOM,
UNREAD_CHAPTERS,
ALL_CHAPTERS
} }
enum class ChapterDownloadAction { enum class ChapterDownloadAction {
@ -11,3 +15,8 @@ enum class ChapterDownloadAction {
CANCEL, CANCEL,
DELETE, DELETE,
} }
enum class EditCoverAction {
EDIT,
DELETE,
}

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
}
}
}
}

View file

@ -1,5 +1,29 @@
package eu.kanade.presentation.util package eu.kanade.presentation.util
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
@Composable
fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember { mutableStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember { mutableStateOf(firstVisibleItemScrollOffset) }
return remember {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}

View file

@ -0,0 +1,158 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.kanade.presentation.util
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarScrollState
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import kotlin.math.abs
/**
* A [TopAppBarScrollBehavior] that adjusts its properties to affect the colors and height of a top
* app bar.
*
* A top app bar that is set up with this [TopAppBarScrollBehavior] will immediately collapse when
* the nested content is pulled up, and will expand back the collapsed area when the content is
* pulled all the way down.
*
* @param decayAnimationSpec a [DecayAnimationSpec] that will be used by the top app bar motion
* when the user flings the content. Preferably, this should match the animation spec used by the
* scrollable content. See also [androidx.compose.animation.rememberSplineBasedDecay] for a
* default [DecayAnimationSpec] that can be used with this behavior.
* @param canScroll a callback used to determine whether scroll events are to be
* handled by this [ExitUntilCollapsedScrollBehavior]
*/
class ExitUntilCollapsedScrollBehavior(
override val state: TopAppBarScrollState,
val decayAnimationSpec: DecayAnimationSpec<Float>,
val canScroll: () -> Boolean = { true },
) : TopAppBarScrollBehavior {
override val scrollFraction: Float
get() = if (state.offsetLimit != 0f) state.offset / state.offsetLimit else 0f
override var nestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Don't intercept if scrolling down.
if (!canScroll() || available.y > 0f) return Offset.Zero
val newOffset = (state.offset + available.y)
val coerced =
newOffset.coerceIn(minimumValue = state.offsetLimit, maximumValue = 0f)
return if (newOffset == coerced) {
// Nothing coerced, meaning we're in the middle of top app bar collapse or
// expand.
state.offset = coerced
// Consume only the scroll on the Y axis.
available.copy(x = 0f)
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (!canScroll()) return Offset.Zero
state.contentOffset += consumed.y
if (available.y < 0f || consumed.y < 0f) {
// When scrolling up, just update the state's offset.
val oldOffset = state.offset
state.offset = (state.offset + consumed.y).coerceIn(
minimumValue = state.offsetLimit,
maximumValue = 0f,
)
return Offset(0f, state.offset - oldOffset)
}
if (consumed.y == 0f && available.y > 0) {
// Reset the total offset to zero when scrolling all the way down. This will
// eliminate some float precision inaccuracies.
state.contentOffset = 0f
}
if (available.y > 0f) {
// Adjust the offset in case the consumed delta Y is less than what was recorded
// as available delta Y in the pre-scroll.
val oldOffset = state.offset
state.offset = (state.offset + available.y).coerceIn(
minimumValue = state.offsetLimit,
maximumValue = 0f,
)
return Offset(0f, state.offset - oldOffset)
}
return Offset.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val result = super.onPostFling(consumed, available)
if ((available.y < 0f && state.contentOffset == 0f) ||
(available.y > 0f && state.offset < 0f)
) {
return result +
onTopBarFling(
scrollBehavior = this@ExitUntilCollapsedScrollBehavior,
initialVelocity = available.y,
decayAnimationSpec = decayAnimationSpec,
)
}
return result
}
}
}
/**
* Tachiyomi: Remove snap behavior
*/
private suspend fun onTopBarFling(
scrollBehavior: TopAppBarScrollBehavior,
initialVelocity: Float,
decayAnimationSpec: DecayAnimationSpec<Float>,
): Velocity {
if (abs(initialVelocity) > 1f) {
var remainingVelocity = initialVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = initialVelocity,
)
.animateDecay(decayAnimationSpec) {
val delta = value - lastValue
val initialOffset = scrollBehavior.state.offset
scrollBehavior.state.offset =
(initialOffset + delta).coerceIn(
minimumValue = scrollBehavior.state.offsetLimit,
maximumValue = 0f,
)
val consumed = abs(initialOffset - scrollBehavior.state.offset)
lastValue = value
remainingVelocity = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
return Velocity(0f, remainingVelocity)
}
return Velocity.Zero
}

View file

@ -0,0 +1,24 @@
package eu.kanade.presentation.util
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@ReadOnlyComposable
fun calculateWindowWidthSizeClass(): WindowWidthSizeClass {
val configuration = LocalConfiguration.current
return fromWidth(configuration.smallestScreenWidthDp.dp)
}
private fun fromWidth(width: Dp): WindowWidthSizeClass {
require(width >= 0.dp) { "Width must not be negative" }
return when {
width < 720.dp -> WindowWidthSizeClass.Compact // Was 600
width < 840.dp -> WindowWidthSizeClass.Medium
else -> WindowWidthSizeClass.Expanded
}
}

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import java.io.Serializable import java.io.Serializable
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
interface Chapter : SChapter, Serializable { interface Chapter : SChapter, Serializable {
@ -29,3 +30,21 @@ interface Chapter : SChapter, Serializable {
} }
} }
} }
fun Chapter.toDomainChapter(): DomainChapter? {
if (id == null || manga_id == null) return null
return DomainChapter(
id = id!!,
mangaId = manga_id!!,
read = read,
bookmark = bookmark,
lastPageRead = last_page_read.toLong(),
dateFetch = date_fetch,
sourceOrder = source_order.toLong(),
url = url,
name = name,
dateUpload = date_upload,
chapterNumber = chapter_number,
scanlator = scanlator,
)
}

View file

@ -12,4 +12,24 @@ class LibraryManga : MangaImpl() {
get() = readCount > 0 get() = readCount > 0
var category: Int = 0 var category: Int = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LibraryManga) return false
if (!super.equals(other)) return false
if (unreadCount != other.unreadCount) return false
if (readCount != other.readCount) return false
if (category != other.category) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + unreadCount
result = 31 * result + readCount
result = 31 * result + category
return result
}
} }

View file

@ -121,3 +121,5 @@ fun Source.getNameForMangaInfo(): String {
else -> toString() else -> toString()
} }
} }
fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource

View file

@ -57,6 +57,33 @@ open class Page(
statusCallback = f statusCallback = f
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Page) return false
if (index != other.index) return false
if (url != other.url) return false
if (imageUrl != other.imageUrl) return false
if (number != other.number) return false
if (status != other.status) return false
if (progress != other.progress) return false
if (statusSubject != other.statusSubject) return false
if (statusCallback != other.statusCallback) return false
return true
}
override fun hashCode(): Int {
var result = index
result = 31 * result + url.hashCode()
result = 31 * result + (imageUrl?.hashCode() ?: 0)
result = 31 * result + status
result = 31 * result + progress
result = 31 * result + (statusSubject?.hashCode() ?: 0)
result = 31 * result + (statusCallback?.hashCode() ?: 0)
return result
}
companion object { companion object {
const val QUEUE = 0 const val QUEUE = 0
const val LOAD_PAGE = 1 const val LOAD_PAGE = 1

View file

@ -83,7 +83,7 @@ class SearchController(
binding.progress.isVisible = isReplacingManga binding.progress.isVisible = isReplacingManga
if (!isReplacingManga) { if (!isReplacingManga) {
router.popController(this) router.popController(this)
if (newManga != null) { if (newManga?.id != null) {
val newMangaController = RouterTransaction.with(MangaController(newManga.id!!)) val newMangaController = RouterTransaction.with(MangaController(newManga.id!!))
if (router.backstack.lastOrNull()?.controller is MangaController) { if (router.backstack.lastOrNull()?.controller is MangaController) {
// Replace old MangaController // Replace old MangaController

View file

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ChangeMangaCoverDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T : ChangeMangaCoverDialog.Listener {
private lateinit var manga: Manga
constructor(target: T, manga: Manga) : this() {
targetController = target
this.manga = manga
}
@Suppress("DEPRECATION")
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_edit_cover)
.setPositiveButton(R.string.action_edit) { _, _ ->
(targetController as? Listener)?.openMangaCoverPicker(manga)
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(R.string.action_delete) { _, _ ->
(targetController as? Listener)?.deleteMangaCover(manga)
}
.create()
}
interface Listener {
fun deleteMangaCover(manga: Manga)
fun openMangaCoverPicker(manga: Manga)
}
}

View file

@ -420,7 +420,7 @@ class MainActivity : BaseActivity() {
SHORTCUT_MANGA -> { SHORTCUT_MANGA -> {
val extras = intent.extras ?: return false val extras = intent.extras ?: return false
val fgController = router.backstack.lastOrNull()?.controller as? MangaController val fgController = router.backstack.lastOrNull()?.controller as? MangaController
if (fgController?.manga?.id != extras.getLong(MangaController.MANGA_EXTRA)) { if (fgController?.mangaId != extras.getLong(MangaController.MANGA_EXTRA)) {
router.popToRoot() router.popToRoot()
setSelectedNavItem(R.id.nav_library) setSelectedNavItem(R.id.nav_library)
router.pushController(RouterTransaction.with(MangaController(extras))) router.pushController(RouterTransaction.with(MangaController(extras)))
@ -601,6 +601,9 @@ class MainActivity : BaseActivity() {
} }
val isFullComposeController = internalTo is FullComposeController<*> val isFullComposeController = internalTo is FullComposeController<*>
binding.appbar.isVisible = !isFullComposeController
binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
if (!isTablet()) { if (!isTablet()) {
// Save lift state // Save lift state
if (isPush) { if (isPush) {
@ -623,17 +626,6 @@ class MainActivity : BaseActivity() {
} }
binding.root.isLiftAppBarOnScroll = internalTo !is NoAppBarElevationController binding.root.isLiftAppBarOnScroll = internalTo !is NoAppBarElevationController
binding.appbar.isVisible = !isFullComposeController
binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
// TODO: Remove when MangaController is full compose
if (!isFullComposeController) {
binding.appbar.isTransparentWhenNotLifted = internalTo is MangaController
binding.controllerContainer.overlapHeader = internalTo is MangaController
}
} else {
binding.appbar.isVisible = !isFullComposeController
} }
} }

View file

@ -1,127 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.text.SpannableStringBuilder
import android.view.View
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.ChaptersItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
import eu.kanade.tachiyomi.util.lang.toRelativeString
import java.util.Date
class ChapterHolder(
view: View,
private val adapter: ChaptersAdapter,
) : BaseChapterHolder(view, adapter) {
private val binding = ChaptersItemBinding.bind(view)
init {
binding.download.listener = downloadActionListener
}
fun bind(item: ChapterItem, manga: Manga) {
val chapter = item.chapter
binding.chapterTitle.text = when (manga.displayMode) {
Manga.CHAPTER_DISPLAY_NUMBER -> {
val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
itemView.context.getString(R.string.display_mode_chapter, number)
}
else -> chapter.name
// TODO: show cleaned name consistently around the app
// else -> cleanChapterName(chapter, manga)
}
// Set correct text color
val chapterTitleColor = when {
chapter.read -> adapter.readColor
chapter.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColor
}
binding.chapterTitle.setTextColor(chapterTitleColor)
val chapterDescriptionColor = when {
chapter.read -> adapter.readColor
chapter.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColorSecondary
}
binding.chapterDescription.setTextColor(chapterDescriptionColor)
binding.bookmarkIcon.isVisible = chapter.bookmark
val descriptions = mutableListOf<CharSequence>()
if (chapter.date_upload > 0) {
descriptions.add(Date(chapter.date_upload).toRelativeString(itemView.context, adapter.relativeTime, adapter.dateFormat))
}
if (!chapter.read && chapter.last_page_read > 0) {
val lastPageRead = buildSpannedString {
color(adapter.readColor) {
append(itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1))
}
}
descriptions.add(lastPageRead)
}
if (!chapter.scanlator.isNullOrBlank()) {
descriptions.add(chapter.scanlator!!)
}
if (descriptions.isNotEmpty()) {
binding.chapterDescription.text = descriptions.joinTo(SpannableStringBuilder(), "")
} else {
binding.chapterDescription.text = ""
}
binding.download.isVisible = item.manga.source != LocalSource.ID
binding.download.setState(item.status, item.progress)
}
private fun cleanChapterName(chapter: Chapter, manga: Manga): String {
return chapter.name
.trim()
.removePrefix(manga.title)
.trim(*CHAPTER_TRIM_CHARS)
}
}
private val CHAPTER_TRIM_CHARS = arrayOf(
// Whitespace
' ',
'\u0009',
'\u000A',
'\u000B',
'\u000C',
'\u000D',
'\u0020',
'\u0085',
'\u00A0',
'\u1680',
'\u2000',
'\u2001',
'\u2002',
'\u2003',
'\u2004',
'\u2005',
'\u2006',
'\u2007',
'\u2008',
'\u2009',
'\u200A',
'\u2028',
'\u2029',
'\u202F',
'\u205F',
'\u3000',
// Separators
'-',
'_',
',',
':',
).toCharArray()

View file

@ -1,33 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem
class ChapterItem(chapter: Chapter, val manga: Manga) :
BaseChapterItem<ChapterHolder, AbstractHeaderItem<FlexibleViewHolder>>(chapter) {
override fun getLayoutRes(): Int {
return R.layout.chapters_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
return ChapterHolder(view, adapter as ChaptersAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ChapterHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this, manga)
}
}

View file

@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.util.system.getResourceColor
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
class ChaptersAdapter(
controller: MangaController,
context: Context,
) : BaseChaptersAdapter<ChapterItem>(controller) {
private val preferences: PreferencesHelper by injectLazy()
var items: List<ChapterItem> = emptyList()
val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
val unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
val bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val decimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
val relativeTime: Int = preferences.relativeTime().get()
val dateFormat: DateFormat = preferences.dateFormat()
override fun updateDataSet(items: List<ChapterItem>?) {
this.items = items ?: emptyList()
super.updateDataSet(items)
}
fun indexOf(item: ChapterItem): Int {
return items.indexOf(item)
}
}

View file

@ -7,10 +7,11 @@ import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bluelinelabs.conductor.Router import com.bluelinelabs.conductor.Router
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.manga.model.toTriStateGroupState import eu.kanade.domain.manga.model.toTriStateGroupState
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.ui.manga.MangaPresenter import eu.kanade.tachiyomi.ui.manga.MangaPresenter
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.view.popupMenu import eu.kanade.tachiyomi.util.view.popupMenu
import eu.kanade.tachiyomi.widget.ExtendedNavigationView import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
@ -18,6 +19,9 @@ import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
class ChaptersSettingsSheet( class ChaptersSettingsSheet(
private val router: Router, private val router: Router,
@ -28,7 +32,7 @@ class ChaptersSettingsSheet(
private var manga: Manga? = null private var manga: Manga? = null
val filters = Filter(context) private val filters = Filter(context)
private val sort = Sort(context) private val sort = Sort(context)
private val display = Display(context) private val display = Display(context)
@ -42,8 +46,14 @@ class ChaptersSettingsSheet(
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
scope = MainScope() scope = MainScope()
// TODO: Listen to changes scope.launch {
updateManga() presenter.state
.filterIsInstance<MangaScreenState.Success>()
.collectLatest {
manga = it.manga
getTabViews().forEach { settings -> (settings as Settings).updateView() }
}
}
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
@ -63,17 +73,13 @@ class ChaptersSettingsSheet(
R.string.action_display, R.string.action_display,
) )
private fun updateManga() {
manga = presenter.manga.toDomainManga()
}
private fun showPopupMenu(view: View) { private fun showPopupMenu(view: View) {
view.popupMenu( view.popupMenu(
menuRes = R.menu.default_chapter_filter, menuRes = R.menu.default_chapter_filter,
onMenuItemClick = { onMenuItemClick = {
when (itemId) { when (itemId) {
R.id.set_as_default -> { R.id.set_as_default -> {
SetChapterSettingsDialog(presenter.manga).showDialog(router) SetChapterSettingsDialog(presenter.manga!!.toDbManga()).showDialog(router)
} }
} }
}, },
@ -144,10 +150,6 @@ class ChaptersSettingsSheet(
bookmarked -> presenter.setBookmarkedFilter(newState) bookmarked -> presenter.setBookmarkedFilter(newState)
else -> {} else -> {}
} }
// TODO: Remove
updateManga()
updateView()
} }
} }
} }
@ -202,16 +204,11 @@ class ChaptersSettingsSheet(
override fun onItemClicked(item: Item) { override fun onItemClicked(item: Item) {
when (item) { when (item) {
source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE.toInt()) source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE)
chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER.toInt()) chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER)
uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE.toInt()) uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE)
else -> throw Exception("Unknown sorting") else -> throw Exception("Unknown sorting")
} }
// TODO: Remove
presenter.reverseSortOrder()
updateManga()
updateView()
} }
} }
} }
@ -257,14 +254,10 @@ class ChaptersSettingsSheet(
if (item.checked) return if (item.checked) return
when (item) { when (item) {
displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME.toInt()) displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME)
displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER.toInt()) displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER)
else -> throw NotImplementedError("Unknown display mode") else -> throw NotImplementedError("Unknown display mode")
} }
// TODO: Remove
updateManga()
updateView()
} }
} }
} }

View file

@ -1,30 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DeleteChaptersDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.confirm_delete_chapters)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? Listener)?.deleteChapters()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun deleteChapters()
}
}

View file

@ -1,69 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MangaChaptersHeaderBinding
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.getResourceColor
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
class MangaChaptersHeaderAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<MangaChaptersHeaderAdapter.HeaderViewHolder>() {
private var numChapters: Int? = null
private var hasActiveFilters: Boolean = false
private lateinit var binding: MangaChaptersHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaChaptersHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun getItemId(position: Int): Long = hashCode().toLong()
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
fun setNumChapters(numChapters: Int) {
this.numChapters = numChapters
notifyItemChanged(0, this)
}
fun setHasActiveFilters(hasActiveFilters: Boolean) {
this.hasActiveFilters = hasActiveFilters
notifyItemChanged(0, this)
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
binding.chaptersLabel.text = if (numChapters == null) {
view.context.getString(R.string.chapters)
} else {
view.context.resources.getQuantityString(R.plurals.manga_num_chapters, numChapters!!, numChapters)
}
val filterColor = if (hasActiveFilters) {
view.context.getResourceColor(R.attr.colorFilterActive)
} else {
view.context.getResourceColor(R.attr.colorOnBackground)
}
binding.btnChaptersFilter.drawable.setTint(filterColor)
merge(view.clicks(), binding.btnChaptersFilter.clicks())
.onEach { controller.showSettingsSheet() }
.launchIn(controller.viewScope)
}
}
}

View file

@ -1,276 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.loadAutoPause
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import uy.kohesive.injekt.injectLazy
class MangaInfoHeaderAdapter(
private val controller: MangaController,
private val fromSource: Boolean,
private val isTablet: Boolean,
) :
RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
private val trackManager: TrackManager by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
private val sourceManager: SourceManager by injectLazy()
private var manga: Manga = controller.presenter.manga
private var source: Source = controller.presenter.source
private var trackCount: Int = 0
private lateinit var binding: MangaInfoHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
updateCoverPosition()
// Expand manga info if navigated from source listing or explicitly set to
// (e.g. on tablets)
binding.mangaSummarySection.expanded = fromSource || isTablet
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun getItemId(position: Int): Long = hashCode().toLong()
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun update(manga: Manga, source: Source) {
this.manga = manga
this.source = source
update()
}
fun update() {
notifyItemChanged(0, this)
}
fun setTrackingCount(trackCount: Int) {
this.trackCount = trackCount
update()
}
private fun updateCoverPosition() {
if (isTablet) return
val appBarHeight = controller.getMainAppBarHeight()
binding.mangaCover.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin += appBarHeight
}
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
// For rounded corners
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { controller.onFavoriteClick() }
.launchIn(controller.viewScope)
if (controller.presenter.manga.favorite) {
binding.btnFavorite.longClicks()
.onEach { controller.onCategoriesClick() }
.launchIn(controller.viewScope)
}
with(binding.btnTracking) {
if (trackManager.hasLoggedServices()) {
isVisible = true
if (trackCount > 0) {
setIconResource(R.drawable.ic_done_24dp)
text = view.context.resources.getQuantityString(
R.plurals.num_trackers,
trackCount,
trackCount,
)
isActivated = true
} else {
setIconResource(R.drawable.ic_sync_24dp)
text = view.context.getString(R.string.manga_tracking_tab)
isActivated = false
}
clicks()
.onEach { controller.onTrackingClick() }
.launchIn(controller.viewScope)
} else {
isVisible = false
}
}
if (controller.presenter.source is HttpSource) {
binding.btnWebview.isVisible = true
binding.btnWebview.clicks()
.onEach { controller.openMangaInWebView() }
.launchIn(controller.viewScope)
}
binding.mangaFullTitle.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.mangaFullTitle.text.toString(),
)
}
.launchIn(controller.viewScope)
binding.mangaFullTitle.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(controller.viewScope)
binding.mangaAuthor.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.mangaAuthor.text.toString(),
binding.mangaAuthor.text.toString(),
)
}
.launchIn(controller.viewScope)
binding.mangaAuthor.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaAuthor.text.toString())
}
.launchIn(controller.viewScope)
binding.mangaArtist.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.mangaArtist.text.toString(),
binding.mangaArtist.text.toString(),
)
}
.launchIn(controller.viewScope)
binding.mangaArtist.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaArtist.text.toString())
}
.launchIn(controller.viewScope)
binding.mangaCover.clicks()
.onEach {
controller.showFullCoverDialog()
}
.launchIn(controller.viewScope)
setMangaInfo()
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo() {
// Update full title TextView.
binding.mangaFullTitle.text = manga.title.ifBlank { view.context.getString(R.string.unknown) }
// Update author TextView.
binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) {
view.context.getString(R.string.unknown_author)
} else {
manga.author
}
// Update artist TextView.
val hasArtist = !manga.artist.isNullOrBlank() && manga.artist != manga.author
binding.mangaArtist.isVisible = hasArtist
if (hasArtist) {
binding.mangaArtist.text = manga.artist
}
// If manga source is known update source TextView.
binding.mangaMissingSourceIcon.isVisible = source is SourceManager.StubSource
with(binding.mangaSource) {
text = source.getNameForMangaInfo()
setOnClickListener {
controller.performSearch(sourceManager.getOrStub(source.id).name)
}
}
// Update manga status.
val (statusDrawable, statusString) = when (manga.status) {
SManga.ONGOING -> R.drawable.ic_status_ongoing_24dp to R.string.ongoing
SManga.COMPLETED -> R.drawable.ic_status_completed_24dp to R.string.completed
SManga.LICENSED -> R.drawable.ic_status_licensed_24dp to R.string.licensed
SManga.PUBLISHING_FINISHED -> R.drawable.ic_done_24dp to R.string.publishing_finished
SManga.CANCELLED -> R.drawable.ic_close_24dp to R.string.cancelled
SManga.ON_HIATUS -> R.drawable.ic_pause_24dp to R.string.on_hiatus
else -> R.drawable.ic_status_unknown_24dp to R.string.unknown
}
binding.mangaStatusIcon.setImageResource(statusDrawable)
binding.mangaStatus.setText(statusString)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if changed.
binding.backdrop.loadAutoPause(manga)
binding.mangaCover.loadAutoPause(manga)
// Manga info section
binding.mangaSummarySection.setTags(manga.getGenres(), controller::performGenreSearch)
binding.mangaSummarySection.description = manga.description
binding.mangaSummarySection.isVisible = !manga.description.isNullOrBlank() || !manga.genre.isNullOrBlank()
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
val (iconResource, stringResource) = when (isFavorite) {
true -> R.drawable.ic_favorite_24dp to R.string.in_library
false -> R.drawable.ic_favorite_border_24dp to R.string.add_to_library
}
binding.btnFavorite.apply {
setIconResource(iconResource)
text = context.getString(stringResource)
isActivated = isFavorite
}
}
}
}

View file

@ -80,7 +80,7 @@ class TrackSearchDialog : DialogController {
// Do an initial search based on the manga's title // Do an initial search based on the manga's title
if (savedViewState == null) { if (savedViewState == null) {
currentlySearched = trackController.presenter.manga.title currentlySearched = trackController.presenter.manga!!.title
binding!!.titleInput.editText?.append(currentlySearched) binding!!.titleInput.editText?.append(currentlySearched)
} }
search(currentlySearched) search(currentlySearched)

View file

@ -10,6 +10,7 @@ import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.DateValidatorPointBackward
import com.google.android.material.datepicker.DateValidatorPointForward import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.datepicker.MaterialDatePicker
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.databinding.TrackControllerBinding import eu.kanade.tachiyomi.databinding.TrackControllerBinding
@ -25,7 +26,7 @@ import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
class TrackSheet( class TrackSheet(
val controller: MangaController, val controller: MangaController,
val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
) : BaseBottomSheetDialog(controller.activity!!), ) : BaseBottomSheetDialog(controller.activity!!),
TrackAdapter.OnClickListener, TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener, SetTrackStatusDialog.Listener,
@ -74,8 +75,8 @@ class TrackSheet(
override fun onSetClick(position: Int) { override fun onSetClick(position: Int) {
val item = adapter.getItem(position) ?: return val item = adapter.getItem(position) ?: return
val manga = controller.presenter.manga val manga = controller.presenter.manga?.toDbManga() ?: return
val source = controller.presenter.source val source = controller.presenter.source ?: return
if (item.service is EnhancedTrackService) { if (item.service is EnhancedTrackService) {
if (item.track != null) { if (item.track != null) {

View file

@ -34,7 +34,7 @@ class HistoryController : ComposeController<HistoryPresenter>(), RootController
nestedScrollInterop = nestedScrollInterop, nestedScrollInterop = nestedScrollInterop,
presenter = presenter, presenter = presenter,
onClickCover = { history -> onClickCover = { history ->
router.pushController(MangaController(history)) router.pushController(MangaController(history.id))
}, },
onClickResume = { history -> onClickResume = { history ->
presenter.getNextChapterForManga(history.mangaId, history.chapterId) presenter.getNextChapterForManga(history.mangaId, history.chapterId)

View file

@ -7,6 +7,7 @@ import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
@ -48,19 +49,18 @@ fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
return coverCache.getCustomCoverFile(id).exists() return coverCache.getCustomCoverFile(id).exists()
} }
fun Manga.removeCovers(coverCache: CoverCache) { fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int {
if (isLocal()) return if (isLocal()) return 0
cover_last_modified = Date().time cover_last_modified = Date().time
coverCache.deleteFromCache(this, true) return coverCache.deleteFromCache(this, true)
}
fun Manga.updateCoverLastModified(db: DatabaseHelper) {
cover_last_modified = Date().time
db.updateMangaCoverLastModified(this).executeAsBlocking()
} }
fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean { fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
return toDomainManga()?.shouldDownloadNewChapters(db, prefs) ?: false
}
fun DomainManga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
if (!favorite) return false if (!favorite) return false
// Boolean to determine if user wants to automatically download new chapters. // Boolean to determine if user wants to automatically download new chapters.
@ -75,7 +75,7 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
// Get all categories, else default category (0) // Get all categories, else default category (0)
val categoriesForManga = val categoriesForManga =
db.getCategoriesForManga(this).executeAsBlocking() db.getCategoriesForManga(toDbManga()).executeAsBlocking()
.mapNotNull { it.id } .mapNotNull { it.id }
.takeUnless { it.isEmpty() } ?: listOf(0) .takeUnless { it.isEmpty() } ?: listOf(0)

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.util.chapter package eu.kanade.tachiyomi.util.chapter
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -34,6 +35,18 @@ object ChapterSettingsHelper {
db.updateChapterFlags(manga).executeAsBlocking() db.updateChapterFlags(manga).executeAsBlocking()
} }
suspend fun applySettingDefaults(mangaId: Long, setMangaChapterFlags: SetMangaChapterFlags) {
setMangaChapterFlags.awaitSetAllFlags(
mangaId = mangaId,
unreadFilter = prefs.filterChapterByRead().toLong(),
downloadedFilter = prefs.filterChapterByDownloaded().toLong(),
bookmarkedFilter = prefs.filterChapterByBookmarked().toLong(),
sortingMode = prefs.sortChapterBySourceOrNumber().toLong(),
sortingDirection = prefs.sortChapterByAscendingOrDescending().toLong(),
displayMode = prefs.displayChapterByNameOrNumber().toLong(),
)
}
/** /**
* Updates all mangas in library with global Chapter Settings. * Updates all mangas in library with global Chapter Settings.
*/ */

View file

@ -2,6 +2,9 @@ package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
import eu.kanade.domain.manga.model.Manga as DomainManga
fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending()): (Chapter, Chapter) -> Int { fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending()): (Chapter, Chapter) -> Int {
return when (manga.sorting) { return when (manga.sorting) {
@ -20,3 +23,28 @@ fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending(
else -> throw NotImplementedError("Invalid chapter sorting method: ${manga.sorting}") else -> throw NotImplementedError("Invalid chapter sorting method: ${manga.sorting}")
} }
} }
fun getChapterSort(
manga: DomainManga,
sortDescending: Boolean = manga.sortDescending(),
): (DomainChapter, DomainChapter) -> Int {
return when (manga.sorting) {
DomainManga.CHAPTER_SORTING_SOURCE -> when (sortDescending) {
true -> { c1, c2 -> c1.sourceOrder.compareTo(c2.sourceOrder) }
false -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) }
}
DomainManga.CHAPTER_SORTING_NUMBER -> when (sortDescending) {
true -> { c1, c2 ->
c2.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c1.chapterNumber.toString())
}
false -> { c1, c2 ->
c1.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c2.chapterNumber.toString())
}
}
DomainManga.CHAPTER_SORTING_UPLOAD_DATE -> when (sortDescending) {
true -> { c1, c2 -> c2.dateUpload.compareTo(c1.dateUpload) }
false -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) }
}
else -> throw NotImplementedError("Unimplemented sorting method")
}
}

View file

@ -1,196 +0,0 @@
package eu.kanade.tachiyomi.widget
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MangaSummaryBinding
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.setChips
import kotlin.math.roundToInt
import kotlin.math.roundToLong
class MangaSummaryView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
private val binding = MangaSummaryBinding.inflate(LayoutInflater.from(context), this, true)
private var animatorSet: AnimatorSet? = null
private var recalculateHeights = false
private var descExpandedHeight = -1
private var descShrunkHeight = -1
var expanded = false
set(value) {
if (field != value) {
field = value
updateExpandState()
}
}
var description: CharSequence? = null
set(value) {
if (field != value) {
field = if (value.isNullOrBlank()) {
context.getString(R.string.unknown)
} else {
value
}
binding.descriptionText.text = field
recalculateHeights = true
doOnNextLayout {
updateExpandState()
}
if (!isInLayout) {
requestLayout()
}
}
}
fun setTags(items: List<String>?, onClick: (item: String) -> Unit) {
listOfNotNull(binding.tagChipsShrunk, binding.tagChipsExpanded).forEach { chips ->
chips.setChips(items, onClick) { tag -> context.copyToClipboard(tag, tag) }
}
}
private fun updateExpandState() = binding.apply {
val initialSetup = descriptionText.maxHeight < 0
val maxHeightTarget = if (expanded) descExpandedHeight else descShrunkHeight
val maxHeightStart = if (initialSetup) maxHeightTarget else descriptionText.maxHeight
val descMaxHeightAnimator = ValueAnimator().apply {
setIntValues(maxHeightStart, maxHeightTarget)
addUpdateListener {
descriptionText.maxHeight = it.animatedValue as Int
}
}
val toggleDrawable = ContextCompat.getDrawable(
context,
if (expanded) R.drawable.anim_caret_up else R.drawable.anim_caret_down,
)
toggleMore.setImageDrawable(toggleDrawable)
var pastHalf = false
val toggleTarget = if (expanded) 1F else 0F
val toggleStart = if (initialSetup) {
toggleTarget
} else {
toggleMore.translationY / toggleMore.height
}
val toggleAnimator = ValueAnimator().apply {
setFloatValues(toggleStart, toggleTarget)
addUpdateListener {
val value = it.animatedValue as Float
toggleMore.translationY = toggleMore.height * value
descriptionScrim.translationY = toggleMore.translationY
toggleMoreScrim.translationY = toggleMore.translationY
tagChipsShrunkContainer.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = toggleMore.translationY.roundToInt()
}
tagChipsExpanded.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = toggleMore.translationY.roundToInt()
}
// Update non-animatable objects mid-animation makes it feel less abrupt
if (it.animatedFraction >= 0.5F && !pastHalf) {
pastHalf = true
descriptionText.text = trimWhenNeeded(description)
tagChipsShrunkContainer.scrollX = 0
tagChipsShrunkContainer.isVisible = !expanded
tagChipsExpanded.isVisible = expanded
}
}
}
animatorSet?.cancel()
animatorSet = AnimatorSet().apply {
interpolator = FastOutSlowInInterpolator()
duration = (TOGGLE_ANIM_DURATION * context.animatorDurationScale).roundToLong()
playTogether(toggleAnimator, descMaxHeightAnimator)
start()
}
(toggleDrawable as? Animatable)?.start()
}
private fun trimWhenNeeded(text: CharSequence?): CharSequence? {
return if (!expanded) {
text
?.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
?.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
} else {
text
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Wait until parent view has determined the exact width
// because this affect the description line count
val measureWidthFreely = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY
if (!recalculateHeights || measureWidthFreely) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
}
recalculateHeights = false
// Measure with expanded lines
binding.descriptionText.maxLines = Int.MAX_VALUE
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
descExpandedHeight = binding.descriptionText.measuredHeight
// Measure with shrunk lines
binding.descriptionText.maxLines = SHRUNK_DESC_MAX_LINES
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
descShrunkHeight = binding.descriptionText.measuredHeight
}
init {
binding.descriptionText.apply {
// So that 1 line of text won't be hidden by scrim
minLines = DESC_MIN_LINES
setOnLongClickListener {
description?.let {
context.copyToClipboard(
context.getString(R.string.description),
it.toString(),
)
}
true
}
}
arrayOf(
binding.descriptionText,
binding.descriptionScrim,
binding.toggleMoreScrim,
binding.toggleMore,
).forEach {
it.setOnClickListener { expanded = !expanded }
}
}
}
private const val TOGGLE_ANIM_DURATION = 300L
private const val DESC_MIN_LINES = 2
private const val SHRUNK_DESC_MAX_LINES = 3

View file

@ -4,6 +4,7 @@ import android.view.LayoutInflater
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
@ -11,6 +12,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.databinding.DialogStubQuadstatemultichoiceBinding import eu.kanade.tachiyomi.databinding.DialogStubQuadstatemultichoiceBinding
import eu.kanade.tachiyomi.databinding.DialogStubTextinputBinding import eu.kanade.tachiyomi.databinding.DialogStubTextinputBinding
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
fun MaterialAlertDialogBuilder.setTextInput( fun MaterialAlertDialogBuilder.setTextInput(
hint: String? = null, hint: String? = null,
@ -71,3 +74,19 @@ fun MaterialAlertDialogBuilder.setQuadStateMultiChoiceItems(
} }
return setView(binding.root) return setView(binding.root)
} }
suspend fun MaterialAlertDialogBuilder.await(
@StringRes positiveLabelId: Int,
@StringRes negativeLabelId: Int,
@StringRes neutralLabelId: Int? = null,
) = suspendCancellableCoroutine<Int> { cont ->
setPositiveButton(positiveLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_POSITIVE) }
setNegativeButton(negativeLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEGATIVE) }
if (neutralLabelId != null) {
setNeutralButton(neutralLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEUTRAL) }
}
setOnDismissListener { cont.cancel() }
val dialog = show()
cont.invokeOnCancellation { dialog.dismiss() }
}

View file

@ -1,84 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="caret_up"
android:height="24.0dip"
android:width="24.0dip"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<group
android:name="caret02"
android:rotation="90.0"
android:translateX="12.0"
android:translateY="9.0">
<group
android:name="caret02_l"
android:rotation="-45.0">
<group
android:name="caret02_l_pivot"
android:translateY="4.0">
<group
android:name="caret02_l_rect_position"
android:translateY="-1.0">
<path
android:name="caret02_l_rect"
android:fillColor="@android:color/black"
android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
</group>
</group>
</group>
<group
android:name="caret02_r"
android:rotation="45.0">
<group
android:name="caret02_r_pivot"
android:translateY="-4.0">
<group
android:name="caret02_r_rect_position"
android:translateY="1.0">
<path
android:name="caret02_r_rect"
android:fillColor="@android:color/black"
android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
</group>
</group>
</group>
</group>
</vector>
</aapt:attr>
<target android:name="caret02">
<aapt:attr name="android:animation">
<objectAnimator
android:interpolator="@android:interpolator/fast_out_slow_in"
android:duration="300"
android:pathData="M 12.0,15.0 c 0.0,-1.0 0.0,-5.33333 0.0,-6.0"
android:propertyXName="translateX"
android:propertyYName="translateY" />
</aapt:attr>
</target>
<target android:name="caret02_l">
<aapt:attr name="android:animation">
<objectAnimator
android:interpolator="@android:interpolator/fast_out_slow_in"
android:duration="300"
android:valueFrom="45.0"
android:valueTo="-45.0"
android:valueType="floatType"
android:propertyName="rotation" />
</aapt:attr>
</target>
<target android:name="caret02_r">
<aapt:attr name="android:animation">
<objectAnimator
android:interpolator="@android:interpolator/fast_out_slow_in"
android:duration="300"
android:valueFrom="-45.0"
android:valueTo="45.0"
android:valueType="floatType"
android:propertyName="rotation" />
</aapt:attr>
</target>
</animated-vector>

View file

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linear_recycler_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/info_recycler"
android:layout_width="0dp"
android:layout_height="match_parent"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/chapters_recycler"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_max="@dimen/tablet_sidebar_max_width"
app:layout_constraintWidth_percent="0.5"
tools:itemCount="1"
tools:listitem="@layout/manga_info_header" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chapters_recycler"
android:layout_width="0dp"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/fab_list_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/info_recycler"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/chapters_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,208 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<ImageView
android:id="@+id/backdrop"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="-32dp"
android:alpha="0.2"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="@+id/manga_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@mipmap/ic_launcher"
tools:ignore="ContentDescription" />
<View
android:id="@+id/backdrop_overlay"
android:layout_width="0dp"
android:layout_height="0dp"
android:alpha="1"
android:background="@drawable/manga_backdrop_gradient"
android:backgroundTint="?android:attr/colorBackground"
app:layout_constraintBottom_toBottomOf="@+id/backdrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/manga_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/tablet_horizontal_cover_margin"
android:layout_marginTop="32dp"
android:layout_marginEnd="@dimen/tablet_horizontal_cover_margin"
android:background="@drawable/rounded_rectangle"
android:contentDescription="@string/description_cover"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="w,3:2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@mipmap/ic_launcher" />
<LinearLayout
android:id="@+id/manga_detail"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="-8dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backdrop">
<TextView
android:id="@+id/manga_full_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:gravity="center"
android:text="@string/manga_info_full_title_label"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textIsSelectable="false" />
<TextView
android:id="@+id/manga_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?android:attr/textColorSecondary"
android:textAlignment="center"
android:textIsSelectable="false"
tools:text="Author" />
<TextView
android:id="@+id/manga_artist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Artist" />
<LinearLayout
android:id="@+id/manga_status_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/manga_status_icon"
android:layout_width="16dp"
android:layout_height="match_parent"
android:layout_marginEnd="4dp"
app:srcCompat="@drawable/ic_status_unknown_24dp"
app:tint="?android:attr/textColorSecondary"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/manga_status"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Status" />
<ImageView
android:id="@+id/manga_missing_source_icon"
android:layout_width="16dp"
android:layout_height="match_parent"
android:layout_marginEnd="4dp"
app:srcCompat="@drawable/ic_warning_white_24dp"
app:tint="@color/error"
tools:ignore="ContentDescription" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:text="•"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/manga_source"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Source" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/manga_actions"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_detail">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_favorite"
style="@style/Widget.Tachiyomi.Button.ActionButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/add_to_library"
app:icon="@drawable/ic_favorite_border_24dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_tracking"
style="@style/Widget.Tachiyomi.Button.ActionButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/manga_tracking_tab"
android:visibility="gone"
app:icon="@drawable/ic_sync_24dp"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_webview"
style="@style/Widget.Tachiyomi.Button.ActionButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/action_web_view"
android:visibility="gone"
app:icon="@drawable/ic_public_24dp"
tools:visibility="visible" />
</LinearLayout>
<eu.kanade.tachiyomi.widget.MangaSummaryView
android:id="@+id/manga_summary_section"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/manga_actions" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<TextView
android:id="@+id/chapters_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/chapters"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textIsSelectable="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btn_chapters_filter"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/btn_chapters_filter"
android:layout_width="28dp"
android:layout_height="28dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_filter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_filter_list_24dp"
app:tint="?attr/colorOnBackground" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/full_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:clipToPadding="false"
android:paddingBottom="@dimen/fab_list_padding"
tools:listitem="@layout/chapters_item" />
</eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:contentInsetStartWithNavigation="0dp"
app:menu="@menu/full_cover"
app:navigationIcon="@drawable/ic_close_24dp" />
</com.google.android.material.appbar.AppBarLayout>
<eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
android:id="@+id/container"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:clipChildren="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appbar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,220 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<ImageView
android:id="@+id/backdrop"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="-32dp"
android:alpha="0.2"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="@+id/manga_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@mipmap/ic_launcher"
tools:ignore="ContentDescription" />
<View
android:id="@+id/backdrop_overlay"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@drawable/manga_backdrop_gradient"
android:backgroundTint="?android:attr/colorBackground"
app:layout_constraintBottom_toBottomOf="@+id/backdrop"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/manga_cover_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:barrierMargin="16dp"
app:constraint_referenced_ids="manga_cover"
app:layout_constraintTop_toTopOf="@id/manga_cover" />
<ImageView
android:id="@+id/manga_cover"
android:layout_width="100dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:background="@drawable/rounded_rectangle"
android:contentDescription="@string/description_cover"
android:maxWidth="100dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="w,3:2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_height="133dp"
tools:src="@mipmap/ic_launcher" />
<LinearLayout
android:id="@+id/manga_detail"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_vertical"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="@id/manga_info_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_cover"
app:layout_constraintTop_toTopOf="@id/manga_cover_barrier">
<TextView
android:id="@+id/manga_full_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/manga_info_full_title_label"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textIsSelectable="false" />
<TextView
android:id="@+id/manga_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Author" />
<TextView
android:id="@+id/manga_artist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Artist" />
<LinearLayout
android:id="@+id/manga_status_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp">
<ImageView
android:id="@+id/manga_status_icon"
android:layout_width="16dp"
android:layout_height="match_parent"
android:layout_marginEnd="4dp"
app:srcCompat="@drawable/ic_status_unknown_24dp"
app:tint="?android:attr/textColorSecondary"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/manga_status"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Status" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:text="•"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:ignore="HardcodedText" />
<ImageView
android:id="@+id/manga_missing_source_icon"
android:layout_width="16dp"
android:layout_height="match_parent"
android:layout_marginEnd="4dp"
app:srcCompat="@drawable/ic_warning_white_24dp"
app:tint="@color/error"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/manga_source"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Source" />
</LinearLayout>
</LinearLayout>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/manga_info_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="manga_cover,manga_detail" />
<LinearLayout
android:id="@+id/manga_actions"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/manga_info_barrier">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_favorite"
style="@style/Widget.Tachiyomi.Button.ActionButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/add_to_library"
app:icon="@drawable/ic_favorite_border_24dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_tracking"
style="@style/Widget.Tachiyomi.Button.ActionButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/manga_tracking_tab"
android:visibility="gone"
app:icon="@drawable/ic_sync_24dp"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_webview"
style="@style/Widget.Tachiyomi.Button.ActionButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/action_web_view"
android:visibility="gone"
app:icon="@drawable/ic_public_24dp"
tools:visibility="visible" />
</LinearLayout>
<eu.kanade.tachiyomi.widget.MangaSummaryView
android:id="@+id/manga_summary_section"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/manga_actions" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/description_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:ellipsize="end"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
app:firstBaselineToTopHeight="0dp"
app:lastBaselineToBottomHeight="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Collapsed summary content Collapsed summary content Collapsed summary content Collapsed summary content Collapsed summary content Collapsed summary content" />
<View
android:id="@+id/description_scrim"
android:layout_width="0dp"
android:layout_height="24sp"
android:background="@drawable/manga_info_gradient"
android:backgroundTint="?android:attr/colorBackground"
app:layout_constraintBottom_toBottomOf="@+id/description_text"
app:layout_constraintEnd_toEndOf="@+id/description_text"
app:layout_constraintStart_toStartOf="@+id/description_text" />
<View
android:id="@+id/toggle_more_scrim"
android:layout_width="36sp"
android:layout_height="18sp"
android:background="@drawable/manga_info_more_gradient"
android:backgroundTint="?android:attr/colorBackground"
app:layout_constraintBottom_toBottomOf="@+id/toggle_more"
app:layout_constraintEnd_toEndOf="@+id/toggle_more"
app:layout_constraintStart_toStartOf="@+id/toggle_more"
app:layout_constraintTop_toTopOf="@+id/toggle_more" />
<ImageButton
android:id="@+id/toggle_more"
style="@style/Widget.Tachiyomi.Button.InlineButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="-6dp"
android:background="@android:color/transparent"
android:contentDescription="@string/manga_info_expand"
android:padding="0dp"
android:src="@drawable/anim_caret_down"
app:layout_constraintBottom_toBottomOf="@id/description_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:tint="?android:attr/textColorPrimary" />
<HorizontalScrollView
android:id="@+id/tag_chips_shrunk_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:requiresFadingEdge="horizontal"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toggle_more">
<com.google.android.material.chip.ChipGroup
android:id="@+id/tag_chips_shrunk"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"
app:chipSpacingHorizontal="4dp"
app:singleLine="true" />
</HorizontalScrollView>
<com.google.android.material.chip.ChipGroup
android:id="@+id/tag_chips_expanded"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"
android:visibility="gone"
app:chipSpacingHorizontal="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toggle_more"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_share_cover"
android:icon="@drawable/ic_share_24dp"
android:title="@string/action_share"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_save_cover"
android:icon="@drawable/ic_save_24dp"
android:title="@string/action_save"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_edit_cover"
android:icon="@drawable/ic_edit_24dp"
android:title="@string/action_edit"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
</menu>

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_share"
android:icon="@drawable/ic_share_24dp"
android:title="@string/action_share"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
<item
android:id="@+id/download_group"
android:icon="@drawable/ic_get_app_24dp"
android:title="@string/manga_download"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom">
<menu>
<item
android:id="@+id/download_next"
android:title="@string/download_1" />
<item
android:id="@+id/download_next_5"
android:title="@string/download_5" />
<item
android:id="@+id/download_next_10"
android:title="@string/download_10" />
<item
android:id="@+id/download_custom"
android:title="@string/download_custom" />
<item
android:id="@+id/download_unread"
android:title="@string/download_unread" />
<item
android:id="@+id/download_all"
android:title="@string/download_all" />
</menu>
</item>
<item
android:id="@+id/action_edit_categories"
android:title="@string/action_edit_categories"
app:showAsAction="never" />
<item
android:id="@+id/action_migrate"
android:title="@string/action_migrate"
app:showAsAction="never" />
</menu>

View file

@ -1,17 +1,21 @@
[versions] [versions]
compose = "1.2.0-rc02" compose = "1.2.0-rc02"
accompanist = "0.24.12-rc" accompanist = "0.24.12-rc"
material3 = "1.0.0-alpha13"
[libraries] [libraries]
activity = "androidx.activity:activity-compose:1.6.0-alpha05" activity = "androidx.activity:activity-compose:1.6.0-alpha05"
foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
animation = { module = "androidx.compose.animation:animation", version.ref = "compose" } animation = { module = "androidx.compose.animation:animation", version.ref = "compose" }
animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref="compose" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" } ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" }
material3-core = "androidx.compose.material3:material3:1.0.0-alpha13" material3-core = { module = "androidx.compose.material3:material3", version.ref = "material3" }
material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3" }
material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.11" material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.11"
material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" } material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" }
accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref="accompanist" }