From 33a778873af0eb52528a29f741fa59b530679c64 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Sat, 25 Jun 2022 22:03:48 +0700 Subject: [PATCH] MangaController overhaul (#7244) --- app/build.gradle.kts | 7 +- .../kanade/data/manga/MangaRepositoryImpl.kt | 4 + .../java/eu/kanade/domain/DomainModule.kt | 2 + .../manga/interactor/GetMangaWithChapters.kt | 4 + .../manga/interactor/SetMangaChapterFlags.kt | 95 ++ .../domain/manga/interactor/UpdateManga.kt | 14 +- .../manga/repository/MangaRepository.kt | 2 + .../kanade/presentation/components/Button.kt | 101 ++ .../components/FloatingActionButton.kt | 111 ++ .../kanade/presentation/components/Surface.kt | 108 ++ .../kanade/presentation/manga/MangaScreen.kt | 803 ++++++++++++ .../manga/MangaScreenConstants.kt | 15 +- .../manga/components/ChapterHeader.kt | 61 + .../manga/components/DotSeparatorText.kt | 9 + .../manga/components/MangaBottomActionMenu.kt | 197 +++ .../manga/components/MangaChapterListItem.kt | 139 +++ .../manga/components/MangaInfoHeader.kt | 616 +++++++++ .../manga/components/MangaSmallAppBar.kt | 237 ++++ .../manga/components/MangaTopAppBar.kt | 141 +++ .../kanade/presentation/util/LazyListState.kt | 24 + .../util/TopAppBarScrollBehavior.kt | 158 +++ .../presentation/util/WindowSizeClass.kt | 24 + .../tachiyomi/data/database/models/Chapter.kt | 19 + .../data/database/models/LibraryManga.kt | 20 + .../java/eu/kanade/tachiyomi/source/Source.kt | 2 + .../eu/kanade/tachiyomi/source/model/Page.kt | 27 + .../migration/search/SearchController.kt | 2 +- .../ui/library/ChangeMangaCoverDialog.kt | 40 - .../kanade/tachiyomi/ui/main/MainActivity.kt | 16 +- .../tachiyomi/ui/manga/MangaController.kt | 1104 ++++------------- .../tachiyomi/ui/manga/MangaPresenter.kt | 784 ++++++------ .../ui/manga/chapter/ChapterHolder.kt | 127 -- .../tachiyomi/ui/manga/chapter/ChapterItem.kt | 33 - .../ui/manga/chapter/ChaptersAdapter.kt | 46 - .../ui/manga/chapter/ChaptersSettingsSheet.kt | 47 +- .../ui/manga/chapter/DeleteChaptersDialog.kt | 30 - .../chapter/MangaChaptersHeaderAdapter.kt | 69 -- .../ui/manga/info/MangaInfoHeaderAdapter.kt | 276 ----- .../ui/manga/track/TrackSearchDialog.kt | 2 +- .../tachiyomi/ui/manga/track/TrackSheet.kt | 7 +- .../ui/recent/history/HistoryController.kt | 2 +- .../kanade/tachiyomi/util/MangaExtensions.kt | 18 +- .../util/chapter/ChapterSettingsHelper.kt | 13 + .../tachiyomi/util/chapter/ChapterSorter.kt | 28 + .../tachiyomi/widget/MangaSummaryView.kt | 196 --- .../MaterialAlertDialogBuilderExtensions.kt | 19 + app/src/main/res/drawable/anim_caret_up.xml | 84 -- .../res/layout-sw720dp/manga_controller.xml | 59 - .../res/layout-sw720dp/manga_info_header.xml | 208 ---- .../main/res/layout/manga_chapters_header.xml | 38 - app/src/main/res/layout/manga_controller.xml | 36 - .../res/layout/manga_full_cover_dialog.xml | 37 - app/src/main/res/layout/manga_info_header.xml | 220 ---- app/src/main/res/layout/manga_summary.xml | 94 -- app/src/main/res/menu/full_cover.xml | 24 - app/src/main/res/menu/manga.xml | 49 - gradle/compose.versions.toml | 8 +- 57 files changed, 3701 insertions(+), 2955 deletions(-) create mode 100644 app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaChapterFlags.kt create mode 100644 app/src/main/java/eu/kanade/presentation/components/Button.kt create mode 100644 app/src/main/java/eu/kanade/presentation/components/FloatingActionButton.kt create mode 100644 app/src/main/java/eu/kanade/presentation/components/Surface.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/ChapterHeader.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/DotSeparatorText.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/MangaTopAppBar.kt create mode 100644 app/src/main/java/eu/kanade/presentation/util/TopAppBarScrollBehavior.kt create mode 100644 app/src/main/java/eu/kanade/presentation/util/WindowSizeClass.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaChaptersHeaderAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt delete mode 100644 app/src/main/res/drawable/anim_caret_up.xml delete mode 100644 app/src/main/res/layout-sw720dp/manga_controller.xml delete mode 100644 app/src/main/res/layout-sw720dp/manga_info_header.xml delete mode 100644 app/src/main/res/layout/manga_chapters_header.xml delete mode 100644 app/src/main/res/layout/manga_controller.xml delete mode 100644 app/src/main/res/layout/manga_full_cover_dialog.xml delete mode 100644 app/src/main/res/layout/manga_info_header.xml delete mode 100644 app/src/main/res/layout/manga_summary.xml delete mode 100644 app/src/main/res/menu/full_cover.xml delete mode 100644 app/src/main/res/menu/manga.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 921e6183b7..f57149dd24 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -150,13 +150,16 @@ dependencies { implementation(compose.activity) implementation(compose.foundation) implementation(compose.material3.core) + implementation(compose.material3.windowsizeclass) implementation(compose.material3.adapter) implementation(compose.material.icons) implementation(compose.animation) + implementation(compose.animation.graphics) implementation(compose.ui.tooling) implementation(compose.ui.util) implementation(compose.accompanist.webview) implementation(compose.accompanist.swiperefresh) + implementation(compose.accompanist.flowlayout) implementation(androidx.paging.runtime) implementation(androidx.paging.compose) @@ -299,7 +302,9 @@ tasks { "-opt-in=coil.annotation.ExperimentalCoilApi", "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-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", ) } diff --git a/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt index 140c51af40..b90a46f87d 100644 --- a/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt @@ -22,6 +22,10 @@ class MangaRepositoryImpl( return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) } } + override suspend fun getMangaByIdAsFlow(id: Long): Flow { + return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) } + } + override fun getFavoritesBySourceId(sourceId: Long): Flow> { return handler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) } } diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 8d061eb1c6..f16925ce98 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -33,6 +33,7 @@ import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId import eu.kanade.domain.manga.interactor.GetMangaById import eu.kanade.domain.manga.interactor.GetMangaWithChapters 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.repository.MangaRepository import eu.kanade.domain.source.interactor.GetEnabledSources @@ -71,6 +72,7 @@ class DomainModule : InjektModule { addFactory { GetMangaById(get()) } addFactory { GetNextChapter(get()) } addFactory { ResetViewerFlags(get()) } + addFactory { SetMangaChapterFlags(get()) } addFactory { UpdateManga(get()) } addFactory { MoveMangaToCategories(get()) } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetMangaWithChapters.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetMangaWithChapters.kt index 1c64264701..cd0375203e 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/GetMangaWithChapters.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetMangaWithChapters.kt @@ -20,4 +20,8 @@ class GetMangaWithChapters( Pair(manga, chapters) } } + + suspend fun awaitManga(id: Long): Manga { + return mangaRepository.getMangaById(id) + } } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaChapterFlags.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaChapterFlags.kt new file mode 100644 index 0000000000..3cb34503f2 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaChapterFlags.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt index b18d1f2229..1071997ac2 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt @@ -8,6 +8,8 @@ import eu.kanade.domain.manga.model.toDbManga import eu.kanade.domain.manga.repository.MangaRepository import eu.kanade.tachiyomi.data.cache.CoverCache import tachiyomi.source.model.MangaInfo +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.util.Date class UpdateManga( @@ -22,7 +24,7 @@ class UpdateManga( localManga: Manga, remoteManga: MangaInfo, manualFetch: Boolean, - coverCache: CoverCache, + coverCache: CoverCache = Injekt.get(), ): Boolean { // 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 @@ -66,4 +68,14 @@ class UpdateManga( suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean { 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), + ) + } } diff --git a/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt b/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt index b72e9bba04..60c07ba84d 100644 --- a/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt +++ b/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt @@ -10,6 +10,8 @@ interface MangaRepository { suspend fun subscribeMangaById(id: Long): Flow + suspend fun getMangaByIdAsFlow(id: Long): Flow + fun getFavoritesBySourceId(sourceId: Long): Flow> suspend fun getDuplicateLibraryManga(title: String, sourceId: Long): Manga? diff --git a/app/src/main/java/eu/kanade/presentation/components/Button.kt b/app/src/main/java/eu/kanade/presentation/components/Button.kt new file mode 100644 index 0000000000..b1472ae323 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/Button.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/components/FloatingActionButton.kt b/app/src/main/java/eu/kanade/presentation/components/FloatingActionButton.kt new file mode 100644 index 0000000000..25d70dd0e1 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/FloatingActionButton.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/presentation/components/Surface.kt b/app/src/main/java/eu/kanade/presentation/components/Surface.kt new file mode 100644 index 0000000000..c27c06712d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/Surface.kt @@ -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) +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt new file mode 100644 index 0000000000..a0041ab84e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -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, 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, bookmarked: Boolean) -> Unit, + onMultiMarkAsReadClicked: (List, markAsRead: Boolean) -> Unit, + onMarkPreviousAsReadClicked: (Chapter) -> Unit, + onMultiDeleteClicked: (List) -> 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, 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, bookmarked: Boolean) -> Unit, + onMultiMarkAsReadClicked: (List, markAsRead: Boolean) -> Unit, + onMarkPreviousAsReadClicked: (Chapter) -> Unit, + onMultiDeleteClicked: (List) -> Unit, +) { + val context = LocalContext.current + val layoutDirection = LocalLayoutDirection.current + val haptic = LocalHapticFeedback.current + val decayAnimationSpec = rememberSplineBasedDecay() + 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().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, 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, bookmarked: Boolean) -> Unit, + onMultiMarkAsReadClicked: (List, markAsRead: Boolean) -> Unit, + onMarkPreviousAsReadClicked: (Chapter) -> Unit, + onMultiDeleteClicked: (List) -> 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().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, + chapters: List, + selectedPositions: Array, +): 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, + chapters: List, + selectedPositions: Array, + 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) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt index 86c217fa43..5fcac70a70 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt @@ -1,8 +1,12 @@ package eu.kanade.presentation.manga -enum class EditCoverAction { - EDIT, - DELETE, +enum class DownloadAction { + NEXT_1_CHAPTER, + NEXT_5_CHAPTERS, + NEXT_10_CHAPTERS, + CUSTOM, + UNREAD_CHAPTERS, + ALL_CHAPTERS } enum class ChapterDownloadAction { @@ -11,3 +15,8 @@ enum class ChapterDownloadAction { CANCEL, DELETE, } + +enum class EditCoverAction { + EDIT, + DELETE, +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/ChapterHeader.kt b/app/src/main/java/eu/kanade/presentation/manga/components/ChapterHeader.kt new file mode 100644 index 0000000000..b1ba69c79f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/ChapterHeader.kt @@ -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 + }, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/DotSeparatorText.kt b/app/src/main/java/eu/kanade/presentation/manga/components/DotSeparatorText.kt new file mode 100644 index 0000000000..f4ae8ef720 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/DotSeparatorText.kt @@ -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 = " • ") +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt new file mode 100644 index 0000000000..bbe0448d63 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt new file mode 100644 index 0000000000..452dae75cc --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt new file mode 100644 index 0000000000..89f46e3f46 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt @@ -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?, + 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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt new file mode 100644 index 0000000000..27c0242e89 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaTopAppBar.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaTopAppBar.kt new file mode 100644 index 0000000000..9af664d672 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaTopAppBar.kt @@ -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?, + 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 + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt b/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt index adf7cd80c4..7fb9235c5f 100644 --- a/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt +++ b/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt @@ -1,5 +1,29 @@ package eu.kanade.presentation.util 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 + +@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 +} diff --git a/app/src/main/java/eu/kanade/presentation/util/TopAppBarScrollBehavior.kt b/app/src/main/java/eu/kanade/presentation/util/TopAppBarScrollBehavior.kt new file mode 100644 index 0000000000..cd9351eb5c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/TopAppBarScrollBehavior.kt @@ -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, + 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, +): 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 +} diff --git a/app/src/main/java/eu/kanade/presentation/util/WindowSizeClass.kt b/app/src/main/java/eu/kanade/presentation/util/WindowSizeClass.kt new file mode 100644 index 0000000000..1f5ee9e4d2 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/WindowSizeClass.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt index 589ed671db..2437e0a39b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.models import eu.kanade.tachiyomi.source.model.SChapter import java.io.Serializable +import eu.kanade.domain.chapter.model.Chapter as DomainChapter 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, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt index 5aebcb3ce1..ba036fd3fa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt @@ -12,4 +12,24 @@ class LibraryManga : MangaImpl() { get() = readCount > 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 + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index 2acfd44bb3..d71dd05bbb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -121,3 +121,5 @@ fun Source.getNameForMangaInfo(): String { else -> toString() } } + +fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index c6b46dde29..0f4d204a69 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -57,6 +57,33 @@ open class Page( 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 { const val QUEUE = 0 const val LOAD_PAGE = 1 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt index f8376a6fe5..a773c04f9e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt @@ -83,7 +83,7 @@ class SearchController( binding.progress.isVisible = isReplacingManga if (!isReplacingManga) { router.popController(this) - if (newManga != null) { + if (newManga?.id != null) { val newMangaController = RouterTransaction.with(MangaController(newManga.id!!)) if (router.backstack.lastOrNull()?.controller is MangaController) { // Replace old MangaController diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt deleted file mode 100644 index d3bfb3603a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt +++ /dev/null @@ -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(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 6097ec9365..89d3a0588b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -420,7 +420,7 @@ class MainActivity : BaseActivity() { SHORTCUT_MANGA -> { val extras = intent.extras ?: return false 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() setSelectedNavItem(R.id.nav_library) router.pushController(RouterTransaction.with(MangaController(extras))) @@ -601,6 +601,9 @@ class MainActivity : BaseActivity() { } val isFullComposeController = internalTo is FullComposeController<*> + binding.appbar.isVisible = !isFullComposeController + binding.controllerContainer.enableScrollingBehavior(!isFullComposeController) + if (!isTablet()) { // Save lift state if (isPush) { @@ -623,17 +626,6 @@ class MainActivity : BaseActivity() { } 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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index f6ea48b3ab..1a287e8e8d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -1,57 +1,44 @@ package eu.kanade.tachiyomi.ui.manga -import android.app.ActivityOptions import android.content.Intent import android.os.Bundle import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.annotation.FloatRange -import androidx.appcompat.view.ActionMode +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.core.os.bundleOf -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.doOnLayout -import androidx.core.view.updateLayoutParams -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType -import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton -import com.google.android.material.snackbar.Snackbar -import dev.chrisbanes.insetter.applyInsetter -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.SelectableAdapter +import com.google.android.material.dialog.MaterialAlertDialogBuilder import eu.kanade.data.chapter.NoChaptersException -import eu.kanade.domain.category.model.toDbCategory -import eu.kanade.domain.history.model.HistoryWithRelations +import eu.kanade.domain.manga.model.toDbManga +import eu.kanade.presentation.manga.ChapterDownloadAction +import eu.kanade.presentation.manga.DownloadAction +import eu.kanade.presentation.manga.MangaScreen +import eu.kanade.presentation.util.calculateWindowWidthSizeClass import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.track.EnhancedTrackService -import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.databinding.MangaControllerBinding import eu.kanade.tachiyomi.network.HttpException -import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.base.controller.FabController -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight +import eu.kanade.tachiyomi.ui.base.controller.FullComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController @@ -61,15 +48,9 @@ import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem -import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersAdapter import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersSettingsSheet -import eu.kanade.tachiyomi.ui.manga.chapter.DeleteChaptersDialog import eu.kanade.tachiyomi.ui.manga.chapter.DownloadCustomChaptersDialog -import eu.kanade.tachiyomi.ui.manga.chapter.MangaChaptersHeaderAdapter -import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter import eu.kanade.tachiyomi.ui.manga.info.MangaFullCoverDialog -import eu.kanade.tachiyomi.ui.manga.info.MangaInfoHeaderAdapter import eu.kanade.tachiyomi.ui.manga.track.TrackItem import eu.kanade.tachiyomi.ui.manga.track.TrackSearchDialog import eu.kanade.tachiyomi.ui.manga.track.TrackSheet @@ -77,413 +58,139 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.recent.history.HistoryController import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController import eu.kanade.tachiyomi.ui.webview.WebViewActivity -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.shrinkOnScroll -import eu.kanade.tachiyomi.util.view.snack -import eu.kanade.tachiyomi.widget.ActionModeWithToolbar import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.runBlocking +import eu.kanade.tachiyomi.widget.materialdialogs.await +import kotlinx.coroutines.launch import logcat.LogPriority -import reactivecircus.flowbinding.recyclerview.scrollStateChanges -import reactivecircus.flowbinding.swiperefreshlayout.refreshes -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy -import java.util.ArrayDeque -import kotlin.math.min +import eu.kanade.domain.chapter.model.Chapter as DomainChapter class MangaController : - NucleusController, - FabController, - ActionModeWithToolbar.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - BaseChaptersAdapter.OnChapterClickListener, + FullComposeController, ChangeMangaCategoriesDialog.Listener, - DownloadCustomChaptersDialog.Listener, - DeleteChaptersDialog.Listener { - - constructor(history: HistoryWithRelations) : this(history.mangaId) - - constructor(mangaId: Long, fromSource: Boolean = false) : super( - bundleOf( - MANGA_EXTRA to mangaId, - FROM_SOURCE_EXTRA to fromSource, - ), - ) { - this.manga = Injekt.get().getManga(mangaId).executeAsBlocking() - if (this.manga != null) { - source = Injekt.get().getOrStub(this.manga!!.source) - } - } + DownloadCustomChaptersDialog.Listener { @Suppress("unused") constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) - var manga: Manga? = null - private set + constructor( + mangaId: Long, + fromSource: Boolean = false, + ) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource)) { + this.mangaId = mangaId + } - var source: Source? = null - private set + var mangaId: Long - val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false) - - private val preferences: PreferencesHelper by injectLazy() - private val coverCache: CoverCache by injectLazy() - - private var mangaInfoAdapter: MangaInfoHeaderAdapter? = null - private var chaptersHeaderAdapter: MangaChaptersHeaderAdapter? = null - private var chaptersAdapter: ChaptersAdapter? = null + val fromSource: Boolean + get() = presenter.isFromSource // Sheet containing filter/sort/display items. - private var settingsSheet: ChaptersSettingsSheet? = null + private lateinit var settingsSheet: ChaptersSettingsSheet - private var actionFab: ExtendedFloatingActionButton? = null - private var actionFabScrollListener: RecyclerView.OnScrollListener? = null + private lateinit var trackSheet: TrackSheet - // Snackbar to add manga to library after downloading chapter(s) - private var addSnackbar: Snackbar? = null - - /** - * Action mode for multiple selection. - */ - private var actionMode: ActionModeWithToolbar? = null - - /** - * Selected items. Used to restore selections after a rotation. - */ - private val selectedChapters = mutableSetOf() - - private val isLocalSource by lazy { presenter.source.id == LocalSource.ID } - - private var lastClickPositionStack = ArrayDeque(listOf(-1)) - - private var isRefreshingInfo = false - private var isRefreshingChapters = false - - private var trackSheet: TrackSheet? = null - - /** - * For [recyclerViewUpdatesToolbarTitleAlpha] - */ - private var recyclerViewToolbarTitleAlphaUpdaterAdded = false - private val recyclerViewToolbarTitleAlphaUpdater = object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - updateToolbarTitleAlpha() - } - } - - init { - setHasOptionsMenu(true) - } - - override fun getTitle(): String? { - return manga?.title - } + private val snackbarHostState = SnackbarHostState() override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { super.onChangeStarted(handler, type) - // Hide toolbar title on enter - // No need to update alpha for cover dialog - if (!type.isEnter) { - if (!type.isPush || router.backstack.lastOrNull()?.controller !is DialogController) { - updateToolbarTitleAlpha(1f) - } - } - recyclerViewUpdatesToolbarTitleAlpha(type.isEnter) - } - - override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeEnded(handler, type) - if (manga == null || source == null) { - activity?.toast(R.string.manga_not_in_db) - router.popController(this) + val actionBar = (activity as? AppCompatActivity)?.supportActionBar + if (type.isEnter) { + actionBar?.hide() + } else { + actionBar?.show() } } override fun createPresenter(): MangaPresenter { return MangaPresenter( - manga!!, - source!!, + mangaId = mangaId, + isFromSource = args.getBoolean(FROM_SOURCE_EXTRA, false), ) } - override fun createBinding(inflater: LayoutInflater) = MangaControllerBinding.inflate(inflater) - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - listOfNotNull(binding.fullRecycler, binding.infoRecycler, binding.chaptersRecycler) - .forEach { - it.applyInsetter { - type(navigationBars = true) { - padding() - } - } - - it.layoutManager = LinearLayoutManager(view.context) - it.setHasFixedSize(true) - } - - if (manga == null || source == null) return - - // Init RecyclerView and adapter - mangaInfoAdapter = MangaInfoHeaderAdapter(this, fromSource, binding.infoRecycler != null).apply { - setHasStableIds(true) - } - chaptersHeaderAdapter = MangaChaptersHeaderAdapter(this).apply { - setHasStableIds(true) - } - chaptersAdapter = ChaptersAdapter(this, view.context) - - // Phone layout - binding.fullRecycler?.let { - val config = ConcatAdapter.Config.Builder() - .setIsolateViewTypes(true) - .setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS) - .build() - it.adapter = ConcatAdapter(config, mangaInfoAdapter, chaptersHeaderAdapter, chaptersAdapter) - - it.scrollStateChanges() - .onEach { _ -> - // Disable swipe refresh when view is not at the top - val firstPos = (it.layoutManager as LinearLayoutManager) - .findFirstCompletelyVisibleItemPosition() - binding.swipeRefresh.isEnabled = firstPos <= 0 - } - .launchIn(viewScope) - - binding.fastScroller.doOnLayout { scroller -> - scroller.updateLayoutParams { - topMargin += getMainAppBarHeight() - } - } - - ViewCompat.setOnApplyWindowInsetsListener(binding.swipeRefresh) { swipeRefresh, windowInsets -> - swipeRefresh as SwipeRefreshLayout - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) - swipeRefresh.isRefreshing = false - swipeRefresh.setProgressViewEndTarget(false, getMainAppBarHeight() + insets.top) - updateRefreshing() - windowInsets + @Composable + override fun ComposeContent() { + val state by presenter.state.collectAsState() + if (state is MangaScreenState.Success) { + val successState = state as MangaScreenState.Success + val isHttpSource = remember { successState.source is HttpSource } + MangaScreen( + state = successState, + snackbarHostState = snackbarHostState, + windowWidthSizeClass = calculateWindowWidthSizeClass(), + onBackClicked = router::popCurrentController, + onChapterClicked = this::openChapter, + onDownloadChapter = this::onDownloadChapters.takeIf { !successState.source.isLocalOrStub() }, + onAddToLibraryClicked = this::onFavoriteClick, + onWebViewClicked = this::openMangaInWebView.takeIf { isHttpSource }, + onTrackingClicked = trackSheet::show.takeIf { successState.trackingAvailable }, + onTagClicked = this::performGenreSearch, + onFilterButtonClicked = settingsSheet::show, + onRefresh = presenter::fetchAllFromSource, + onContinueReading = this::continueReading, + onSearch = this::performSearch, + onCoverClicked = this::openCoverDialog, + onShareClicked = this::shareManga.takeIf { isHttpSource }, + onDownloadActionClicked = this::runDownloadChapterAction.takeIf { !successState.source.isLocalOrStub() }, + onEditCategoryClicked = this::onCategoriesClick.takeIf { successState.manga.favorite }, + onMigrateClicked = this::migrateManga.takeIf { successState.manga.favorite }, + onMultiBookmarkClicked = presenter::bookmarkChapters, + onMultiMarkAsReadClicked = presenter::markChaptersRead, + onMarkPreviousAsReadClicked = presenter::markPreviousChapterRead, + onMultiDeleteClicked = this::deleteChaptersWithConfirmation, + ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } } + } - // Tablet layout - binding.infoRecycler?.adapter = mangaInfoAdapter - binding.chaptersRecycler?.adapter = ConcatAdapter(chaptersHeaderAdapter, chaptersAdapter) - - chaptersAdapter?.fastScroller = binding.fastScroller - - actionFabScrollListener = actionFab?.shrinkOnScroll(chapterRecycler) - // Initially set FAB invisible; will become visible if unread chapters are present - actionFab?.hide() - - binding.swipeRefresh.refreshes() - .onEach { - fetchMangaInfoFromSource(manualFetch = true) - fetchChaptersFromSource(manualFetch = true) - } - .launchIn(viewScope) + // Let compose view handle this + override fun handleBack(): Boolean { + (activity as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher?.onBackPressed() + return true + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedViewState: Bundle?): View { settingsSheet = ChaptersSettingsSheet(router, presenter) - trackSheet = TrackSheet(this, (activity as MainActivity).supportFragmentManager) - - updateFilterIconState() - recyclerViewUpdatesToolbarTitleAlpha(true) - } - - private fun recyclerViewUpdatesToolbarTitleAlpha(enable: Boolean) { - val recycler = binding.fullRecycler ?: binding.infoRecycler ?: return - if (enable) { - if (!recyclerViewToolbarTitleAlphaUpdaterAdded) { - recycler.addOnScrollListener(recyclerViewToolbarTitleAlphaUpdater) - recyclerViewToolbarTitleAlphaUpdaterAdded = true - } - } else if (recyclerViewToolbarTitleAlphaUpdaterAdded) { - recycler.removeOnScrollListener(recyclerViewToolbarTitleAlphaUpdater) - recyclerViewToolbarTitleAlphaUpdaterAdded = false - } - } - - private fun updateToolbarTitleAlpha(@FloatRange(from = 0.0, to = 1.0) alpha: Float? = null) { - // Controller may actually already be destroyed by the time this gets run - if (!isAttached) return - - val scrolledList = binding.fullRecycler ?: binding.infoRecycler!! - (activity as? MainActivity)?.binding?.appbar?.titleTextAlpha = when { - // Specific alpha provided - alpha != null -> alpha - - // First item isn't in view, full opacity - ((scrolledList.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0) -> 1F - - // Based on scroll amount when first item is in view - else -> min(scrolledList.computeVerticalScrollOffset(), 255) / 255F - } - } - - private fun updateFilterIconState() { - chaptersHeaderAdapter?.setHasActiveFilters(settingsSheet?.filters?.hasActiveFilters() == true) - } - - override fun configureFab(fab: ExtendedFloatingActionButton) { - actionFab = fab - fab.setText(R.string.action_start) - fab.setIconResource(R.drawable.ic_play_arrow_24dp) - fab.setOnClickListener { - val item = presenter.getNextUnreadChapter() - if (item != null) { - openChapter(item.chapter, it) - } - } - } - - override fun cleanupFab(fab: ExtendedFloatingActionButton) { - fab.setOnClickListener(null) - actionFabScrollListener?.let { chapterRecycler.removeOnScrollListener(it) } - actionFab = null - } - - private fun updateFabVisibility() { - val context = view?.context ?: return - val adapter = chaptersAdapter ?: return - val fab = actionFab ?: return - if (adapter.items.any { it.read }) { - fab.text = context.getString(R.string.action_resume) - } else { - fab.text = context.getString(R.string.action_start) - } - if (adapter.items.any { !it.read }) { - fab.show() - } else { - fab.hide() - } - } - - override fun onDestroyView(view: View) { - recyclerViewUpdatesToolbarTitleAlpha(false) - destroyActionModeIfNeeded() - mangaInfoAdapter = null - chaptersHeaderAdapter = null - chaptersAdapter = null - settingsSheet = null - addSnackbar?.dismiss() - super.onDestroyView(view) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.manga, menu) - } - - override fun onPrepareOptionsMenu(menu: Menu) { - runBlocking { - // Hide options for local manga - menu.findItem(R.id.action_share).isVisible = !isLocalSource - menu.findItem(R.id.download_group).isVisible = !isLocalSource - - // Hide options for non-library manga - menu.findItem(R.id.action_edit_categories).isVisible = - presenter.manga.favorite && presenter.getCategories().isNotEmpty() - menu.findItem(R.id.action_migrate).isVisible = presenter.manga.favorite - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_share -> shareManga() - R.id.download_next, R.id.download_next_5, R.id.download_next_10, - R.id.download_custom, R.id.download_unread, R.id.download_all, - -> downloadChapters(item.itemId) - - R.id.action_edit_categories -> onCategoriesClick() - R.id.action_migrate -> migrateManga() - } - return super.onOptionsItemSelected(item) - } - - private fun updateRefreshing() { - binding.swipeRefresh.isRefreshing = isRefreshingInfo || isRefreshingChapters + return super.onCreateView(inflater, container, savedViewState) } // Manga info - start - /** - * Check if manga is initialized. - * If true update header with manga information, - * if false fetch manga information - * - * @param manga manga object containing information about manga. - * @param source the source of the manga. - */ - fun onNextMangaInfo(manga: Manga, source: Source) { - if (manga.initialized) { - // Update view. - mangaInfoAdapter?.update(manga, source) - } else { - // Initialize manga. - fetchMangaInfoFromSource() - } - } - - /** - * Start fetching manga information from source. - */ - private fun fetchMangaInfoFromSource(manualFetch: Boolean = false) { - isRefreshingInfo = true - updateRefreshing() - - // Call presenter and start fetching manga information - presenter.fetchMangaFromSource(manualFetch) - } - - fun onFetchMangaInfoDone() { - isRefreshingInfo = false - updateRefreshing() - } - fun onFetchMangaInfoError(error: Throwable) { - isRefreshingInfo = false - updateRefreshing() - // Ignore early hints "errors" that aren't handled by OkHttp if (error is HttpException && error.code == 103) { return } - activity?.toast(error.message) } - fun onTrackingCount(trackCount: Int) { - mangaInfoAdapter?.setTrackingCount(trackCount) - } - - fun openMangaInWebView() { + private fun openMangaInWebView() { + val manga = presenter.manga ?: return val source = presenter.source as? HttpSource ?: return val url = try { - source.mangaDetailsRequest(presenter.manga).url.toString() + source.mangaDetailsRequest(manga.toDbManga()).url.toString() } catch (e: Exception) { return } val activity = activity ?: return - val intent = WebViewActivity.newIntent(activity, url, source.id, presenter.manga.title) + val intent = WebViewActivity.newIntent(activity, url, source.id, manga.title) startActivity(intent) } fun shareManga() { val context = view?.context ?: return - + val manga = presenter.manga ?: return val source = presenter.source as? HttpSource ?: return try { - val url = source.mangaDetailsRequest(presenter.manga).url.toString() + val url = source.mangaDetailsRequest(manga.toDbManga()).url.toString() val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, url) @@ -494,139 +201,66 @@ class MangaController : } } - fun onFavoriteClick() { - val manga = presenter.manga - - if (manga.favorite) { - toggleFavorite() - activity?.toast(activity?.getString(R.string.manga_removed_library)) - activity?.invalidateOptionsMenu() - } else { - launchIO { - val duplicateManga = presenter.getDuplicateLibraryManga(manga) - - withUIContext { - if (duplicateManga != null) { - AddDuplicateMangaDialog(this@MangaController, duplicateManga) { addToLibrary(manga) } - .showDialog(router) - } else { - addToLibrary(manga) - } + private fun onFavoriteClick(checkDuplicate: Boolean = true) { + presenter.toggleFavorite( + onRemoved = this::onFavoriteRemoved, + onAdded = { activity?.toast(activity?.getString(R.string.manga_added_library)) }, + onDuplicateExists = if (checkDuplicate) { + { + AddDuplicateMangaDialog( + target = this, + libraryManga = it.toDbManga(), + onAddToLibrary = { onFavoriteClick(checkDuplicate = false) }, + ).showDialog(router) } + } else null, + onRequireCategory = { manga, categories -> + val ids = presenter.getMangaCategoryIds(manga) + val preselected = categories.map { + if (it.id in ids) { + QuadStateTextView.State.CHECKED.ordinal + } else { + QuadStateTextView.State.UNCHECKED.ordinal + } + }.toTypedArray() + showChangeCategoryDialog(manga.toDbManga(), categories, preselected) + }, + ) + } + + private fun onFavoriteRemoved() { + val context = activity ?: return + context.toast(activity?.getString(R.string.manga_removed_library)) + viewScope.launch { + if (!presenter.hasDownloads()) return@launch + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.delete_downloads_for_manga), + actionLabel = context.getString(R.string.action_delete), + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed) { + presenter.deleteDownloads() } } } fun onTrackingClick() { - trackSheet?.show() + trackSheet.show() } - private fun addToLibrary(newManga: Manga) { - launchIO { - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() } + private fun onCategoriesClick() { + val manga = presenter.manga ?: return + val categories = presenter.getCategories() - withUIContext { - when { - // Default category set - defaultCategory != null -> { - toggleFavorite() - presenter.moveMangaToCategory(newManga, defaultCategory.toDbCategory()) - activity?.toast(activity?.getString(R.string.manga_added_library)) - activity?.invalidateOptionsMenu() - } - - // Automatic 'Default' or no categories - defaultCategoryId == 0 || categories.isEmpty() -> { - toggleFavorite() - presenter.moveMangaToCategory(newManga, null) - activity?.toast(activity?.getString(R.string.manga_added_library)) - activity?.invalidateOptionsMenu() - } - - // Choose a category - else -> { - val ids = presenter.getMangaCategoryIds(newManga) - val preselected = categories.map { - if (it.id in ids) { - QuadStateTextView.State.CHECKED.ordinal - } else { - QuadStateTextView.State.UNCHECKED.ordinal - } - }.toTypedArray() - - showChangeCategoryDialog( - newManga, - categories.map { it.toDbCategory() }, - preselected, - ) - } - } + val ids = presenter.getMangaCategoryIds(manga) + val preselected = categories.map { + if (it.id in ids) { + QuadStateTextView.State.CHECKED.ordinal + } else { + QuadStateTextView.State.UNCHECKED.ordinal } - } - - if (source != null) { - presenter.trackList - .map { it.service } - .filterIsInstance() - .filter { it.accept(source!!) } - .forEach { service -> - launchIO { - try { - service.match(newManga)?.let { track -> - presenter.registerTracking(track, service as TrackService) - } - } catch (e: Exception) { - logcat(LogPriority.WARN, e) { - "Could not match manga: ${newManga.title} with service $service" - } - } - } - } - } - } - - /** - * Toggles the favorite status and asks for confirmation to delete downloaded chapters. - */ - private fun toggleFavorite() { - val isNowFavorite = presenter.toggleFavorite() - if (isNowFavorite) { - addSnackbar?.dismiss() - } - if (activity != null && !isNowFavorite && presenter.hasDownloads()) { - (activity as? MainActivity)?.binding?.rootCoordinator?.snack(activity!!.getString(R.string.delete_downloads_for_manga)) { - setAction(R.string.action_delete) { - presenter.deleteDownloads() - } - } - } - mangaInfoAdapter?.update() - } - - fun onCategoriesClick() { - launchIO { - val manga = presenter.manga - val categories = presenter.getCategories() - - if (categories.isEmpty()) { - return@launchIO - } - - val ids = presenter.getMangaCategoryIds(manga) - val preselected = categories.map { - if (it.id in ids) { - QuadStateTextView.State.CHECKED.ordinal - } else { - QuadStateTextView.State.UNCHECKED.ordinal - } - }.toTypedArray() - - withUIContext { - showChangeCategoryDialog(manga, categories.map { it.toDbCategory() }, preselected) - } - } + }.toTypedArray() + showChangeCategoryDialog(manga.toDbManga(), categories, preselected) } private fun showChangeCategoryDialog(manga: Manga, categories: List, preselected: Array) { @@ -634,25 +268,13 @@ class MangaController : .showDialog(router) } - override fun updateCategoriesForMangas(mangas: List, addCategories: List, removeCategories: List) { - val manga = mangas.firstOrNull() ?: return - - if (!manga.favorite) { - toggleFavorite() - activity?.toast(activity?.getString(R.string.manga_added_library)) - activity?.invalidateOptionsMenu() - } - - presenter.moveMangaToCategories(manga, addCategories) - } - - /** - * Perform a global search using the provided query. - * - * @param query the search query to pass to the search controller - */ - fun performGlobalSearch(query: String) { - router.pushController(GlobalSearchController(query)) + override fun updateCategoriesForMangas( + mangas: List, + addCategories: List, + removeCategories: List, + ) { + val changed = mangas.firstOrNull() ?: return + presenter.moveMangaToCategoriesAndAddToLibrary(changed, addCategories) } /** @@ -660,7 +282,12 @@ class MangaController : * * @param query the search query to the parent controller */ - fun performSearch(query: String) { + private fun performSearch(query: String, global: Boolean) { + if (global) { + router.pushController(GlobalSearchController(query)) + return + } + if (router.backstackSize < 2) { return } @@ -694,7 +321,7 @@ class MangaController : * * @param genreName the search genre to the parent controller */ - fun performGenreSearch(genreName: String) { + private fun performGenreSearch(genreName: String) { if (router.backstackSize < 2) { return } @@ -708,12 +335,12 @@ class MangaController : router.handleBack() previousController.searchWithGenre(genreName) } else { - performSearch(genreName) + performSearch(genreName, global = false) } } - fun showFullCoverDialog() { - val mangaId = manga?.id ?: return + private fun openCoverDialog() { + val mangaId = presenter.manga?.id ?: return router.pushController(MangaFullCoverDialog(mangaId).withFadeTransaction()) } @@ -721,7 +348,8 @@ class MangaController : * Initiates source migration for the specific manga. */ private fun migrateManga() { - val controller = SearchController(presenter.manga) + val manga = presenter.manga ?: return + val controller = SearchController(manga.toDbManga()) controller.targetController = this router.pushController(controller) } @@ -730,50 +358,18 @@ class MangaController : // Chapters list - start - fun onNextChapters(chapters: List) { - // If the list is empty and it hasn't requested previously, fetch chapters from source - // We use presenter chapters instead because they are always unfiltered - if (!presenter.hasRequested && presenter.allChapters.isEmpty()) { - fetchChaptersFromSource() - } - - val chaptersHeader = chaptersHeaderAdapter ?: return - chaptersHeader.setNumChapters(chapters.size) - - val adapter = chaptersAdapter ?: return - adapter.updateDataSet(chapters) - - if (selectedChapters.isNotEmpty()) { - adapter.clearSelection() // we need to start from a clean state, index may have changed - createActionModeIfNeeded() - selectedChapters.forEach { item -> - val position = adapter.indexOf(item) - if (position != -1 && !adapter.isSelected(position)) { - adapter.toggleSelection(position) - } - } - actionMode?.invalidate() - } - - updateFabVisibility() - updateFilterIconState() + private fun continueReading() { + val chapter = presenter.getNextUnreadChapter() + if (chapter != null) openChapter(chapter) } - private fun fetchChaptersFromSource(manualFetch: Boolean = false) { - isRefreshingChapters = true - updateRefreshing() - - presenter.fetchChaptersFromSource(manualFetch) - } - - fun onFetchChaptersDone() { - isRefreshingChapters = false - updateRefreshing() + private fun openChapter(chapter: DomainChapter) { + activity?.run { + startActivity(ReaderActivity.newIntent(this, chapter.mangaId, chapter.id)) + } } fun onFetchChaptersError(error: Throwable) { - isRefreshingChapters = false - updateRefreshing() if (error is NoChaptersException) { activity?.toast(activity?.getString(R.string.no_chapters_error)) } else { @@ -781,335 +377,97 @@ class MangaController : } } - fun onChapterDownloadUpdate(download: Download) { - chaptersAdapter?.currentItems?.find { it.id == download.chapter.id }?.let { - chaptersAdapter?.updateItem(it, it.status) - } - } - - private fun openChapter(chapter: Chapter, sharedElement: View? = null) { - val activity = activity ?: return - activity.apply { - val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter) - if (sharedElement != null) { - val activityOptions = ActivityOptions.makeSceneTransitionAnimation( - activity, - sharedElement, - ReaderActivity.SHARED_ELEMENT_NAME, - ) - startActivity( - intent.apply { - putExtra(ReaderActivity.EXTRA_IS_TRANSITION, true) - }, - activityOptions.toBundle(), - ) - } else { - startActivity(intent) - } - } - } - - override fun onItemClick(view: View?, position: Int): Boolean { - val adapter = chaptersAdapter ?: return false - val item = adapter.getItem(position) ?: return false - return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { - if (adapter.isSelected(position)) { - lastClickPositionStack.remove(position) // possible that it's not there, but no harm - } else { - lastClickPositionStack.push(position) - } - - toggleSelection(position) - true - } else { - openChapter(item.chapter) - false - } - } - - override fun onItemLongClick(position: Int) { - createActionModeIfNeeded() - val lastClickPosition = lastClickPositionStack.peek()!! - when { - lastClickPosition == -1 -> setSelection(position) - lastClickPosition > position -> { - for (i in position until lastClickPosition) setSelection(i) - chaptersAdapter?.notifyItemRangeChanged(position, lastClickPosition, position) - } - lastClickPosition < position -> { - for (i in lastClickPosition + 1..position) setSelection(i) - chaptersAdapter?.notifyItemRangeChanged(lastClickPosition + 1, position, position) - } - else -> setSelection(position) - } - if (lastClickPosition != position) { - lastClickPositionStack.remove(position) // move to top if already exists - lastClickPositionStack.push(position) - } - } - - fun showSettingsSheet() { - settingsSheet?.show() - } - - // SELECTIONS & ACTION MODE - - private fun toggleSelection(position: Int) { - val adapter = chaptersAdapter ?: return - val item = adapter.getItem(position) ?: return - adapter.toggleSelection(position) - if (adapter.isSelected(position)) { - selectedChapters.add(item) - } else { - selectedChapters.remove(item) - } - actionMode?.invalidate() - } - - private fun setSelection(position: Int) { - val adapter = chaptersAdapter ?: return - val item = adapter.getItem(position) ?: return - if (!adapter.isSelected(position)) { - adapter.toggleSelection(position) - selectedChapters.add(item) - actionMode?.invalidate() - } - } - - private fun getSelectedChapters(): List { - val adapter = chaptersAdapter ?: return emptyList() - return adapter.selectedPositions.mapNotNull { adapter.getItem(it) } - } - - private fun createActionModeIfNeeded() { - if (actionMode == null) { - actionMode = (activity as MainActivity).startActionModeAndToolbar(this) - } - } - - private fun destroyActionModeIfNeeded() { - lastClickPositionStack.clear() - lastClickPositionStack.push(-1) - actionMode?.finish() - } - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.generic_selection, menu) - chaptersAdapter?.mode = SelectableAdapter.Mode.MULTI - return true - } - - override fun onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) { - menuInflater.inflate(R.menu.chapter_selection, menu) - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val count = chaptersAdapter?.selectedItemCount ?: 0 - if (count == 0) { - // Destroy action mode if there are no items selected. - destroyActionModeIfNeeded() - } else { - mode.title = count.toString() - - // Hide FAB to avoid interfering with the bottom action toolbar - actionFab?.hide() - } - return true - } - - override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) { - val chapters = getSelectedChapters() - if (chapters.isEmpty()) return - toolbar.findToolbarItem(R.id.action_download)?.isVisible = !isLocalSource && chapters.any { !it.isDownloaded } - toolbar.findToolbarItem(R.id.action_delete)?.isVisible = !isLocalSource && chapters.any { it.isDownloaded } - toolbar.findToolbarItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark } - toolbar.findToolbarItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark } - toolbar.findToolbarItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read } - toolbar.findToolbarItem(R.id.action_mark_as_unread)?.isVisible = chapters.any { it.chapter.read } - toolbar.findToolbarItem(R.id.action_mark_previous_as_read)?.isVisible = chapters.size == 1 - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_select_all -> selectAll() - R.id.action_select_inverse -> selectInverse() - R.id.action_download -> downloadChapters(getSelectedChapters()) - R.id.action_delete -> showDeleteChaptersConfirmationDialog() - R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true) - R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false) - R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) - R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) - R.id.action_mark_previous_as_read -> markPreviousAsRead(getSelectedChapters()) - else -> return false - } - return true - } - - override fun onDestroyActionMode(mode: ActionMode) { - chaptersAdapter?.mode = SelectableAdapter.Mode.SINGLE - chaptersAdapter?.clearSelection() - selectedChapters.clear() - actionMode = null - } - - override fun onDestroyActionToolbar() { - updateFabVisibility() - } - - override fun onDetach(view: View) { - destroyActionModeIfNeeded() - super.onDetach(view) - } - - override fun downloadChapter(position: Int) { - val item = chaptersAdapter?.getItem(position) ?: return - if (item.status == Download.State.ERROR) { - DownloadService.start(activity!!) - } else { - downloadChapters(listOf(item)) - } - chaptersAdapter?.updateItem(item) - } - - override fun deleteChapter(position: Int) { - val item = chaptersAdapter?.getItem(position) ?: return - deleteChapters(listOf(item)) - chaptersAdapter?.updateItem(item) - } - // SELECTION MODE ACTIONS - private fun selectAll() { - val adapter = chaptersAdapter ?: return - adapter.selectAll() - selectedChapters.addAll(adapter.items) - actionMode?.invalidate() - } - - private fun selectInverse() { - val adapter = chaptersAdapter ?: return - - selectedChapters.clear() - for (i in 0..adapter.itemCount) { - adapter.toggleSelection(i) - adapter.notifyItemChanged(i, i) - } - selectedChapters.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) }) - - actionMode?.invalidate() - } - - private fun markAsRead(chapters: List) { - presenter.markChaptersRead(chapters, true) - destroyActionModeIfNeeded() - } - - private fun markAsUnread(chapters: List) { - presenter.markChaptersRead(chapters, false) - destroyActionModeIfNeeded() - } - - private fun downloadChapters(chapters: List) { - if (source is SourceManager.StubSource) { - activity?.let { - it.toast(it.getString(R.string.source_not_installed, source?.toString().orEmpty())) - } - return - } - - val view = view - val manga = presenter.manga - presenter.downloadChapters(chapters) - if (view != null && !manga.favorite) { - addSnackbar = (activity as? MainActivity)?.binding?.rootCoordinator?.snack(view.context.getString(R.string.snack_add_to_library)) { - setAction(R.string.action_add) { - if (!manga.favorite) { - addToLibrary(manga) + private fun onDownloadChapters( + items: List, + action: ChapterDownloadAction, + ) { + viewScope.launch { + when (action) { + ChapterDownloadAction.START -> { + downloadChapters(items.map { it.chapter }) + if (items.any { it.downloadState == Download.State.ERROR }) { + DownloadService.start(activity!!) } } + ChapterDownloadAction.START_NOW -> { + val chapterId = items.singleOrNull()?.chapter?.id ?: return@launch + presenter.startDownloadingNow(chapterId) + } + ChapterDownloadAction.CANCEL -> { + val chapterId = items.singleOrNull()?.chapter?.id ?: return@launch + presenter.cancelDownload(chapterId) + } + ChapterDownloadAction.DELETE -> { + deleteChapters(items.map { it.chapter }) + } } } - destroyActionModeIfNeeded() } - private fun showDeleteChaptersConfirmationDialog() { - DeleteChaptersDialog(this).showDialog(router) - } + private suspend fun downloadChapters(chapters: List) { + presenter.downloadChapters(chapters) - override fun deleteChapters() { - deleteChapters(getSelectedChapters()) - } - - private fun markPreviousAsRead(chapters: List) { - val adapter = chaptersAdapter ?: return - val prevChapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items - val chapterPos = prevChapters.indexOf(chapters.lastOrNull()) - if (chapterPos != -1) { - markAsRead(prevChapters.take(chapterPos)) + if (!presenter.isFavoritedManga) { + val result = snackbarHostState.showSnackbar( + message = activity!!.getString(R.string.snack_add_to_library), + actionLabel = activity!!.getString(R.string.action_add), + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed && !presenter.isFavoritedManga) { + onFavoriteClick() + } } - destroyActionModeIfNeeded() } - private fun bookmarkChapters(chapters: List, bookmarked: Boolean) { - presenter.bookmarkChapters(chapters, bookmarked) - destroyActionModeIfNeeded() + private fun deleteChaptersWithConfirmation(chapters: List) { + viewScope.launch { + val result = MaterialAlertDialogBuilder(activity!!) + .setMessage(R.string.confirm_delete_chapters) + .await(android.R.string.ok, android.R.string.cancel) + if (result == AlertDialog.BUTTON_POSITIVE) deleteChapters(chapters) + } } - fun deleteChapters(chapters: List) { + fun deleteChapters(chapters: List) { if (chapters.isEmpty()) return - presenter.deleteChapters(chapters) - destroyActionModeIfNeeded() - } - - fun onChaptersDeleted(chapters: List) { - // this is needed so the downloaded text gets removed from the item - chapters.forEach { - chaptersAdapter?.updateItem(it, it) - } - } - - fun onChaptersDeletedError(error: Throwable) { - logcat(LogPriority.ERROR, error) - } - - override fun startDownloadNow(position: Int) { - val chapter = chaptersAdapter?.getItem(position) ?: return - presenter.startDownloadingNow(chapter) } // OVERFLOW MENU DIALOGS - private fun downloadChapters(choice: Int) { - val chaptersToDownload = when (choice) { - R.id.download_next -> presenter.getUnreadChaptersSorted().take(1) - R.id.download_next_5 -> presenter.getUnreadChaptersSorted().take(5) - R.id.download_next_10 -> presenter.getUnreadChaptersSorted().take(10) - R.id.download_custom -> { + private fun runDownloadChapterAction(action: DownloadAction) { + val chaptersToDownload = when (action) { + DownloadAction.NEXT_1_CHAPTER -> presenter.getUnreadChaptersSorted().take(1) + DownloadAction.NEXT_5_CHAPTERS -> presenter.getUnreadChaptersSorted().take(5) + DownloadAction.NEXT_10_CHAPTERS -> presenter.getUnreadChaptersSorted().take(10) + DownloadAction.CUSTOM -> { showCustomDownloadDialog() return } - R.id.download_unread -> presenter.allChapters.filter { !it.read } - R.id.download_all -> presenter.allChapters - else -> emptyList() + DownloadAction.UNREAD_CHAPTERS -> presenter.getUnreadChapters() + DownloadAction.ALL_CHAPTERS -> { + (presenter.state.value as? MangaScreenState.Success)?.chapters?.map { it.chapter } + } } - if (chaptersToDownload.isNotEmpty()) { - downloadChapters(chaptersToDownload) + if (!chaptersToDownload.isNullOrEmpty()) { + viewScope.launch { downloadChapters(chaptersToDownload) } } - destroyActionModeIfNeeded() } private fun showCustomDownloadDialog() { + val availableChapters = presenter.processedChapters?.count() ?: return DownloadCustomChaptersDialog( this, - presenter.allChapters.size, + availableChapters, ).showDialog(router) } override fun downloadCustomChapters(amount: Int) { val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount) if (chaptersToDownload.isNotEmpty()) { - downloadChapters(chaptersToDownload) + viewScope.launch { downloadChapters(chaptersToDownload) } } } @@ -1117,7 +475,7 @@ class MangaController : // Tracker sheet - start fun onNextTrackers(trackers: List) { - trackSheet?.onNextTrackers(trackers) + trackSheet.onNextTrackers(trackers) } fun onTrackingRefreshDone() { @@ -1138,21 +496,13 @@ class MangaController : } private fun getTrackingSearchDialog(): TrackSearchDialog? { - return trackSheet?.getSearchDialog() + return trackSheet.getSearchDialog() } // Tracker sheet - end - private val chapterRecycler: RecyclerView - get() = binding.fullRecycler ?: binding.chaptersRecycler!! - companion object { const val FROM_SOURCE_EXTRA = "from_source" const val MANGA_EXTRA = "manga" - - /** - * Key to change the cover of a manga in [onActivityResult]. - */ - const val REQUEST_IMAGE_OPEN = 101 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt index f819d87c98..29504470d7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt @@ -1,41 +1,45 @@ package eu.kanade.tachiyomi.ui.manga import android.os.Bundle -import com.jakewharton.rxrelay.PublishRelay -import eu.kanade.domain.category.interactor.GetCategories +import androidx.compose.runtime.Immutable +import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource +import eu.kanade.domain.chapter.interactor.UpdateChapter +import eu.kanade.domain.chapter.model.ChapterUpdate import eu.kanade.domain.chapter.model.toDbChapter import eu.kanade.domain.manga.interactor.GetDuplicateLibraryManga import eu.kanade.domain.manga.interactor.GetMangaWithChapters +import eu.kanade.domain.manga.interactor.SetMangaChapterFlags import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.TriStateFilter +import eu.kanade.domain.manga.model.isLocal import eu.kanade.domain.manga.model.toDbManga import eu.kanade.domain.manga.model.toMangaInfo -import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.models.toDomainManga -import eu.kanade.tachiyomi.data.database.models.toMangaInfo +import eu.kanade.tachiyomi.data.database.models.toDomainChapter import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.saver.ImageSaver import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.toSChapter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem import eu.kanade.tachiyomi.ui.manga.track.TrackItem import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper import eu.kanade.tachiyomi.util.chapter.getChapterSort -import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay -import eu.kanade.tachiyomi.util.isLocal import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.preference.asImmediateFlow import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.system.logcat @@ -44,8 +48,10 @@ import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.Stat import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.supervisorScope import logcat.LogPriority import rx.Observable @@ -54,44 +60,38 @@ import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.Date -import eu.kanade.domain.category.model.Category as DomainCategory +import uy.kohesive.injekt.injectLazy +import java.text.DateFormat +import eu.kanade.domain.chapter.model.Chapter as DomainChapter +import eu.kanade.domain.manga.model.Manga as DomainManga class MangaPresenter( - val manga: Manga, - val source: Source, - val preferences: PreferencesHelper = Injekt.get(), + val mangaId: Long, + val isFromSource: Boolean, + private val preferences: PreferencesHelper = Injekt.get(), private val db: DatabaseHelper = Injekt.get(), private val trackManager: TrackManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), - private val coverCache: CoverCache = Injekt.get(), - private val getMangaWithChapters: GetMangaWithChapters = Injekt.get(), + private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(), private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(), - private val getCategories: GetCategories = Injekt.get(), + private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(), + private val updateChapter: UpdateChapter = Injekt.get(), private val updateManga: UpdateManga = Injekt.get(), + private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), ) : BasePresenter() { + private val _state: MutableStateFlow = MutableStateFlow(MangaScreenState.Loading) + + val state = _state.asStateFlow() + + private val successState: MangaScreenState.Success? + get() = state.value as? MangaScreenState.Success + /** * Subscription to update the manga from the source. */ private var fetchMangaJob: Job? = null - var allChapters: List = emptyList() - private set - var filteredAndSortedChapters: List = emptyList() - private set - - /** - * Subject of list of chapters to allow updating the view without going to DB. - */ - private val chaptersRelay by lazy { PublishRelay.create>() } - - /** - * Whether the chapter list has been requested to the source. - */ - var hasRequested = false - private set - /** * Subscription to retrieve the new list of chapters from the source. */ @@ -108,72 +108,94 @@ class MangaPresenter( private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } + private val imageSaver: ImageSaver by injectLazy() + private var trackSubscription: Subscription? = null private var searchTrackerJob: Job? = null private var refreshTrackersJob: Job? = null + val manga: DomainManga? + get() = successState?.manga + + val source: Source? + get() = successState?.source + + val isFavoritedManga: Boolean + get() = manga?.favorite ?: false + + val processedChapters: Sequence? + get() = successState?.processedChapters + override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - if (!manga.favorite) { - ChapterSettingsHelper.applySettingDefaults(manga) - } - // Manga info - start - getMangaObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, manga -> view.onNextMangaInfo(manga, source) }) - getTrackingObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaController::onTrackingCount) { _, error -> - logcat(LogPriority.ERROR, error) - } - - // Prepare the relay. - chaptersRelay.flatMap { applyChapterFilters(it) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache( - { _, chapters -> - filteredAndSortedChapters = chapters - view?.onNextChapters(chapters) - }, - { _, error -> logcat(LogPriority.ERROR, error) }, - ) - - // Manga info - end - - // Chapters list - start - - // Keeps subscribed to changes and sends the list of chapters to the relay. presenterScope.launchIO { - manga.id?.let { mangaId -> - getMangaWithChapters.subscribe(mangaId) - .collectLatest { (_, chapters) -> - val chapterItems = chapters.map { it.toDbChapter().toModel() } - setDownloadedChapters(chapterItems) - this@MangaPresenter.allChapters = chapterItems - observeDownloads() - chaptersRelay.call(chapterItems) - } + if (!getMangaAndChapters.awaitManga(mangaId).favorite) { + ChapterSettingsHelper.applySettingDefaults(mangaId, setMangaChapterFlags) } + + getMangaAndChapters.subscribe(mangaId) + .collectLatest { (manga, chapters) -> + val chapterItems = chapters.toChapterItems(manga) + val currentState = _state.value + _state.value = when (currentState) { + // Initialize success state + MangaScreenState.Loading -> MangaScreenState.Success( + manga = manga, + source = Injekt.get().getOrStub(manga.source), + dateRelativeTime = preferences.relativeTime().get(), + dateFormat = preferences.dateFormat(), + isFromSource = isFromSource, + trackingAvailable = trackManager.hasLoggedServices(), + chapters = chapterItems, + ).also { + getTrackingObservable(manga) + .subscribeLatestCache( + { _, count -> successState?.let { _state.value = it.copy(trackingCount = count) } }, + { _, error -> logcat(LogPriority.ERROR, error) }, + ) + } + + // Update state + is MangaScreenState.Success -> currentState.copy(manga = manga, chapters = chapterItems) + } + + fetchTrackers() + observeDownloads() + + if (!manga.initialized) { + fetchAllFromSource(manualFetch = false) + } + } } - // Chapters list - end + preferences.incognitoMode() + .asImmediateFlow { incognito -> + successState?.let { + _state.value = it.copy(isIncognitoMode = incognito) + } + } + .launchIn(presenterScope) - fetchTrackers() + preferences.downloadedOnly() + .asImmediateFlow { downloadedOnly -> + successState?.let { + _state.value = it.copy(isDownloadedOnlyMode = downloadedOnly) + } + } + .launchIn(presenterScope) } - suspend fun getDuplicateLibraryManga(manga: Manga): Manga? { - return getDuplicateLibraryManga.await(manga.title, manga.source)?.toDbManga() + fun fetchAllFromSource(manualFetch: Boolean = true) { + fetchMangaFromSource(manualFetch) + fetchChaptersFromSource(manualFetch) } // Manga info - start - private fun getMangaObservable(): Observable { - return db.getManga(manga.url, manga.source).asRxObservable() - } - private fun getTrackingObservable(): Observable { + private fun getTrackingObservable(manga: DomainManga): Observable { if (!trackManager.hasLoggedServices()) { return Observable.just(0) } @@ -189,53 +211,116 @@ class MangaPresenter( /** * Fetch manga information from source. */ - fun fetchMangaFromSource(manualFetch: Boolean = false) { + private fun fetchMangaFromSource(manualFetch: Boolean = false) { if (fetchMangaJob?.isActive == true) return + val successState = successState ?: return fetchMangaJob = presenterScope.launchIO { + _state.value = successState.copy(isRefreshingInfo = true) try { - manga.toDomainManga()?.let { domainManga -> - val networkManga = source.getMangaDetails(domainManga.toMangaInfo()) - - updateManga.awaitUpdateFromSource(domainManga, networkManga, manualFetch, coverCache) - } - - withUIContext { view?.onFetchMangaInfoDone() } + val networkManga = successState.source.getMangaDetails(successState.manga.toMangaInfo()) + updateManga.awaitUpdateFromSource(successState.manga, networkManga, manualFetch) } catch (e: Throwable) { withUIContext { view?.onFetchMangaInfoError(e) } } + _state.value = successState.copy(isRefreshingInfo = false) } } /** * Update favorite status of manga, (removes / adds) manga (to / from) library. - * - * @return the new status of the manga. */ - fun toggleFavorite(): Boolean { - manga.favorite = !manga.favorite - manga.date_added = when (manga.favorite) { - true -> Date().time - false -> 0 + fun toggleFavorite( + onRemoved: () -> Unit, + onAdded: () -> Unit, + onRequireCategory: (manga: DomainManga, availableCats: List) -> Unit, + onDuplicateExists: ((DomainManga) -> Unit)?, + ) { + val state = successState ?: return + presenterScope.launchIO { + val manga = state.manga + + if (isFavoritedManga) { + // Remove from library + if (updateManga.awaitUpdateFavorite(manga.id, false)) { + // Remove covers and update last modified in db + if (manga.toDbManga().removeCovers() > 0) { + updateManga.awaitUpdateCoverLastModified(manga.id) + } + launchUI { onRemoved() } + } + } else { + // Add to library + // First, check if duplicate exists if callback is provided + if (onDuplicateExists != null) { + val duplicate = getDuplicateLibraryManga.await(manga.title, manga.source) + if (duplicate != null) { + launchUI { onDuplicateExists(duplicate) } + return@launchIO + } + } + + // Now check if user previously set categories, when available + val categories = getCategories() + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + when { + // Default category set + defaultCategory != null -> { + val result = updateManga.awaitUpdateFavorite(manga.id, true) + if (!result) return@launchIO + moveMangaToCategory(manga.toDbManga(), defaultCategory) + launchUI { onAdded() } + } + + // Automatic 'Default' or no categories + defaultCategoryId == 0 || categories.isEmpty() -> { + val result = updateManga.awaitUpdateFavorite(manga.id, true) + if (!result) return@launchIO + moveMangaToCategory(manga.toDbManga(), null) + launchUI { onAdded() } + } + + // Choose a category + else -> launchUI { onRequireCategory(manga, categories) } + } + + // Finally match with enhanced tracking when available + val source = state.source + trackList + .map { it.service } + .filterIsInstance() + .filter { it.accept(source) } + .forEach { service -> + launchIO { + try { + service.match(manga.toDbManga())?.let { track -> + registerTracking(track, service as TrackService) + } + } catch (e: Exception) { + logcat(LogPriority.WARN, e) { + "Could not match manga: ${manga.title} with service $service" + } + } + } + } + } } - if (!manga.favorite) { - manga.removeCovers(coverCache) - } - db.insertManga(manga).executeAsBlocking() - return manga.favorite } /** * Returns true if the manga has any downloads. */ fun hasDownloads(): Boolean { - return downloadManager.getDownloadCount(manga) > 0 + val manga = successState?.manga ?: return false + return downloadManager.getDownloadCount(manga.toDbManga()) > 0 } /** * Deletes all the downloads for the manga. */ fun deleteDownloads() { - downloadManager.deleteManga(manga, source) + val state = successState ?: return + downloadManager.deleteManga(state.manga.toDbManga(), state.source) } /** @@ -243,8 +328,8 @@ class MangaPresenter( * * @return List of categories, not including the default category */ - suspend fun getCategories(): List { - return getCategories.subscribe().firstOrNull() ?: emptyList() + fun getCategories(): List { + return db.getCategories().executeAsBlocking() } /** @@ -253,9 +338,16 @@ class MangaPresenter( * @param manga the manga to get categories from. * @return Array of category ids the manga is in, if none returns default id */ - fun getMangaCategoryIds(manga: Manga): Array { - val categories = db.getCategoriesForManga(manga).executeAsBlocking() - return categories.mapNotNull { it?.id?.toLong() }.toTypedArray() + fun getMangaCategoryIds(manga: DomainManga): Array { + val categories = db.getCategoriesForManga(manga.toDbManga()).executeAsBlocking() + return categories.mapNotNull { it.id }.toTypedArray() + } + + fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, categories: List) { + moveMangaToCategories(manga, categories) + presenterScope.launchIO { + updateManga.awaitUpdateFavorite(manga.id!!, true) + } } /** @@ -264,7 +356,7 @@ class MangaPresenter( * @param manga the manga to move. * @param categories the selected categories. */ - fun moveMangaToCategories(manga: Manga, categories: List) { + private fun moveMangaToCategories(manga: Manga, categories: List) { val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } db.setMangaCategories(mc, listOf(manga)) } @@ -275,7 +367,7 @@ class MangaPresenter( * @param manga the manga to move. * @param category the selected category, or null for default category. */ - fun moveMangaToCategory(manga: Manga, category: Category?) { + private fun moveMangaToCategory(manga: Manga, category: Category?) { moveMangaToCategories(manga, listOfNotNull(category)) } @@ -288,13 +380,10 @@ class MangaPresenter( observeDownloadsStatusSubscription = downloadManager.queue.getStatusObservable() .observeOn(Schedulers.io()) .onBackpressureBuffer() - .filter { download -> download.manga.id == manga.id } + .filter { download -> download.manga.id == successState?.manga?.id } .observeOn(AndroidSchedulers.mainThread()) .subscribeLatestCache( - { view, it -> - onDownloadStatusChange(it) - view.onChapterDownloadUpdate(it) - }, + { _, it -> updateDownloadState(it) }, { _, error -> logcat(LogPriority.ERROR, error) }, @@ -304,170 +393,133 @@ class MangaPresenter( observeDownloadsPageSubscription = downloadManager.queue.getProgressObservable() .observeOn(Schedulers.io()) .onBackpressureBuffer() - .filter { download -> download.manga.id == manga.id } + .filter { download -> download.manga.id == successState?.manga?.id } .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaController::onChapterDownloadUpdate) { _, error -> - logcat(LogPriority.ERROR, error) + .subscribeLatestCache( + { _, download -> updateDownloadState(download) }, + { _, error -> logcat(LogPriority.ERROR, error) }, + ) + } + + private fun updateDownloadState(download: Download) { + val successState = successState ?: return + val modifiedIndex = successState.chapters.indexOfFirst { it.chapter.id == download.chapter.id } + if (modifiedIndex >= 0) { + val newChapters = successState.chapters.toMutableList().apply { + val item = removeAt(modifiedIndex) + .copy(downloadState = download.status, downloadProgress = download.progress) + add(modifiedIndex, item) } - } - - /** - * Converts a chapter from the database to an extended model, allowing to store new fields. - */ - private fun Chapter.toModel(): ChapterItem { - // Create the model object. - val model = ChapterItem(this, manga) - - // Find an active download for this chapter. - val download = downloadManager.queue.find { it.chapter.id == id } - - if (download != null) { - // If there's an active download, assign it. - model.download = download + _state.value = successState.copy(chapters = newChapters) } - return model } - /** - * Finds and assigns the list of downloaded chapters. - * - * @param chapters the list of chapter from the database. - */ - private fun setDownloadedChapters(chapters: List) { - chapters - .filter { downloadManager.isChapterDownloaded(it, manga) } - .forEach { it.status = Download.State.DOWNLOADED } + private fun List.toChapterItems(manga: DomainManga): List { + return map { chapter -> + val activeDownload = downloadManager.queue.find { chapter.id == it.chapter.id } + val downloaded = downloadManager.isChapterDownloaded(chapter.toDbChapter(), manga.toDbManga()) + val downloadState = when { + activeDownload != null -> activeDownload.status + downloaded -> Download.State.DOWNLOADED + else -> Download.State.NOT_DOWNLOADED + } + ChapterItem( + chapter = chapter, + downloadState = downloadState, + downloadProgress = activeDownload?.progress ?: 0, + ) + } } /** * Requests an updated list of chapters from the source. */ - fun fetchChaptersFromSource(manualFetch: Boolean = false) { - hasRequested = true - + private fun fetchChaptersFromSource(manualFetch: Boolean = false) { if (fetchChaptersJob?.isActive == true) return + val successState = successState ?: return + fetchChaptersJob = presenterScope.launchIO { + _state.value = successState.copy(isRefreshingChapter = true) try { - val chapters = source.getChapterList(manga.toMangaInfo()) + val chapters = successState.source.getChapterList(successState.manga.toMangaInfo()) .map { it.toSChapter() } - val (newChapters, _) = syncChaptersWithSource(chapters, manga, source) - if (manualFetch) { - downloadNewChapters(newChapters) - } + val (newChapters, _) = syncChaptersWithSource.await( + chapters, + successState.manga, + successState.source, + ) - withUIContext { view?.onFetchChaptersDone() } + if (manualFetch) { + val dbChapters = newChapters.map { it.toDbChapter() } + downloadNewChapters(dbChapters) + } } catch (e: Throwable) { withUIContext { view?.onFetchChaptersError(e) } } - } - } - - /** - * Updates the UI after applying the filters. - */ - private fun refreshChapters() { - chaptersRelay.call(allChapters) - } - - /** - * Applies the view filters to the list of chapters obtained from the database. - * @param chapters the list of chapters from the database - * @return an observable of the list of chapters filtered and sorted. - */ - private fun applyChapterFilters(chapters: List): Observable> { - var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) - - val unreadFilter = onlyUnread() - if (unreadFilter == State.INCLUDE) { - observable = observable.filter { !it.read } - } else if (unreadFilter == State.EXCLUDE) { - observable = observable.filter { it.read } - } - - val downloadedFilter = onlyDownloaded() - if (downloadedFilter == State.INCLUDE) { - observable = observable.filter { it.isDownloaded || it.manga.isLocal() } - } else if (downloadedFilter == State.EXCLUDE) { - observable = observable.filter { !it.isDownloaded && !it.manga.isLocal() } - } - - val bookmarkedFilter = onlyBookmarked() - if (bookmarkedFilter == State.INCLUDE) { - observable = observable.filter { it.bookmark } - } else if (bookmarkedFilter == State.EXCLUDE) { - observable = observable.filter { !it.bookmark } - } - - return observable.toSortedList(getChapterSort(manga)) - } - - /** - * Called when a download for the active manga changes status. - * @param download the download whose status changed. - */ - private fun onDownloadStatusChange(download: Download) { - // Assign the download to the model object. - if (download.status == Download.State.QUEUE) { - allChapters.find { it.id == download.chapter.id }?.let { - if (it.download == null) { - it.download = download - } - } - } - - // Force UI update if downloaded filter active and download finished. - if (onlyDownloaded() != State.IGNORE && download.status == Download.State.DOWNLOADED) { - refreshChapters() + _state.value = successState.copy(isRefreshingChapter = false) } } /** * Returns the next unread chapter or null if everything is read. */ - fun getNextUnreadChapter(): ChapterItem? { - return if (sortDescending()) { - return filteredAndSortedChapters.findLast { !it.read } - } else { - filteredAndSortedChapters.find { !it.read } + fun getNextUnreadChapter(): DomainChapter? { + val successState = successState ?: return null + return successState.processedChapters.map { it.chapter }.let { chapters -> + if (successState.manga.sortDescending()) { + chapters.findLast { !it.read } + } else { + chapters.find { !it.read } + } } } - fun getUnreadChaptersSorted(): List { - val chapters = allChapters - .sortedWith(getChapterSort(manga)) - .filter { !it.read && it.status == Download.State.NOT_DOWNLOADED } - .distinctBy { it.name } - return if (sortDescending()) { - chapters.reversed() - } else { - chapters - } + fun getUnreadChapters(): List { + return successState?.processedChapters + ?.filter { (chapter, dlStatus) -> !chapter.read && dlStatus == Download.State.NOT_DOWNLOADED } + ?.map { it.chapter } + ?.toList() + ?: emptyList() } - fun startDownloadingNow(chapter: Chapter) { - downloadManager.startDownloadNow(chapter.id) + fun getUnreadChaptersSorted(): List { + val manga = successState?.manga ?: return emptyList() + val chapters = getUnreadChapters().sortedWith(getChapterSort(manga)) + return if (manga.sortDescending()) chapters.reversed() else chapters + } + + fun startDownloadingNow(chapterId: Long) { + downloadManager.startDownloadNow(chapterId) + } + + fun cancelDownload(chapterId: Long) { + val activeDownload = downloadManager.queue.find { chapterId == it.chapter.id } ?: return + downloadManager.deletePendingDownload(activeDownload) + updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) + } + + fun markPreviousChapterRead(pointer: DomainChapter) { + val successState = successState ?: return + val chapters = successState.chapters.map { it.chapter } + val prevChapters = if (successState.manga.sortDescending()) chapters.asReversed() else chapters + val pointerPos = prevChapters.indexOf(pointer) + if (pointerPos != -1) markChaptersRead(prevChapters.take(pointerPos), true) } /** * Mark the selected chapter list as read/unread. - * @param selectedChapters the list of selected chapters. + * @param chapters the list of selected chapters. * @param read whether to mark chapters as read or unread. */ - fun markChaptersRead(selectedChapters: List, read: Boolean) { - val chapters = selectedChapters.map { chapter -> - chapter.read = read - if (!read) { - chapter.last_page_read = 0 - } - chapter - } - - launchIO { - db.updateChaptersProgress(chapters).executeAsBlocking() - - if (preferences.removeAfterMarkedAsRead()) { - deleteChapters(chapters.filter { it.read }) + fun markChaptersRead(chapters: List, read: Boolean) { + presenterScope.launchIO { + val modified = chapters.filterNot { it.read == read } + modified + .map { ChapterUpdate(id = it.id, read = read) } + .forEach { updateChapter.await(it) } + if (read && preferences.removeAfterMarkedAsRead()) { + deleteChapters(modified) } } } @@ -476,21 +528,21 @@ class MangaPresenter( * Downloads the given list of chapters with the manager. * @param chapters the list of chapters to download. */ - fun downloadChapters(chapters: List) { - downloadManager.downloadChapters(manga, chapters) + fun downloadChapters(chapters: List) { + val manga = successState?.manga ?: return + downloadManager.downloadChapters(manga.toDbManga(), chapters.map { it.toDbChapter() }) } /** * Bookmarks the given list of chapters. - * @param selectedChapters the list of chapters to bookmark. + * @param chapters the list of chapters to bookmark. */ - fun bookmarkChapters(selectedChapters: List, bookmarked: Boolean) { - launchIO { - selectedChapters - .forEach { - it.bookmark = bookmarked - db.updateChapterProgress(it).executeAsBlocking() - } + fun bookmarkChapters(chapters: List, bookmarked: Boolean) { + presenterScope.launchIO { + chapters + .filterNot { it.bookmark == bookmarked } + .map { ChapterUpdate(id = it.id, bookmark = bookmarked) } + .forEach { updateChapter.await(it) } } } @@ -498,40 +550,37 @@ class MangaPresenter( * Deletes the given list of chapter. * @param chapters the list of chapters to delete. */ - fun deleteChapters(chapters: List) { + fun deleteChapters(chapters: List) { + val successState = successState ?: return launchIO { + val chapters2 = chapters.map { it.toDbChapter() } try { - downloadManager.deleteChapters(chapters, manga, source).forEach { - if (it is ChapterItem) { - it.status = Download.State.NOT_DOWNLOADED - it.download = null + val deletedIds = downloadManager + .deleteChapters(chapters2, successState.manga.toDbManga(), successState.source) + .map { it.id } + val deletedChapters = successState.chapters.filter { deletedIds.contains(it.chapter.id) } + if (deletedChapters.isEmpty()) return@launchIO + + // TODO: Don't do this fake status update + val newChapters = successState.chapters.toMutableList().apply { + deletedChapters.forEach { + val index = indexOf(it) + val toAdd = removeAt(index) + .copy(downloadState = Download.State.NOT_DOWNLOADED, downloadProgress = 0) + add(index, toAdd) } } - - if (onlyDownloaded() != State.IGNORE) { - refreshChapters() - } - - view?.onChaptersDeleted(chapters) + _state.value = successState.copy(chapters = newChapters) } catch (e: Throwable) { - view?.onChaptersDeletedError(e) + logcat(LogPriority.ERROR, e) } } } private fun downloadNewChapters(chapters: List) { + val manga = successState?.manga ?: return if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(db, preferences)) return - - downloadChapters(chapters) - } - - /** - * Reverses the sorting and requests an UI update. - */ - fun reverseSortOrder() { - manga.setChapterOrder(if (sortDescending()) Manga.CHAPTER_SORT_ASC else Manga.CHAPTER_SORT_DESC) - db.updateChapterFlags(manga).executeAsBlocking() - refreshChapters() + downloadChapters(chapters.map { it.toDomainChapter()!! }) } /** @@ -539,13 +588,15 @@ class MangaPresenter( * @param state whether to display only unread chapters or all chapters. */ fun setUnreadFilter(state: State) { - manga.readFilter = when (state) { - State.IGNORE -> Manga.SHOW_ALL - State.INCLUDE -> Manga.CHAPTER_SHOW_UNREAD - State.EXCLUDE -> Manga.CHAPTER_SHOW_READ + val manga = successState?.manga ?: return + val flag = when (state) { + State.IGNORE -> DomainManga.SHOW_ALL + State.INCLUDE -> DomainManga.CHAPTER_SHOW_UNREAD + State.EXCLUDE -> DomainManga.CHAPTER_SHOW_READ + } + presenterScope.launchIO { + setMangaChapterFlags.awaitSetUnreadFilter(manga, flag) } - db.updateChapterFlags(manga).executeAsBlocking() - refreshChapters() } /** @@ -553,13 +604,15 @@ class MangaPresenter( * @param state whether to display only downloaded chapters or all chapters. */ fun setDownloadedFilter(state: State) { - manga.downloadedFilter = when (state) { - State.IGNORE -> Manga.SHOW_ALL - State.INCLUDE -> Manga.CHAPTER_SHOW_DOWNLOADED - State.EXCLUDE -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED + val manga = successState?.manga ?: return + val flag = when (state) { + State.IGNORE -> DomainManga.SHOW_ALL + State.INCLUDE -> DomainManga.CHAPTER_SHOW_DOWNLOADED + State.EXCLUDE -> DomainManga.CHAPTER_SHOW_NOT_DOWNLOADED + } + presenterScope.launchIO { + setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag) } - db.updateChapterFlags(manga).executeAsBlocking() - refreshChapters() } /** @@ -567,83 +620,37 @@ class MangaPresenter( * @param state whether to display only bookmarked chapters or all chapters. */ fun setBookmarkedFilter(state: State) { - manga.bookmarkedFilter = when (state) { - State.IGNORE -> Manga.SHOW_ALL - State.INCLUDE -> Manga.CHAPTER_SHOW_BOOKMARKED - State.EXCLUDE -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED + val manga = successState?.manga ?: return + val flag = when (state) { + State.IGNORE -> DomainManga.SHOW_ALL + State.INCLUDE -> DomainManga.CHAPTER_SHOW_BOOKMARKED + State.EXCLUDE -> DomainManga.CHAPTER_SHOW_NOT_BOOKMARKED + } + presenterScope.launchIO { + setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag) } - db.updateChapterFlags(manga).executeAsBlocking() - refreshChapters() } /** * Sets the active display mode. * @param mode the mode to set. */ - fun setDisplayMode(mode: Int) { - manga.displayMode = mode - db.updateChapterFlags(manga).executeAsBlocking() - refreshChapters() + fun setDisplayMode(mode: Long) { + val manga = successState?.manga ?: return + presenterScope.launchIO { + setMangaChapterFlags.awaitSetDisplayMode(manga, mode) + } } /** * Sets the sorting method and requests an UI update. * @param sort the sorting mode. */ - fun setSorting(sort: Int) { - manga.sorting = sort - db.updateChapterFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Whether downloaded only mode is enabled. - */ - private fun forceDownloaded(): Boolean { - return manga.favorite && preferences.downloadedOnly().get() - } - - /** - * Whether the display only downloaded filter is enabled. - */ - private fun onlyDownloaded(): State { - if (forceDownloaded()) { - return State.INCLUDE + fun setSorting(sort: Long) { + val manga = successState?.manga ?: return + presenterScope.launchIO { + setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort) } - return when (manga.downloadedFilter) { - Manga.CHAPTER_SHOW_DOWNLOADED -> State.INCLUDE - Manga.CHAPTER_SHOW_NOT_DOWNLOADED -> State.EXCLUDE - else -> State.IGNORE - } - } - - /** - * Whether the display only downloaded filter is enabled. - */ - private fun onlyBookmarked(): State { - return when (manga.bookmarkedFilter) { - Manga.CHAPTER_SHOW_BOOKMARKED -> State.INCLUDE - Manga.CHAPTER_SHOW_NOT_BOOKMARKED -> State.EXCLUDE - else -> State.IGNORE - } - } - - /** - * Whether the display only unread filter is enabled. - */ - private fun onlyUnread(): State { - return when (manga.readFilter) { - Manga.CHAPTER_SHOW_UNREAD -> State.INCLUDE - Manga.CHAPTER_SHOW_READ -> State.EXCLUDE - else -> State.IGNORE - } - } - - /** - * Whether the sorting method is descending or ascending. - */ - fun sortDescending(): Boolean { - return manga.sortDescending() } // Chapters list - end @@ -651,6 +658,7 @@ class MangaPresenter( // Track sheet - start private fun fetchTrackers() { + val manga = successState?.manga ?: return trackSubscription?.let { remove(it) } trackSubscription = db.getTracks(manga.id) .asRxObservable() @@ -677,6 +685,8 @@ class MangaPresenter( db.insertTrack(track).executeAsBlocking() if (it.service is EnhancedTrackService) { + val allChapters = successState?.chapters + ?.map { it.chapter.toDbChapter() } ?: emptyList() syncChaptersWithTrackServiceTwoWay(db, allChapters, track, it.service) } } @@ -704,10 +714,13 @@ class MangaPresenter( } fun registerTracking(item: Track?, service: TrackService) { + val successState = successState ?: return if (item != null) { - item.manga_id = manga.id!! + item.manga_id = successState.manga.id launchIO { try { + val allChapters = successState.chapters + .map { it.chapter.toDbChapter() } val hasReadChapters = allChapters.any { it.read } service.bind(item, hasReadChapters) db.insertTrack(item).executeAsBlocking() @@ -725,7 +738,8 @@ class MangaPresenter( } fun unregisterTracking(service: TrackService) { - db.deleteTrackForManga(manga, service).executeAsBlocking() + val manga = successState?.manga ?: return + db.deleteTrackForManga(manga.toDbManga(), service).executeAsBlocking() } private fun updateRemote(track: Track, service: TrackService) { @@ -784,3 +798,71 @@ class MangaPresenter( // Track sheet - end } + +sealed class MangaScreenState { + @Immutable + object Loading : MangaScreenState() + + @Immutable + data class Success( + val manga: DomainManga, + val source: Source, + val dateRelativeTime: Int, + val dateFormat: DateFormat, + val isFromSource: Boolean, + val chapters: List, + val trackingAvailable: Boolean = false, + val trackingCount: Int = 0, + val isRefreshingInfo: Boolean = false, + val isRefreshingChapter: Boolean = false, + val isIncognitoMode: Boolean = false, + val isDownloadedOnlyMode: Boolean = false, + ) : MangaScreenState() { + + val processedChapters: Sequence + get() = chapters.applyFilters(manga) + + /** + * Applies the view filters to the list of chapters obtained from the database. + * @return an observable of the list of chapters filtered and sorted. + */ + private fun List.applyFilters(manga: DomainManga): Sequence { + val isLocalManga = manga.isLocal() + val unreadFilter = manga.unreadFilter + val downloadedFilter = manga.downloadedFilter + val bookmarkedFilter = manga.bookmarkedFilter + return asSequence() + .filter { (chapter) -> + when (unreadFilter) { + TriStateFilter.DISABLED -> true + TriStateFilter.ENABLED_IS -> !chapter.read + TriStateFilter.ENABLED_NOT -> chapter.read + } + } + .filter { (chapter) -> + when (bookmarkedFilter) { + TriStateFilter.DISABLED -> true + TriStateFilter.ENABLED_IS -> chapter.bookmark + TriStateFilter.ENABLED_NOT -> !chapter.bookmark + } + } + .filter { + when (downloadedFilter) { + TriStateFilter.DISABLED -> true + TriStateFilter.ENABLED_IS -> it.isDownloaded || isLocalManga + TriStateFilter.ENABLED_NOT -> !it.isDownloaded && !isLocalManga + } + } + .sortedWith { (chapter1), (chapter2) -> getChapterSort(manga).invoke(chapter1, chapter2) } + } + } +} + +@Immutable +data class ChapterItem( + val chapter: DomainChapter, + val downloadState: Download.State, + val downloadProgress: Int, +) { + val isDownloaded = downloadState == Download.State.DOWNLOADED +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt deleted file mode 100644 index ad77304e34..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt +++ /dev/null @@ -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() - - 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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt deleted file mode 100644 index d63f7d602d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt +++ /dev/null @@ -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>(chapter) { - - override fun getLayoutRes(): Int { - return R.layout.chapters_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ChapterHolder { - return ChapterHolder(view, adapter as ChaptersAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ChapterHolder, - position: Int, - payloads: List?, - ) { - holder.bind(this, manga) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt deleted file mode 100644 index def5c2f5bd..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt +++ /dev/null @@ -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(controller) { - - private val preferences: PreferencesHelper by injectLazy() - - var items: List = 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?) { - this.items = items ?: emptyList() - super.updateDataSet(items) - } - - fun indexOf(item: ChapterItem): Int { - return items.indexOf(item) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt index d85ceabd4c..0a89ffe09a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt @@ -7,10 +7,11 @@ import android.view.View import androidx.core.view.isVisible import com.bluelinelabs.conductor.Router import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.toDbManga import eu.kanade.domain.manga.model.toTriStateGroupState 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.MangaScreenState import eu.kanade.tachiyomi.util.view.popupMenu import eu.kanade.tachiyomi.widget.ExtendedNavigationView 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.MainScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch class ChaptersSettingsSheet( private val router: Router, @@ -28,7 +32,7 @@ class ChaptersSettingsSheet( private var manga: Manga? = null - val filters = Filter(context) + private val filters = Filter(context) private val sort = Sort(context) private val display = Display(context) @@ -42,8 +46,14 @@ class ChaptersSettingsSheet( override fun onAttachedToWindow() { super.onAttachedToWindow() scope = MainScope() - // TODO: Listen to changes - updateManga() + scope.launch { + presenter.state + .filterIsInstance() + .collectLatest { + manga = it.manga + getTabViews().forEach { settings -> (settings as Settings).updateView() } + } + } } override fun onDetachedFromWindow() { @@ -63,17 +73,13 @@ class ChaptersSettingsSheet( R.string.action_display, ) - private fun updateManga() { - manga = presenter.manga.toDomainManga() - } - private fun showPopupMenu(view: View) { view.popupMenu( menuRes = R.menu.default_chapter_filter, onMenuItemClick = { when (itemId) { 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) else -> {} } - - // TODO: Remove - updateManga() - updateView() } } } @@ -202,16 +204,11 @@ class ChaptersSettingsSheet( override fun onItemClicked(item: Item) { when (item) { - source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE.toInt()) - chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER.toInt()) - uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE.toInt()) + source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE) + chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER) + uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE) else -> throw Exception("Unknown sorting") } - - // TODO: Remove - presenter.reverseSortOrder() - updateManga() - updateView() } } } @@ -257,14 +254,10 @@ class ChaptersSettingsSheet( if (item.checked) return when (item) { - displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME.toInt()) - displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER.toInt()) + displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME) + displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER) else -> throw NotImplementedError("Unknown display mode") } - - // TODO: Remove - updateManga() - updateView() } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt deleted file mode 100644 index 81177d46a1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt +++ /dev/null @@ -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(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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaChaptersHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaChaptersHeaderAdapter.kt deleted file mode 100644 index 0ee0493429..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaChaptersHeaderAdapter.kt +++ /dev/null @@ -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() { - - 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) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt deleted file mode 100644 index 839f2fd794..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt +++ /dev/null @@ -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() { - - 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 { - 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 - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt index e918ca6cd2..f90378b534 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt @@ -80,7 +80,7 @@ class TrackSearchDialog : DialogController { // Do an initial search based on the manga's title if (savedViewState == null) { - currentlySearched = trackController.presenter.manga.title + currentlySearched = trackController.presenter.manga!!.title binding!!.titleInput.editText?.append(currentlySearched) } search(currentlySearched) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt index 43783e4dd4..0d8cbeac5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt @@ -10,6 +10,7 @@ import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.DateValidatorPointForward import com.google.android.material.datepicker.MaterialDatePicker +import eu.kanade.domain.manga.model.toDbManga import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.databinding.TrackControllerBinding @@ -25,7 +26,7 @@ import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog class TrackSheet( val controller: MangaController, - val fragmentManager: FragmentManager, + private val fragmentManager: FragmentManager, ) : BaseBottomSheetDialog(controller.activity!!), TrackAdapter.OnClickListener, SetTrackStatusDialog.Listener, @@ -74,8 +75,8 @@ class TrackSheet( override fun onSetClick(position: Int) { val item = adapter.getItem(position) ?: return - val manga = controller.presenter.manga - val source = controller.presenter.source + val manga = controller.presenter.manga?.toDbManga() ?: return + val source = controller.presenter.source ?: return if (item.service is EnhancedTrackService) { if (item.track != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt index 50ea2f45b4..670b743ac7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt @@ -34,7 +34,7 @@ class HistoryController : ComposeController(), RootController nestedScrollInterop = nestedScrollInterop, presenter = presenter, onClickCover = { history -> - router.pushController(MangaController(history)) + router.pushController(MangaController(history.id)) }, onClickResume = { history -> presenter.getNextChapterForManga(history.mangaId, history.chapterId) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt index f22199a225..9fdb0dea41 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt @@ -7,6 +7,7 @@ import eu.kanade.domain.manga.model.toDbManga import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper 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.source.LocalSource import eu.kanade.tachiyomi.source.model.SManga @@ -48,19 +49,18 @@ fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean { return coverCache.getCustomCoverFile(id).exists() } -fun Manga.removeCovers(coverCache: CoverCache) { - if (isLocal()) return +fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int { + if (isLocal()) return 0 cover_last_modified = Date().time - coverCache.deleteFromCache(this, true) -} - -fun Manga.updateCoverLastModified(db: DatabaseHelper) { - cover_last_modified = Date().time - db.updateMangaCoverLastModified(this).executeAsBlocking() + return coverCache.deleteFromCache(this, true) } 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 // 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) val categoriesForManga = - db.getCategoriesForManga(this).executeAsBlocking() + db.getCategoriesForManga(toDbManga()).executeAsBlocking() .mapNotNull { it.id } .takeUnless { it.isEmpty() } ?: listOf(0) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSettingsHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSettingsHelper.kt index b71d745866..5d1e6da34b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSettingsHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSettingsHelper.kt @@ -1,5 +1,6 @@ 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.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -34,6 +35,18 @@ object ChapterSettingsHelper { 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. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSorter.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSorter.kt index a069a2c7c4..81f498833f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSorter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSorter.kt @@ -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.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 { 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}") } } + +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") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt deleted file mode 100644 index c98bcee897..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt +++ /dev/null @@ -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?, 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 { - topMargin = toggleMore.translationY.roundToInt() - } - tagChipsExpanded.updateLayoutParams { - 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/materialdialogs/MaterialAlertDialogBuilderExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/materialdialogs/MaterialAlertDialogBuilderExtensions.kt index 33a18380d2..0a7d8a7708 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/materialdialogs/MaterialAlertDialogBuilderExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/materialdialogs/MaterialAlertDialogBuilderExtensions.kt @@ -4,6 +4,7 @@ import android.view.LayoutInflater import android.view.inputmethod.InputMethodManager import android.widget.TextView import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.core.content.getSystemService import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged @@ -11,6 +12,8 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import eu.kanade.tachiyomi.databinding.DialogStubQuadstatemultichoiceBinding import eu.kanade.tachiyomi.databinding.DialogStubTextinputBinding +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume fun MaterialAlertDialogBuilder.setTextInput( hint: String? = null, @@ -71,3 +74,19 @@ fun MaterialAlertDialogBuilder.setQuadStateMultiChoiceItems( } return setView(binding.root) } + +suspend fun MaterialAlertDialogBuilder.await( + @StringRes positiveLabelId: Int, + @StringRes negativeLabelId: Int, + @StringRes neutralLabelId: Int? = null, +) = suspendCancellableCoroutine { 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() } +} diff --git a/app/src/main/res/drawable/anim_caret_up.xml b/app/src/main/res/drawable/anim_caret_up.xml deleted file mode 100644 index 78b817a168..0000000000 --- a/app/src/main/res/drawable/anim_caret_up.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-sw720dp/manga_controller.xml b/app/src/main/res/layout-sw720dp/manga_controller.xml deleted file mode 100644 index 52f58ce4ce..0000000000 --- a/app/src/main/res/layout-sw720dp/manga_controller.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-sw720dp/manga_info_header.xml b/app/src/main/res/layout-sw720dp/manga_info_header.xml deleted file mode 100644 index add76742e2..0000000000 --- a/app/src/main/res/layout-sw720dp/manga_info_header.xml +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/manga_chapters_header.xml b/app/src/main/res/layout/manga_chapters_header.xml deleted file mode 100644 index aac308b0a3..0000000000 --- a/app/src/main/res/layout/manga_chapters_header.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/manga_controller.xml b/app/src/main/res/layout/manga_controller.xml deleted file mode 100644 index 6c114dc6e9..0000000000 --- a/app/src/main/res/layout/manga_controller.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/manga_full_cover_dialog.xml b/app/src/main/res/layout/manga_full_cover_dialog.xml deleted file mode 100644 index f2768743b2..0000000000 --- a/app/src/main/res/layout/manga_full_cover_dialog.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/manga_info_header.xml b/app/src/main/res/layout/manga_info_header.xml deleted file mode 100644 index 7aac83a706..0000000000 --- a/app/src/main/res/layout/manga_info_header.xml +++ /dev/null @@ -1,220 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/manga_summary.xml b/app/src/main/res/layout/manga_summary.xml deleted file mode 100644 index 89d192bf2a..0000000000 --- a/app/src/main/res/layout/manga_summary.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/full_cover.xml b/app/src/main/res/menu/full_cover.xml deleted file mode 100644 index 053dc3f9b1..0000000000 --- a/app/src/main/res/menu/full_cover.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/menu/manga.xml b/app/src/main/res/menu/manga.xml deleted file mode 100644 index f635cf810b..0000000000 --- a/app/src/main/res/menu/manga.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index fad1d11774..1e63b96220 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -1,17 +1,21 @@ [versions] compose = "1.2.0-rc02" accompanist = "0.24.12-rc" +material3 = "1.0.0-alpha13" [libraries] activity = "androidx.activity:activity-compose:1.6.0-alpha05" foundation = { module = "androidx.compose.foundation:foundation", 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-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" material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" } -accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } \ No newline at end of file +accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } +accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref="accompanist" } \ No newline at end of file