diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1fc7b9291..63655c49c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -133,13 +133,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) @@ -306,7 +309,8 @@ tasks { "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", - "-opt-in=kotlin.time.ExperimentalTime", + "-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/MangaMergeRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/manga/MangaMergeRepositoryImpl.kt new file mode 100644 index 000000000..29f862951 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/manga/MangaMergeRepositoryImpl.kt @@ -0,0 +1,29 @@ +package eu.kanade.data.manga + +import eu.kanade.data.DatabaseHandler +import eu.kanade.data.exh.mergedMangaReferenceMapper +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.repository.MangaMergeRepository +import exh.merged.sql.models.MergedMangaReference +import kotlinx.coroutines.flow.Flow + +class MangaMergeRepositoryImpl( + private val handler: DatabaseHandler, +) : MangaMergeRepository { + + override suspend fun getMergedMangaById(id: Long): List { + return handler.awaitList { mergedQueries.selectMergedMangasById(id, mangaMapper) } + } + + override suspend fun subscribeMergedMangaById(id: Long): Flow> { + return handler.subscribeToList { mergedQueries.selectMergedMangasById(id, mangaMapper) } + } + + override suspend fun getReferencesById(id: Long): List { + return handler.awaitList { mergedQueries.selectByMergeId(id, mergedMangaReferenceMapper) } + } + + override suspend fun subscribeReferencesById(id: Long): Flow> { + return handler.subscribeToList { mergedQueries.selectByMergeId(id, mergedMangaReferenceMapper) } + } +} diff --git a/app/src/main/java/eu/kanade/data/manga/MangaMetadataRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/manga/MangaMetadataRepositoryImpl.kt new file mode 100644 index 000000000..044c743e2 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/manga/MangaMetadataRepositoryImpl.kt @@ -0,0 +1,40 @@ +package eu.kanade.data.manga + +import eu.kanade.data.DatabaseHandler +import eu.kanade.data.exh.searchMetadataMapper +import eu.kanade.data.exh.searchTagMapper +import eu.kanade.data.exh.searchTitleMapper +import eu.kanade.domain.manga.repository.MangaMetadataRepository +import exh.metadata.sql.models.SearchMetadata +import exh.metadata.sql.models.SearchTag +import exh.metadata.sql.models.SearchTitle +import kotlinx.coroutines.flow.Flow + +class MangaMetadataRepositoryImpl( + private val handler: DatabaseHandler, +) : MangaMetadataRepository { + + override suspend fun getMetadataById(id: Long): SearchMetadata? { + return handler.awaitOneOrNull { search_metadataQueries.selectByMangaId(id, searchMetadataMapper) } + } + + override suspend fun subscribeMetadataById(id: Long): Flow { + return handler.subscribeToOneOrNull { search_metadataQueries.selectByMangaId(id, searchMetadataMapper) } + } + + override suspend fun getTagsById(id: Long): List { + return handler.awaitList { search_tagsQueries.selectByMangaId(id, searchTagMapper) } + } + + override suspend fun subscribeTagsById(id: Long): Flow> { + return handler.subscribeToList { search_tagsQueries.selectByMangaId(id, searchTagMapper) } + } + + override suspend fun getTitlesById(id: Long): List { + return handler.awaitList { search_titlesQueries.selectByMangaId(id, searchTitleMapper) } + } + + override suspend fun subscribeTitlesById(id: Long): Flow> { + return handler.subscribeToList { search_titlesQueries.selectByMangaId(id, searchTitleMapper) } + } +} 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 140c51af4..04abec76d 100644 --- a/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt @@ -2,6 +2,7 @@ package eu.kanade.data.manga import eu.kanade.data.DatabaseHandler import eu.kanade.data.listOfStringsAdapter +import eu.kanade.data.listOfStringsAndAdapter import eu.kanade.data.toLong import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.MangaUpdate @@ -22,6 +23,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) } } @@ -72,6 +77,7 @@ class MangaRepositoryImpl( coverLastModified = update.coverLastModified, dateAdded = update.dateAdded, mangaId = update.id, + filteredScanlators = update.filteredScanlators?.let(listOfStringsAndAdapter::encode), ) } true diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 8d061eb1c..f16925ce9 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/SYDomainModule.kt b/app/src/main/java/eu/kanade/domain/SYDomainModule.kt index 8a481fa35..64d0ab756 100644 --- a/app/src/main/java/eu/kanade/domain/SYDomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/SYDomainModule.kt @@ -1,6 +1,14 @@ package eu.kanade.domain +import eu.kanade.data.manga.MangaMergeRepositoryImpl +import eu.kanade.data.manga.MangaMetadataRepositoryImpl import eu.kanade.domain.chapter.interactor.GetMergedChapterByMangaId +import eu.kanade.domain.manga.interactor.GetFlatMetadataById +import eu.kanade.domain.manga.interactor.GetMergedMangaById +import eu.kanade.domain.manga.interactor.GetMergedReferencesById +import eu.kanade.domain.manga.interactor.SetMangaFilteredScanlators +import eu.kanade.domain.manga.repository.MangaMergeRepository +import eu.kanade.domain.manga.repository.MangaMetadataRepository import eu.kanade.domain.source.interactor.GetShowLatest import eu.kanade.domain.source.interactor.GetSourceCategories import eu.kanade.domain.source.interactor.SetSourceCategories @@ -9,6 +17,7 @@ import eu.kanade.domain.source.interactor.ToggleSources import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.addFactory +import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.get class SYDomainModule : InjektModule { @@ -19,6 +28,14 @@ class SYDomainModule : InjektModule { addFactory { ToggleExcludeFromDataSaver(get()) } addFactory { SetSourceCategories(get()) } addFactory { ToggleSources(get()) } + addFactory { SetMangaFilteredScanlators(get()) } + + addSingletonFactory { MangaMetadataRepositoryImpl(get()) } + addFactory { GetFlatMetadataById(get()) } + + addSingletonFactory { MangaMergeRepositoryImpl(get()) } + addFactory { GetMergedMangaById(get()) } + addFactory { GetMergedReferencesById(get()) } addFactory { GetMergedChapterByMangaId(get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetFlatMetadataById.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetFlatMetadataById.kt new file mode 100644 index 000000000..fb1492266 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetFlatMetadataById.kt @@ -0,0 +1,40 @@ +package eu.kanade.domain.manga.interactor + +import eu.kanade.domain.manga.repository.MangaMetadataRepository +import eu.kanade.tachiyomi.util.system.logcat +import exh.metadata.metadata.base.FlatMetadata +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import logcat.LogPriority + +class GetFlatMetadataById( + private val mangaMetadataRepository: MangaMetadataRepository, +) { + + suspend fun await(id: Long): FlatMetadata? { + return try { + val meta = mangaMetadataRepository.getMetadataById(id) + return if (meta != null) { + val tags = mangaMetadataRepository.getTagsById(id) + val titles = mangaMetadataRepository.getTitlesById(id) + + FlatMetadata(meta, tags, titles) + } else null + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + null + } + } + + suspend fun subscribe(id: Long): Flow { + return combine( + mangaMetadataRepository.subscribeMetadataById(id), + mangaMetadataRepository.subscribeTagsById(id), + mangaMetadataRepository.subscribeTitlesById(id), + ) { meta, tags, titles -> + if (meta != null) { + FlatMetadata(meta, tags, titles) + } else null + } + } +} 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 1c6426470..cd0375203 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/GetMergedMangaById.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetMergedMangaById.kt new file mode 100644 index 000000000..821ab3c21 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetMergedMangaById.kt @@ -0,0 +1,25 @@ +package eu.kanade.domain.manga.interactor + +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.repository.MangaMergeRepository +import eu.kanade.tachiyomi.util.system.logcat +import kotlinx.coroutines.flow.Flow +import logcat.LogPriority + +class GetMergedMangaById( + private val mangaMergeRepository: MangaMergeRepository, +) { + + suspend fun await(id: Long): List { + return try { + mangaMergeRepository.getMergedMangaById(id) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + emptyList() + } + } + + suspend fun subscribe(id: Long): Flow> { + return mangaMergeRepository.subscribeMergedMangaById(id) + } +} diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetMergedReferencesById.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetMergedReferencesById.kt new file mode 100644 index 000000000..e85b50398 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetMergedReferencesById.kt @@ -0,0 +1,25 @@ +package eu.kanade.domain.manga.interactor + +import eu.kanade.domain.manga.repository.MangaMergeRepository +import eu.kanade.tachiyomi.util.system.logcat +import exh.merged.sql.models.MergedMangaReference +import kotlinx.coroutines.flow.Flow +import logcat.LogPriority + +class GetMergedReferencesById( + private val mangaMergeRepository: MangaMergeRepository, +) { + + suspend fun await(id: Long): List { + return try { + mangaMergeRepository.getReferencesById(id) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + emptyList() + } + } + + suspend fun subscribe(id: Long): Flow> { + return mangaMergeRepository.subscribeReferencesById(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 000000000..3cb34503f --- /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/SetMangaFilteredScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaFilteredScanlators.kt new file mode 100644 index 000000000..254cc892d --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaFilteredScanlators.kt @@ -0,0 +1,17 @@ +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 SetMangaFilteredScanlators(private val mangaRepository: MangaRepository) { + + suspend fun awaitSetFilteredScanlators(manga: Manga, filteredScanlators: List): Boolean { + return mangaRepository.update( + MangaUpdate( + id = manga.id, + filteredScanlators = filteredScanlators, + ), + ) + } +} 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 b18d1f222..1071997ac 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/model/MangaUpdate.kt b/app/src/main/java/eu/kanade/domain/manga/model/MangaUpdate.kt index 0d18659d4..6e343b056 100644 --- a/app/src/main/java/eu/kanade/domain/manga/model/MangaUpdate.kt +++ b/app/src/main/java/eu/kanade/domain/manga/model/MangaUpdate.kt @@ -18,4 +18,7 @@ data class MangaUpdate( val status: Long? = null, val thumbnailUrl: String? = null, val initialized: Boolean? = null, + // SY --> + val filteredScanlators: List? = null, + // SY <-- ) diff --git a/app/src/main/java/eu/kanade/domain/manga/repository/MangaMergeRepository.kt b/app/src/main/java/eu/kanade/domain/manga/repository/MangaMergeRepository.kt new file mode 100644 index 000000000..61d926f4a --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/repository/MangaMergeRepository.kt @@ -0,0 +1,15 @@ +package eu.kanade.domain.manga.repository + +import eu.kanade.domain.manga.model.Manga +import exh.merged.sql.models.MergedMangaReference +import kotlinx.coroutines.flow.Flow + +interface MangaMergeRepository { + suspend fun getMergedMangaById(id: Long): List + + suspend fun subscribeMergedMangaById(id: Long): Flow> + + suspend fun getReferencesById(id: Long): List + + suspend fun subscribeReferencesById(id: Long): Flow> +} diff --git a/app/src/main/java/eu/kanade/domain/manga/repository/MangaMetadataRepository.kt b/app/src/main/java/eu/kanade/domain/manga/repository/MangaMetadataRepository.kt new file mode 100644 index 000000000..4ca556e13 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/repository/MangaMetadataRepository.kt @@ -0,0 +1,20 @@ +package eu.kanade.domain.manga.repository + +import exh.metadata.sql.models.SearchMetadata +import exh.metadata.sql.models.SearchTag +import exh.metadata.sql.models.SearchTitle +import kotlinx.coroutines.flow.Flow + +interface MangaMetadataRepository { + suspend fun getMetadataById(id: Long): SearchMetadata? + + suspend fun subscribeMetadataById(id: Long): Flow + + suspend fun getTagsById(id: Long): List + + suspend fun subscribeTagsById(id: Long): Flow> + + suspend fun getTitlesById(id: Long): List + + suspend fun subscribeTitlesById(id: Long): Flow> +} 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 b72e9bba0..60c07ba84 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 000000000..b1472ae32 --- /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/Chip.kt b/app/src/main/java/eu/kanade/presentation/components/Chip.kt new file mode 100644 index 000000000..c3927f47b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/Chip.kt @@ -0,0 +1,245 @@ +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.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ChipBorder +import androidx.compose.material3.ChipColors +import androidx.compose.material3.ChipElevation +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface +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.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@ExperimentalMaterial3Api +@Composable +fun SuggestionChip( + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: @Composable (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ChipElevation? = SuggestionChipDefaults.suggestionChipElevation(), + shape: Shape = MaterialTheme.shapes.small, + border: ChipBorder? = SuggestionChipDefaults.suggestionChipBorder(), + colors: ChipColors = SuggestionChipDefaults.suggestionChipColors(), +) = Chip( + modifier = modifier, + label = label, + labelTextStyle = MaterialTheme.typography.labelLarge, + labelColor = colors.labelColor(enabled).value, + leadingIcon = icon, + avatar = null, + trailingIcon = null, + leadingIconColor = colors.leadingIconContentColor(enabled).value, + trailingIconColor = colors.trailingIconContentColor(enabled).value, + containerColor = colors.containerColor(enabled).value, + tonalElevation = elevation?.tonalElevation(enabled, interactionSource)?.value ?: 0.dp, + shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp, + minHeight = SuggestionChipDefaults.Height, + paddingValues = SuggestionChipPadding, + shape = shape, + border = border?.borderStroke(enabled)?.value, +) + +@ExperimentalMaterial3Api +@Composable +fun SuggestionChip( + onClick: () -> Unit, + onLongClick: () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: @Composable (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ChipElevation? = SuggestionChipDefaults.suggestionChipElevation(), + shape: Shape = MaterialTheme.shapes.small, + border: ChipBorder? = SuggestionChipDefaults.suggestionChipBorder(), + colors: ChipColors = SuggestionChipDefaults.suggestionChipColors(), +) = Chip( + modifier = modifier, + onClick = onClick, + onLongClick = onLongClick, + enabled = enabled, + label = label, + labelTextStyle = MaterialTheme.typography.labelLarge, + labelColor = colors.labelColor(enabled).value, + leadingIcon = icon, + avatar = null, + trailingIcon = null, + leadingIconColor = colors.leadingIconContentColor(enabled).value, + trailingIconColor = colors.trailingIconContentColor(enabled).value, + containerColor = colors.containerColor(enabled).value, + tonalElevation = elevation?.tonalElevation(enabled, interactionSource)?.value ?: 0.dp, + shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp, + minHeight = SuggestionChipDefaults.Height, + paddingValues = SuggestionChipPadding, + shape = shape, + border = border?.borderStroke(enabled)?.value, + interactionSource = interactionSource, +) + +@Suppress("SameParameterValue") +@ExperimentalMaterial3Api +@Composable +private fun Chip( + modifier: Modifier, + label: @Composable () -> Unit, + labelTextStyle: TextStyle, + labelColor: Color, + leadingIcon: @Composable (() -> Unit)?, + avatar: @Composable (() -> Unit)?, + trailingIcon: @Composable (() -> Unit)?, + leadingIconColor: Color, + trailingIconColor: Color, + containerColor: Color, + tonalElevation: Dp, + shadowElevation: Dp, + minHeight: Dp, + paddingValues: PaddingValues, + shape: Shape, + border: BorderStroke?, +) { + Surface( + modifier = modifier, + shape = shape, + color = containerColor, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + border = border, + ) { + ChipContent( + label = label, + labelTextStyle = labelTextStyle, + labelColor = labelColor, + leadingIcon = leadingIcon, + avatar = avatar, + trailingIcon = trailingIcon, + leadingIconColor = leadingIconColor, + trailingIconColor = trailingIconColor, + minHeight = minHeight, + paddingValues = paddingValues, + ) + } +} + +@Suppress("SameParameterValue") +@ExperimentalMaterial3Api +@Composable +private fun Chip( + modifier: Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + enabled: Boolean, + label: @Composable () -> Unit, + labelTextStyle: TextStyle, + labelColor: Color, + leadingIcon: @Composable (() -> Unit)?, + avatar: @Composable (() -> Unit)?, + trailingIcon: @Composable (() -> Unit)?, + leadingIconColor: Color, + trailingIconColor: Color, + containerColor: Color, + tonalElevation: Dp, + shadowElevation: Dp, + minHeight: Dp, + paddingValues: PaddingValues, + shape: Shape, + border: BorderStroke?, + interactionSource: MutableInteractionSource, +) { + Surface( + onClick = onClick, + modifier = modifier, + onLongClick = onLongClick, + enabled = enabled, + shape = shape, + color = containerColor, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + border = border, + interactionSource = interactionSource, + ) { + ChipContent( + label = label, + labelTextStyle = labelTextStyle, + labelColor = labelColor, + leadingIcon = leadingIcon, + avatar = avatar, + trailingIcon = trailingIcon, + leadingIconColor = leadingIconColor, + trailingIconColor = trailingIconColor, + minHeight = minHeight, + paddingValues = paddingValues, + ) + } +} + +@Composable +private fun ChipContent( + label: @Composable () -> Unit, + labelTextStyle: TextStyle, + labelColor: Color, + leadingIcon: @Composable (() -> Unit)?, + avatar: @Composable (() -> Unit)?, + trailingIcon: @Composable (() -> Unit)?, + leadingIconColor: Color, + trailingIconColor: Color, + minHeight: Dp, + paddingValues: PaddingValues, +) { + CompositionLocalProvider( + LocalContentColor provides labelColor, + LocalTextStyle provides labelTextStyle, + ) { + Row( + Modifier.defaultMinSize(minHeight = minHeight).padding(paddingValues), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + if (avatar != null) { + avatar() + } else if (leadingIcon != null) { + CompositionLocalProvider( + LocalContentColor provides leadingIconColor, content = leadingIcon, + ) + } + Spacer(Modifier.width(HorizontalElementsPadding)) + label() + Spacer(Modifier.width(HorizontalElementsPadding)) + if (trailingIcon != null) { + CompositionLocalProvider( + LocalContentColor provides trailingIconColor, content = trailingIcon, + ) + } + } + } +} + +/** + * The padding between the elements in the chip. + */ +private val HorizontalElementsPadding = 8.dp + +/** + * Returns the [PaddingValues] for the suggestion chip. + */ +private val SuggestionChipPadding = PaddingValues(horizontal = HorizontalElementsPadding) 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 000000000..25d70dd0e --- /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 000000000..c27c06712 --- /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 000000000..1cd80dac7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -0,0 +1,887 @@ +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.manga.components.SearchMetadataChips +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.source.online.MetadataSource +import eu.kanade.tachiyomi.ui.manga.ChapterItem +import eu.kanade.tachiyomi.ui.manga.MangaScreenState +import eu.kanade.tachiyomi.util.lang.toRelativeString +import exh.source.MERGED_SOURCE_ID +import exh.source.getMainSource +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)?, + onMetadataViewerClicked: () -> Unit, + onEditInfoClicked: () -> Unit, + onRecommendClicked: () -> Unit, + onMergedSettingsClicked: () -> Unit, + onMergeClicked: () -> Unit, + onMergeWithAnotherClicked: () -> 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, + onMetadataViewerClicked = onMetadataViewerClicked, + onEditInfoClicked = onEditInfoClicked, + onRecommendClicked = onRecommendClicked, + onMergedSettingsClicked = onMergedSettingsClicked, + onMergeClicked = onMergeClicked, + onMergeWithAnotherClicked = onMergeWithAnotherClicked, + 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, + onMetadataViewerClicked = onMetadataViewerClicked, + onEditInfoClicked = onEditInfoClicked, + onRecommendClicked = onRecommendClicked, + onMergedSettingsClicked = onMergedSettingsClicked, + onMergeClicked = onMergeClicked, + onMergeWithAnotherClicked = onMergeWithAnotherClicked, + 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)?, + onMetadataViewerClicked: () -> Unit, + onEditInfoClicked: () -> Unit, + onRecommendClicked: () -> Unit, + onMergedSettingsClicked: () -> Unit, + onMergeClicked: () -> Unit, + onMergeWithAnotherClicked: () -> 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, + onEditInfoClicked = onEditInfoClicked, + onRecommendClicked = onRecommendClicked, + showRecommendsInOverflow = state.showRecommendationsInOverflow, + showMergedSettings = state.manga.source == MERGED_SOURCE_ID, + onMergedSettingsClicked = onMergedSettingsClicked, + onMergeClicked = onMergeClicked, + doGlobalSearch = onSearch, + showMergeWithAnother = state.showMergeWithAnother, + onMergeWithAnotherClicked = onMergeWithAnotherClicked, + mangaMetadataHeader = remember { + getDescriptionComposable( + source = state.source.getMainSource>(), + state = state, + openMetadataViewer = onMetadataViewerClicked, + search = { onSearch(it, false) }, + ) + }, + searchMetadataChips = remember { SearchMetadataChips(state.meta, state.source, state.manga.genre) }, + 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)?, + onMetadataViewerClicked: () -> Unit, + onEditInfoClicked: () -> Unit, + onRecommendClicked: () -> Unit, + onMergedSettingsClicked: () -> Unit, + onMergeClicked: () -> Unit, + onMergeWithAnotherClicked: () -> 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, + showEditInfo = state.manga.favorite, + onEditInfoClicked = onEditInfoClicked, + showRecommends = state.showRecommendationsInOverflow, + onRecommendClicked = onRecommendClicked, + showMergeSettings = state.manga.source == MERGED_SOURCE_ID, + onMergedSettingsClicked = onMergedSettingsClicked, + 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, + onMergeClicked = onMergeClicked, + onTagClicked = onTagClicked, + onEditCategory = onEditCategoryClicked, + onCoverClick = onCoverClicked, + doSearch = onSearch, + showRecommendsInOverflow = state.showRecommendationsInOverflow, + showMergeWithAnother = state.showMergeWithAnother, + onRecommendClicked = onRecommendClicked, + onMergeWithAnotherClicked = onMergeWithAnotherClicked, + mangaMetadataHeader = remember { + getDescriptionComposable( + source = state.source.getMainSource>(), + state = state, + openMetadataViewer = onMetadataViewerClicked, + search = { onSearch(it, false) }, + ) + }, + searchMetadataChips = remember { SearchMetadataChips(state.meta, state.source, state.manga.genre) }, + ) + + 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) + } +} + +private fun getDescriptionComposable( + source: MetadataSource<*, *>?, + state: MangaScreenState.Success, + openMetadataViewer: () -> Unit, + search: (String) -> Unit, +): (@Composable () -> Unit)? { + return if (source != null) { + @Composable { + source.DescriptionComposable(state = state, openMetadataViewer = openMetadataViewer, search = search) + } + } else null +} 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 86c217fa4..5fcac70a7 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 000000000..b1ba69c79 --- /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 000000000..f4ae8ef72 --- /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 000000000..bbe0448d6 --- /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 000000000..452dae75c --- /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/MangaInfoButtons.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoButtons.kt new file mode 100644 index 000000000..f44cf07d8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoButtons.kt @@ -0,0 +1,45 @@ +package eu.kanade.presentation.manga.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.Button +import eu.kanade.tachiyomi.R + +@Composable +fun MangaInfoButtons( + showRecommendsButton: Boolean, + showMergeWithAnotherButton: Boolean, + onRecommendClicked: () -> Unit, + onMergeWithAnotherClicked: () -> Unit, +) { + if (showRecommendsButton || showMergeWithAnotherButton) { + Column(Modifier.fillMaxWidth()) { + if (showMergeWithAnotherButton) { + Button( + onClick = onMergeWithAnotherClicked, + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text(stringResource(R.string.merge_with_another_source)) + } + } + if (showRecommendsButton) { + Button( + onClick = onRecommendClicked, + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text(stringResource(R.string.az_recommends)) + } + } + } + } +} 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 000000000..8c4ea4522 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt @@ -0,0 +1,630 @@ +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.material.icons.outlined.CallMerge +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +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)?, + onMergeClicked: () -> Unit, + onTagClicked: (String) -> Unit, + onEditCategory: (() -> Unit)?, + onCoverClick: () -> Unit, + doSearch: (query: String, global: Boolean) -> Unit, + onRecommendClicked: () -> Unit, + showRecommendsInOverflow: Boolean, + showMergeWithAnother: Boolean, + onMergeWithAnotherClicked: () -> Unit, + mangaMetadataHeader: (@Composable () -> Unit)?, + searchMetadataChips: SearchMetadataChips?, +) { + 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, + ) + } + } + + // SY --> Manga metadata + mangaMetadataHeader?.invoke() + // SY <-- + + // 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, + ) + } + MangaActionButton( + title = stringResource(R.string.merge), + icon = Icons.Outlined.CallMerge, + color = defaultActionButtonColor, + onClick = onMergeClicked, + ) + } + + // 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) { + if (searchMetadataChips != null) { + NamespaceTags( + tags = searchMetadataChips, + onClick = onTagClicked, + onLongClick = { doSearch(it, true) }, + ) + } else { + FlowRow( + modifier = Modifier.padding(horizontal = 16.dp), + mainAxisSpacing = 4.dp, + crossAxisSpacing = 8.dp, + ) { + tags.forEach { + TagsChip( + text = it, + onClick = { onTagClicked(it) }, + onLongClick = { doSearch(it, true) }, + ) + } + } + } + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(items = tags) { + TagsChip( + text = it, + onClick = { onTagClicked(it) }, + onLongClick = { doSearch(it, true) }, + ) + } + } + } + } + } + } + // SY --> + MangaInfoButtons( + showRecommendsButton = !showRecommendsInOverflow, + showMergeWithAnotherButton = showMergeWithAnother, + onRecommendClicked = onRecommendClicked, + onMergeWithAnotherClicked = onMergeWithAnotherClicked, + ) + // SY <-- + } +} + +@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 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 000000000..642e417b4 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt @@ -0,0 +1,274 @@ +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)?, + showEditInfo: Boolean, + onEditInfoClicked: () -> Unit, + showRecommends: Boolean, + onRecommendClicked: () -> Unit, + showMergeSettings: Boolean, + onMergedSettingsClicked: () -> 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 || showEditInfo || showRecommends || showMergeSettings) { + 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, + ) { + if (onEditCategoryClicked != null) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_edit_categories)) }, + onClick = { + onEditCategoryClicked() + onDismissRequest() + }, + ) + } + if (onMigrateClicked != null) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_migrate)) }, + onClick = { + onMigrateClicked() + onDismissRequest() + }, + ) + } + if (showEditInfo) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_edit_info)) }, + onClick = { + onEditInfoClicked() + onDismissRequest() + }, + ) + } + if (showRecommends) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.az_recommends)) }, + onClick = { + onRecommendClicked() + onDismissRequest() + }, + ) + } + if (showMergeSettings) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.merge_settings)) }, + onClick = { + onMergedSettingsClicked() + 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 000000000..e2e304389 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaTopAppBar.kt @@ -0,0 +1,167 @@ +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)?, + onEditInfoClicked: () -> Unit, + onRecommendClicked: () -> Unit, + showRecommendsInOverflow: Boolean, + showMergedSettings: Boolean, + onMergedSettingsClicked: () -> Unit, + onMergeClicked: () -> Unit, + showMergeWithAnother: Boolean, + onMergeWithAnotherClicked: () -> Unit, + doGlobalSearch: (query: String, global: Boolean) -> Unit, + mangaMetadataHeader: (@Composable () -> Unit)?, + searchMetadataChips: SearchMetadataChips?, + 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, + onMergeClicked = onMergeClicked, + onTagClicked = onTagClicked, + onEditCategory = onEditCategoryClicked, + onCoverClick = onCoverClick, + doSearch = doGlobalSearch, + showRecommendsInOverflow = showRecommendsInOverflow, + showMergeWithAnother = showMergeWithAnother, + onRecommendClicked = onRecommendClicked, + onMergeWithAnotherClicked = onMergeWithAnotherClicked, + mangaMetadataHeader = mangaMetadataHeader, + searchMetadataChips = searchMetadataChips, + ) + + 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, + // SY --> + showEditInfo = favorite, + onEditInfoClicked = onEditInfoClicked, + showRecommends = showRecommendsInOverflow, + onRecommendClicked = onRecommendClicked, + showMergeSettings = showMergedSettings, + onMergedSettingsClicked = onMergedSettingsClicked, + // SY <-- + 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/manga/components/NamespaceTags.kt b/app/src/main/java/eu/kanade/presentation/manga/components/NamespaceTags.kt new file mode 100644 index 000000000..a65629367 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/NamespaceTags.kt @@ -0,0 +1,216 @@ +package eu.kanade.presentation.manga.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ChipBorder +import androidx.compose.material3.LocalMinimumTouchTargetEnforcement +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.accompanist.flowlayout.FlowRow +import eu.kanade.presentation.components.SuggestionChip +import eu.kanade.presentation.theme.TachiyomiTheme +import eu.kanade.tachiyomi.source.online.all.EHentai +import exh.metadata.metadata.EHentaiSearchMetadata +import exh.metadata.metadata.base.RaisedSearchMetadata +import exh.metadata.metadata.base.RaisedTag +import exh.source.EH_SOURCE_ID +import exh.source.EXH_SOURCE_ID +import exh.util.SourceTagsUtil +import tachiyomi.source.Source + +@Immutable +data class DisplayTag( + val namespace: String?, + val text: String, + val search: String, + val border: Dp?, +) + +@Immutable +@JvmInline +value class SearchMetadataChips( + val tags: Map>, +) { + companion object { + operator fun invoke(meta: RaisedSearchMetadata?, source: Source, tags: List?): SearchMetadataChips? { + return if (meta != null) { + SearchMetadataChips( + meta.tags + .filterNot { it.type == RaisedSearchMetadata.TAG_TYPE_VIRTUAL } + .map { + DisplayTag( + namespace = it.namespace, + text = it.name, + search = if (!it.namespace.isNullOrEmpty()) { + SourceTagsUtil.getWrappedTag(source.id, namespace = it.namespace, tag = it.name) + } else { + SourceTagsUtil.getWrappedTag(source.id, fullTag = it.name) + } ?: it.name, + border = if (source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID) { + when (it.type) { + EHentaiSearchMetadata.TAG_TYPE_NORMAL -> 3.dp + EHentaiSearchMetadata.TAG_TYPE_LIGHT -> 1.dp + else -> null + } + } else null, + ) + } + .groupBy { it.namespace.orEmpty() }, + ) + } else if (tags != null && tags.all { it.contains(':') }) { + SearchMetadataChips( + tags + .map { tag -> + val index = tag.indexOf(':') + DisplayTag(tag.substring(0, index).trim(), tag.substring(index + 1).trim(), tag, null) + } + .groupBy { + it.namespace.orEmpty() + }, + ) + } else null + } + } +} + +@Composable +fun NamespaceTags( + tags: SearchMetadataChips, + onClick: (item: String) -> Unit, + onLongClick: (item: String) -> Unit, +) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + tags.tags.forEach { (namespace, tags) -> + Row(Modifier.padding(start = 16.dp)) { + if (namespace.isNotEmpty()) { + TagsChip(namespace, onClick = null, onLongClick = null) + } + FlowRow( + modifier = Modifier.padding(start = 8.dp, end = 16.dp), + mainAxisSpacing = 4.dp, + crossAxisSpacing = 8.dp, + ) { + tags.forEach { (_, text, search, border) -> + TagsChip( + text = text, + onClick = { onClick(search) }, + onLongClick = { onLongClick(search) }, + border = border?.let { SuggestionChipDefaults.suggestionChipBorder(borderWidth = it) }, + ) + } + } + } + } + } +} + +@Composable +fun TagsChip( + text: String, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + border: ChipBorder? = null, +) { + CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) { + if (onClick != null) { + if (onLongClick != null) { + SuggestionChip( + onClick = onClick, + onLongClick = onLongClick, + label = { Text(text = text, style = MaterialTheme.typography.bodySmall) }, + border = border, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + labelColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } else { + SuggestionChip( + onClick = onClick, + label = { Text(text = text, style = MaterialTheme.typography.bodySmall) }, + border = border, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + labelColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } + } else { + SuggestionChip( + label = { Text(text = text, style = MaterialTheme.typography.bodySmall) }, + border = border, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + labelColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } + } +} + +@Preview +@Composable +fun NamespaceTagsPreview() { + TachiyomiTheme { + Surface { + val context = LocalContext.current + NamespaceTags( + tags = remember { + EHentaiSearchMetadata().apply { + this.tags.addAll( + arrayOf( + RaisedTag( + "Male", + "Test", + EHentaiSearchMetadata.TAG_TYPE_NORMAL, + ), + RaisedTag( + "Male", + "Test2", + EHentaiSearchMetadata.TAG_TYPE_WEAK, + ), + RaisedTag( + "Male", + "Test3", + EHentaiSearchMetadata.TAG_TYPE_LIGHT, + ), + RaisedTag( + "Female", + "Test", + EHentaiSearchMetadata.TAG_TYPE_NORMAL, + ), + RaisedTag( + "Female", + "Test2", + EHentaiSearchMetadata.TAG_TYPE_WEAK, + ), + RaisedTag( + "Female", + "Test3", + EHentaiSearchMetadata.TAG_TYPE_LIGHT, + ), + ), + ) + }.let { SearchMetadataChips(it, EHentai(EXH_SOURCE_ID, true, context), emptyList()) }!! + }, + onClick = {}, + onLongClick = {}, + ) + } + } +} 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 adf7cd80c..7fb9235c5 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 000000000..cd9351eb5 --- /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 000000000..1f5ee9e4d --- /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/backup/AbstractBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt index 4c03e0261..d249975ce 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt @@ -111,6 +111,7 @@ abstract class AbstractBackupManager(protected val context: Context) { coverLastModified = manga.cover_last_modified, dateAdded = manga.date_added, mangaId = manga.id!!, + filteredScanlators = manga.filtered_scanlators, ) } return manga.id!! diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt index 403f8098b..dc3cd2b15 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt @@ -97,7 +97,7 @@ data class BackupManga( artist = customArtist, description = customDescription, genre = customGenre, - status = customStatus.takeUnless { it == 0 }, + status = customStatus.takeUnless { it == 0 }?.toLong(), ) } return null @@ -138,7 +138,7 @@ data class BackupManga( backupManga.customAuthor = it.author backupManga.customDescription = it.description backupManga.customGenre = it.genre - backupManga.customStatus = it.status ?: 0 + backupManga.customStatus = it.status?.toInt() ?: 0 } } // SY <-- 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 589ed671d..2437e0a39 100755 --- 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 4050189d1..2a97c80d8 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 @@ -16,4 +16,26 @@ class LibraryManga : MangaImpl() { // SY --> var read: Int = 0 // SY <-- + + 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 + if (read != other.read) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + unreadCount + result = 31 * result + readCount + result = 31 * result + category + result = 31 * result + read + return result + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt index 7fbccd02a..930da74e2 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt @@ -42,7 +42,7 @@ open class MangaImpl : Manga { set(value) { ogGenre = value } override var status: Int - get() = if (favorite) customManga?.status ?: ogStatus else ogStatus + get() = if (favorite) customManga?.status?.toInt() ?: ogStatus else ogStatus set(value) { ogStatus = value } // SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt index e590f9336..27b13a9eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt @@ -77,7 +77,7 @@ class CustomMangaManager(val context: Context) { val artist: String? = null, val description: String? = null, val genre: List? = null, - val status: Int? = null, + val status: Long? = null, ) { fun toManga() = CustomMangaInfo( @@ -87,7 +87,7 @@ class CustomMangaManager(val context: Context) { artist = this@MangaJson.artist, description = this@MangaJson.description, genre = this@MangaJson.genre, - status = this@MangaJson.status?.takeUnless { it == 0 }, + status = this@MangaJson.status?.takeUnless { it == 0L }, ) } @@ -98,7 +98,7 @@ class CustomMangaManager(val context: Context) { val artist: String? = null, val description: String? = null, val genre: List? = null, - val status: Int? = null, + val status: Long? = null, ) { val genreString by lazy { genre?.joinToString() 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 30bebb1c6..829c4c587 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -150,3 +150,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 1db75179a..fa2ffc115 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -58,6 +58,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/source/online/MetadataSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MetadataSource.kt index e900180b9..629abeb4e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/MetadataSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MetadataSource.kt @@ -112,10 +112,8 @@ interface MetadataSource : CatalogueSource { } } - fun getDescriptionAdapter(controller: MangaController): RecyclerView.Adapter<*>? - @Composable - fun DescriptionComposable(controller: MangaController) + fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) fun MangaInfo.id() = db.getManga(key, id).executeAsBlocking()?.id val SManga.id get() = (this as? Manga)?.id diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt index 22ba67f4e..2da4015ff 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt @@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.lang.runAsObservable import eu.kanade.tachiyomi.util.lang.withIOContext @@ -46,7 +46,6 @@ import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.toGenreString import exh.metadata.metadata.base.RaisedTag import exh.ui.login.EhLoginActivity import exh.ui.metadata.adapters.EHentaiDescription -import exh.ui.metadata.adapters.EHentaiDescriptionAdapter import exh.util.UriFilter import exh.util.UriGroup import exh.util.asObservableWithAsyncStacktrace @@ -84,7 +83,6 @@ import tachiyomi.source.model.ChapterInfo import tachiyomi.source.model.MangaInfo import uy.kohesive.injekt.injectLazy import java.net.URLEncoder -import java.util.ArrayList // TODO Consider gallery updating when doing tabbed browsing class EHentai( @@ -1071,13 +1069,9 @@ class EHentai( return "${uri.scheme}://${uri.host}/g/${obj["gid"]!!.jsonPrimitive.int}/${obj["token"]!!.jsonPrimitive.content}/" } - override fun getDescriptionAdapter(controller: MangaController): EHentaiDescriptionAdapter { - return EHentaiDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - EHentaiDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + EHentaiDescription(state, openMetadataViewer, search) } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/Hitomi.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/Hitomi.kt index 5b6377934..f31bd8f28 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/Hitomi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/Hitomi.kt @@ -11,14 +11,13 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.asJsoup import exh.metadata.metadata.HitomiSearchMetadata import exh.metadata.metadata.base.RaisedSearchMetadata import exh.metadata.metadata.base.RaisedTag import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.HitomiDescription -import exh.ui.metadata.adapters.HitomiDescriptionAdapter import exh.util.urlImportFetchSearchManga import org.jsoup.nodes.Document import tachiyomi.source.model.MangaInfo @@ -138,13 +137,9 @@ class Hitomi(delegate: HttpSource, val context: Context) : return "https://hitomi.la/manga/${uri.pathSegments[1].substringBefore('.')}.html" } - override fun getDescriptionAdapter(controller: MangaController): HitomiDescriptionAdapter { - return HitomiDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - HitomiDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + HitomiDescription(state, openMetadataViewer) } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt index 5ab3d8858..f4c26d7a7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt @@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.RandomMangaSource import eu.kanade.tachiyomi.source.online.UrlImportableSource import eu.kanade.tachiyomi.ui.base.controller.BaseController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.lang.runAsObservable import exh.md.MangaDexFabHeaderAdapter import exh.md.dto.MangaDto @@ -50,7 +50,6 @@ import exh.md.utils.MdUtil import exh.metadata.metadata.MangaDexSearchMetadata import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.MangaDexDescription -import exh.ui.metadata.adapters.MangaDexDescriptionAdapter import okhttp3.OkHttpClient import okhttp3.Response import rx.Observable @@ -217,13 +216,9 @@ class MangaDex(delegate: HttpSource, val context: Context) : // MetadataSource methods override val metaClass: KClass = MangaDexSearchMetadata::class - override fun getDescriptionAdapter(controller: MangaController): MangaDexDescriptionAdapter { - return MangaDexDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - MangaDexDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + MangaDexDescription(state, openMetadataViewer) } override suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Triple, StatisticsMangaDto>) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt index fcc242640..2ca77f077 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt @@ -11,13 +11,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import exh.metadata.metadata.NHentaiSearchMetadata import exh.metadata.metadata.base.RaisedSearchMetadata import exh.metadata.metadata.base.RaisedTag import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.NHentaiDescription -import exh.ui.metadata.adapters.NHentaiDescriptionAdapter import exh.util.trimOrNull import exh.util.urlImportFetchSearchManga import kotlinx.serialization.SerialName @@ -170,13 +169,9 @@ class NHentai(delegate: HttpSource, val context: Context) : return "$baseUrl/g/${uri.pathSegments[1]}/" } - override fun getDescriptionAdapter(controller: MangaController): NHentaiDescriptionAdapter { - return NHentaiDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - NHentaiDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + NHentaiDescription(state, openMetadataViewer) } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/PervEden.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/PervEden.kt index c7f195084..603497884 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/PervEden.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/PervEden.kt @@ -10,14 +10,13 @@ import eu.kanade.tachiyomi.source.model.toSManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.asJsoup import exh.metadata.metadata.PervEdenSearchMetadata import exh.metadata.metadata.base.RaisedSearchMetadata import exh.metadata.metadata.base.RaisedTag import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.PervEdenDescription -import exh.ui.metadata.adapters.PervEdenDescriptionAdapter import exh.util.urlImportFetchSearchManga import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -135,12 +134,8 @@ class PervEden(delegate: HttpSource, val context: Context) : return newUri.toString() } - override fun getDescriptionAdapter(controller: MangaController): PervEdenDescriptionAdapter { - return PervEdenDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - PervEdenDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + PervEdenDescription(state, openMetadataViewer) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt index 443facffb..387271ec7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt @@ -11,13 +11,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.asJsoup import exh.metadata.metadata.EightMusesSearchMetadata import exh.metadata.metadata.base.RaisedTag import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.EightMusesDescription -import exh.ui.metadata.adapters.EightMusesDescriptionAdapter import exh.util.urlImportFetchSearchManga import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -99,12 +98,8 @@ class EightMuses(delegate: HttpSource, val context: Context) : return "/comics/album/${path.joinToString("/")}" } - override fun getDescriptionAdapter(controller: MangaController): EightMusesDescriptionAdapter { - return EightMusesDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - EightMusesDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + EightMusesDescription(state, openMetadataViewer) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt index 8e6259d4e..f1d6ea968 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt @@ -10,13 +10,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.asJsoup import exh.metadata.metadata.HBrowseSearchMetadata import exh.metadata.metadata.base.RaisedTag import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.HBrowseDescription -import exh.ui.metadata.adapters.HBrowseDescriptionAdapter import exh.util.urlImportFetchSearchManga import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -87,12 +86,8 @@ class HBrowse(delegate: HttpSource, val context: Context) : return uri.pathSegments.firstOrNull()?.let { "/$it/c00001/" } } - override fun getDescriptionAdapter(controller: MangaController): HBrowseDescriptionAdapter { - return HBrowseDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - HBrowseDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + HBrowseDescription(state, openMetadataViewer) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Pururin.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Pururin.kt index 12797cd82..d1a12699c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Pururin.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Pururin.kt @@ -12,14 +12,13 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.asJsoup import exh.metadata.metadata.PururinSearchMetadata import exh.metadata.metadata.base.RaisedSearchMetadata import exh.metadata.metadata.base.RaisedTag import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.PururinDescription -import exh.ui.metadata.adapters.PururinDescriptionAdapter import exh.util.dropBlank import exh.util.trimAll import exh.util.urlImportFetchSearchManga @@ -117,12 +116,8 @@ class Pururin(delegate: HttpSource, val context: Context) : return "${PururinSearchMetadata.BASE_URL}/gallery/${uri.pathSegments.getOrNull(1)}/${uri.lastPathSegment}" } - override fun getDescriptionAdapter(controller: MangaController): PururinDescriptionAdapter { - return PururinDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - PururinDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + PururinDescription(state, openMetadataViewer) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Tsumino.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Tsumino.kt index 07cc12f3c..7bb3edcf8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Tsumino.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Tsumino.kt @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.NamespaceSource import eu.kanade.tachiyomi.source.online.UrlImportableSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.asJsoup import exh.metadata.metadata.TsuminoSearchMetadata import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT @@ -19,7 +19,6 @@ import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUA import exh.metadata.metadata.base.RaisedTag import exh.source.DelegatedHttpSource import exh.ui.metadata.adapters.TsuminoDescription -import exh.ui.metadata.adapters.TsuminoDescriptionAdapter import exh.util.dropBlank import exh.util.trimAll import exh.util.urlImportFetchSearchManga @@ -144,12 +143,8 @@ class Tsumino(delegate: HttpSource, val context: Context) : val RATING_FAVORITES_REGEX = "/ ([0-9].*) favs".toRegex() } - override fun getDescriptionAdapter(controller: MangaController): TsuminoDescriptionAdapter { - return TsuminoDescriptionAdapter(controller) - } - @Composable - override fun DescriptionComposable(controller: MangaController) { - TsuminoDescription(controller) + override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { + TsuminoDescription(state, openMetadataViewer) } } 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 d3bfb3603..000000000 --- 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/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 42306d554..0ab4f11d7 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -682,7 +682,7 @@ class LibraryPresenter( manga.artist.takeUnless { it == manga.originalArtist }, manga.description.takeUnless { it == manga.originalDescription }, manga.genre.takeUnless { it == manga.originalGenre }?.let { manga.getGenres() }, - manga.status.takeUnless { it == manga.originalStatus }, + manga.status.takeUnless { it == manga.originalStatus }?.toLong(), ) } if (mangaJson != null) { 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 9b531a85a..3efc99724 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -474,7 +474,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))) @@ -655,6 +655,9 @@ class MainActivity : BaseActivity() { } val isFullComposeController = internalTo is FullComposeController<*> + binding.appbar.isVisible = !isFullComposeController + binding.controllerContainer.enableScrollingBehavior(!isFullComposeController) + if (!isTablet()) { // Save lift state if (isPush) { @@ -677,17 +680,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/EditMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt index 0d27cd499..39f033c76 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.manga import android.app.Dialog import android.content.Context -import android.net.Uri import android.os.Bundle import android.widget.ArrayAdapter import android.widget.ScrollView @@ -14,9 +13,9 @@ import coil.transform.RoundedCornersTransformation import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder +import eu.kanade.domain.manga.interactor.GetMangaById +import eu.kanade.domain.manga.model.Manga import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.databinding.EditMangaDialogBinding import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.model.SManga @@ -28,6 +27,7 @@ import exh.util.dropBlank import exh.util.trimOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking import reactivecircus.flowbinding.android.view.clicks import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -38,17 +38,13 @@ class EditMangaDialog : DialogController { private val manga: Manga - private var customCoverUri: Uri? = null - - private var willResetCover = false - private val infoController get() = targetController as MangaController private val context: Context get() = binding.root.context constructor(target: MangaController, manga: Manga) : super( - bundleOf(KEY_MANGA to manga.id!!), + bundleOf(KEY_MANGA to manga.id), ) { targetController = target this.manga = manga @@ -56,8 +52,7 @@ class EditMangaDialog : DialogController { @Suppress("unused") constructor(bundle: Bundle) : super(bundle) { - manga = Injekt.get().getManga(bundle.getLong(KEY_MANGA)) - .executeAsBlocking()!! + manga = runBlocking { Injekt.get().await(bundle.getLong(KEY_MANGA))!! } } override fun onCreateDialog(savedViewState: Bundle?): Dialog { @@ -93,9 +88,9 @@ class EditMangaDialog : DialogController { ) binding.status.adapter = statusAdapter - if (manga.status != manga.originalStatus) { + if (manga.status != manga.ogStatus) { binding.status.setSelection( - when (manga.status) { + when (manga.status.toInt()) { SManga.UNKNOWN -> 0 SManga.ONGOING -> 1 SManga.COMPLETED -> 2 @@ -117,76 +112,61 @@ class EditMangaDialog : DialogController { binding.mangaAuthor.setText(manga.author.orEmpty()) binding.mangaArtist.setText(manga.artist.orEmpty()) binding.mangaDescription.setText(manga.description.orEmpty()) - binding.mangaGenresTags.setChips(manga.getGenres().orEmpty().dropBlank()) + binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank()) } else { - if (manga.title != manga.originalTitle) { + if (manga.title != manga.ogTitle) { binding.title.append(manga.title) } - if (manga.author != manga.originalAuthor) { + if (manga.author != manga.ogAuthor) { binding.mangaAuthor.append(manga.author.orEmpty()) } - if (manga.artist != manga.originalArtist) { + if (manga.artist != manga.ogArtist) { binding.mangaArtist.append(manga.artist.orEmpty()) } - if (manga.description != manga.originalDescription) { + if (manga.description != manga.ogDescription) { binding.mangaDescription.append(manga.description.orEmpty()) } - binding.mangaGenresTags.setChips(manga.getGenres().orEmpty().dropBlank()) + binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank()) - binding.title.hint = context.getString(R.string.title_hint, manga.originalTitle) - if (manga.originalAuthor != null) { - binding.mangaAuthor.hint = context.getString(R.string.author_hint, manga.originalAuthor) + binding.title.hint = context.getString(R.string.title_hint, manga.ogTitle) + if (manga.ogAuthor != null) { + binding.mangaAuthor.hint = context.getString(R.string.author_hint, manga.ogAuthor) } - if (manga.originalArtist != null) { - binding.mangaArtist.hint = context.getString(R.string.artist_hint, manga.originalArtist) + if (manga.ogArtist != null) { + binding.mangaArtist.hint = context.getString(R.string.artist_hint, manga.ogArtist) } - if (manga.originalDescription != null) { + if (!manga.ogDescription.isNullOrBlank()) { binding.mangaDescription.hint = context.getString( R.string.description_hint, - manga.originalDescription?.replace( - "\n", - " ", - )?.chop(20), + manga.ogDescription.replace("\n", " ").chop(20), ) } } binding.mangaGenresTags.clearFocus() - /*binding.coverLayout.clicks() - .onEach { infoController.changeCover() } - .launchIn(infoController.viewScope)*/ + binding.resetTags.clicks() .onEach { resetTags() } .launchIn(infoController.viewScope) } private fun resetTags() { - if (manga.genre.isNullOrBlank() || manga.source == LocalSource.ID) { + if (manga.genre.isNullOrEmpty() || manga.source == LocalSource.ID) { binding.mangaGenresTags.setChips(emptyList()) } else { - binding.mangaGenresTags.setChips(manga.getOriginalGenres().orEmpty()) + binding.mangaGenresTags.setChips(manga.ogGenre.orEmpty()) } } - fun loadCover() { + private fun loadCover() { val radius = context.resources.getDimension(R.dimen.card_radius) binding.mangaCover.load(manga) { transformations(RoundedCornersTransformation(radius)) } } - fun updateCover(uri: Uri) { - willResetCover = false - val radius = context.resources.getDimension(R.dimen.card_radius) - binding.mangaCover.load(uri) { - transformations(RoundedCornersTransformation(radius)) - } - customCoverUri = uri - } - private fun onPositiveButtonClick() { infoController.presenter.updateMangaInfo( - context, binding.title.text.toString(), binding.mangaAuthor.text.toString(), binding.mangaArtist.text.toString(), @@ -202,9 +182,7 @@ class EditMangaDialog : DialogController { 6 -> SManga.ON_HIATUS else -> null } - }, - customCoverUri, - willResetCover, + }?.toLong(), ) } 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 48ce18184..13f568478 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,59 +1,47 @@ 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.dialog.MaterialAlertDialogBuilder -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 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.CatalogueSource -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.popControllerWithTag import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction @@ -67,16 +55,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.MangaInfoButtonsAdapter -import eu.kanade.tachiyomi.ui.manga.info.MangaInfoHeaderAdapter import eu.kanade.tachiyomi.ui.manga.merged.EditMergedSettingsDialog import eu.kanade.tachiyomi.ui.manga.track.TrackItem import eu.kanade.tachiyomi.ui.manga.track.TrackSearchDialog @@ -85,501 +66,187 @@ 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.lang.launchUI 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 exh.log.xLogD +import eu.kanade.tachiyomi.widget.materialdialogs.await import exh.md.similar.MangaDexSimilarController -import exh.metadata.metadata.base.RaisedSearchMetadata import exh.recs.RecommendsController import exh.source.MERGED_SOURCE_ID import exh.source.getMainSource import exh.source.isMdBasedSource +import exh.ui.metadata.MetadataViewController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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 +import eu.kanade.domain.manga.model.Manga as DomainManga 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, smartSearchConfig: SourcesController.SmartSearchConfig? = null, update: Boolean = false) : super( - bundleOf( - MANGA_EXTRA to mangaId, - FROM_SOURCE_EXTRA to fromSource, - // SY --> - SMART_SEARCH_CONFIG_EXTRA to smartSearchConfig, - UPDATE_EXTRA to update, - // SY <-- - ), - ) { - 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)) + constructor( + mangaId: Long, + fromSource: Boolean = false, + smartSearchConfig: SourcesController.SmartSearchConfig? = null, + update: Boolean = false, + ) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource, SMART_SEARCH_CONFIG_EXTRA to smartSearchConfig, UPDATE_EXTRA to update)) { + this.mangaId = mangaId + } + // SY --> constructor(redirect: MangaPresenter.EXHRedirect) : super( bundleOf( - MANGA_EXTRA to (redirect.manga.id ?: 0), + MANGA_EXTRA to redirect.mangaId, UPDATE_EXTRA to redirect.update, ), ) { - this.manga = redirect.manga - if (manga != null) { - source = Injekt.get().getOrStub(redirect.manga.source) - } + this.mangaId = redirect.mangaId } // SY <-- - var manga: Manga? = null - private set + var mangaId: Long - var source: Source? = null - private set - - 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 // SY --> - private var mangaInfoButtonsAdapter: MangaInfoButtonsAdapter? = null - // SY <-- - - // Sheet containing filter/sort/display items. - private var settingsSheet: ChaptersSettingsSheet? = null - - private var actionFab: ExtendedFloatingActionButton? = null - private var actionFabScrollListener: RecyclerView.OnScrollListener? = null - - // 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() - } - } - - // EXH --> val smartSearchConfig: SourcesController.SmartSearchConfig? = args.getParcelable( SMART_SEARCH_CONFIG_EXTRA, ) + // SY <-- - private var editMangaDialog: EditMangaDialog? = null + // Sheet containing filter/sort/display items. + private lateinit var settingsSheet: ChaptersSettingsSheet - private var editMergedSettingsDialog: EditMergedSettingsDialog? = null - // EXH <-- + private lateinit var trackSheet: TrackSheet - 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), + // SY --> + smartSearched = smartSearchConfig != null, + // SY <-- ) } - 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) - // SY --> - if (!preferences.recommendsInOverflow().get() || smartSearchConfig != null) { - mangaInfoButtonsAdapter = MangaInfoButtonsAdapter(this).apply { - setHasStableIds(true) - } - } - // SY <-- - - // Phone layout - binding.fullRecycler?.let { - val config = ConcatAdapter.Config.Builder() - .setIsolateViewTypes(true) - .setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS) - .build() - it.adapter = ConcatAdapter( - config, - listOfNotNull( - mangaInfoAdapter, - mangaInfoButtonsAdapter, - chaptersHeaderAdapter, - chaptersAdapter, - ), + @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, + // SY --> + onWebViewClicked = { if (successState.mergedData == null) openMangaInWebView() else openMergedMangaWebview() }.takeIf { isHttpSource }, + // SY <-- + 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 }, + // SY --> + onMetadataViewerClicked = this::openMetadataViewer, + onEditInfoClicked = this::openEditMangaInfoDialog, + onRecommendClicked = this::openRecommends, + onMergedSettingsClicked = this::openMergedSettingsDialog, + onMergeClicked = this::openSmartSearch, + onMergeWithAnotherClicked = this::mergeWithAnother, + // SY <-- + onMultiBookmarkClicked = presenter::bookmarkChapters, + onMultiMarkAsReadClicked = presenter::markChaptersRead, + onMarkPreviousAsReadClicked = presenter::markPreviousChapterRead, + onMultiDeleteClicked = this::deleteChaptersWithConfirmation, ) - - 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 + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } } + } - // Tablet layout - binding.infoRecycler?.adapter = ConcatAdapter( - listOfNotNull( - mangaInfoAdapter, - mangaInfoButtonsAdapter, - ), - ) - 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) - - presenter.redirectFlow - .onEach { redirect -> - xLogD("Redirecting to updated manga (manga.id: %s, manga.title: %s, update: %s)!", redirect.manga.id, redirect.manga.title, redirect.update) - // Replace self - router?.replaceTopController(MangaController(redirect).withFadeTransaction()) - } - .launchIn(viewScope) - - 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 - - // SY --> - menu.findItem(R.id.action_edit).isVisible = presenter.manga.favorite || isLocalSource - menu.findItem(R.id.action_recommend).isVisible = preferences.recommendsInOverflow().get() - menu.findItem(R.id.action_merged).isVisible = presenter.manga.source == MERGED_SOURCE_ID - menu.findItem(R.id.action_toggle_dedupe).isVisible = false // presenter.manga.source == MERGED_SOURCE_ID - // SY <-- - } - } - - 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) - - // SY --> - R.id.action_edit -> { - editMangaDialog = EditMangaDialog( - this, - presenter.manga, - ) - editMangaDialog?.showDialog(router) - } - - R.id.action_recommend -> { - openRecommends() - } - R.id.action_merged -> { - editMergedSettingsDialog = EditMergedSettingsDialog( - this, - presenter.manga, - ) - editMergedSettingsDialog?.showDialog(router) - } - R.id.action_toggle_dedupe -> { - presenter.dedupe = !presenter.dedupe - presenter.toggleDedupe() - } - // SY <-- - - 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, metadata: RaisedSearchMetadata?) { - if (manga.initialized) { - // Update view. - mangaInfoAdapter?.update(manga, source, metadata, presenter.mergedMangaReferences) - } 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) + // SY --> + private fun openEditMangaInfoDialog() { + EditMangaDialog( + this, + presenter.manga ?: return, + ).showDialog(router) } - // SY --> - fun openMergedMangaWebview() { + private fun openMergedSettingsDialog() { + EditMergedSettingsDialog( + this, + presenter.manga!!.toDbManga(), + ).showDialog(router) + } + + private fun openMetadataViewer() { + router.pushController(MetadataViewController(presenter.manga ?: return)) + } + + private fun openMergedMangaWebview() { val sourceManager: SourceManager = Injekt.get() - val mergedManga = presenter.mergedManga.values.filterNot { it.source == MERGED_SOURCE_ID } + val mergedManga = (presenter.state.value as? MangaScreenState.Success)?.mergedData?.manga?.values?.filterNot { it.source == MERGED_SOURCE_ID } + .orEmpty() val sources = mergedManga.map { sourceManager.getOrStub(it.source) } MaterialAlertDialogBuilder(activity!!) .setTitle(R.string.action_open_in_web_view) @@ -596,11 +263,11 @@ class MangaController : } // SY <-- - fun openMangaInWebView(manga: Manga = presenter.manga, source: HttpSource? = presenter.source as? HttpSource) { + private fun openMangaInWebView(manga: DomainManga? = presenter.manga, source: HttpSource? = presenter.source as? HttpSource) { source ?: return - + manga ?: return val url = try { - source.mangaDetailsRequest(manga).url.toString() + source.mangaDetailsRequest(manga.toDbManga()).url.toString() } catch (e: Exception) { return } @@ -612,10 +279,10 @@ class MangaController : 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) @@ -626,109 +293,53 @@ 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) + 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 { - addToLibrary(manga) + 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() - } - - private fun addToLibrary(newManga: Manga) { - launchIO { - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() } - - 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, - ) - } - } - } - } - - 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" - } - } - } - } - } - } - - // SY --> - fun setRefreshing() { - isRefreshingInfo = true - updateRefreshing() - } - // SY <-- - // EXH --> fun openSmartSearch() { - val smartSearchConfig = SourcesController.SmartSearchConfig(presenter.manga.title, presenter.manga.id) + val manga = presenter.manga ?: return + val smartSearchConfig = SourcesController.SmartSearchConfig(manga.title, manga.id) router?.pushController( SourcesController( @@ -739,34 +350,36 @@ class MangaController : ) } - suspend fun mergeWithAnother() { - try { - val mergedManga = withContext(Dispatchers.IO + NonCancellable) { - presenter.smartSearchMerge(applicationContext!!, presenter.manga, smartSearchConfig?.origMangaId!!) + private fun mergeWithAnother() { + launchUI { + try { + val mergedManga = withContext(Dispatchers.IO + NonCancellable) { + presenter.smartSearchMerge(applicationContext!!, presenter.manga!!, smartSearchConfig?.origMangaId!!) + } + + router?.popControllerWithTag(SMART_SEARCH_SOURCE_TAG) + router?.popCurrentController() + router?.replaceTopController( + MangaController( + mergedManga.id!!, + true, + update = true, + ).withFadeTransaction(), + ) + applicationContext?.toast(R.string.manga_merged) + } catch (e: Exception) { + if (e is CancellationException) throw e + + val activity = activity ?: return@launchUI + activity.toast(activity.getString(R.string.failed_merge, e.message)) } - - router?.popControllerWithTag(SMART_SEARCH_SOURCE_TAG) - router?.popCurrentController() - router?.replaceTopController( - MangaController( - mergedManga.id!!, - true, - update = true, - ).withFadeTransaction(), - ) - applicationContext?.toast(R.string.manga_merged) - } catch (e: Exception) { - if (e is CancellationException) throw e - - val activity = activity ?: return - activity.toast(activity.getString(R.string.failed_merge, e.message)) } } // EXH <-- // AZ --> fun openRecommends() { - val source = presenter.source.getMainSource() + val source = presenter.source!!.getMainSource() if (source.isMdBasedSource()) { MaterialAlertDialogBuilder(activity!!) .setTitle(R.string.az_recommends) @@ -779,57 +392,34 @@ class MangaController : ) { dialog, index -> dialog.dismiss() when (index) { - 0 -> router.pushController(MangaDexSimilarController(presenter.manga, source as CatalogueSource)) - 1 -> router.pushController(RecommendsController(presenter.manga, source as CatalogueSource)) + 0 -> router.pushController(MangaDexSimilarController(presenter.manga!!, source as CatalogueSource)) + 1 -> router.pushController(RecommendsController(presenter.manga!!, source as CatalogueSource)) } } .show() } else if (source is CatalogueSource) { - router.pushController(RecommendsController(presenter.manga, source)) + router.pushController(RecommendsController(presenter.manga!!, source)) } } // AZ <-- - /** - * 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 onTrackingClick() { + trackSheet.show() } - fun onCategoriesClick() { - launchIO { - val manga = presenter.manga - val categories = presenter.getCategories() + private fun onCategoriesClick() { + val manga = presenter.manga ?: return + 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 } - - 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) { @@ -837,25 +427,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) } /** @@ -863,7 +441,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 } @@ -903,7 +486,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 } @@ -917,12 +500,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()) } @@ -930,11 +513,12 @@ class MangaController : * Initiates source migration for the specific manga. */ private fun migrateManga() { + val manga = presenter.manga ?: return // SY --> PreMigrationController.navigateToMigration( - preferences.skipPreMigration().get(), + Injekt.get().skipPreMigration().get(), router, - listOf(presenter.manga.id!!), + listOf(manga.id), ) // SY <-- } @@ -943,51 +527,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() - settingsSheet?.filters?.updateScanlatorFilter() + 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 { @@ -995,335 +546,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) } } } @@ -1331,7 +644,7 @@ class MangaController : // Tracker sheet - start fun onNextTrackers(trackers: List) { - trackSheet?.onNextTrackers(trackers) + trackSheet.onNextTrackers(trackers) } fun onTrackingRefreshDone() { @@ -1352,14 +665,11 @@ 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" @@ -1368,12 +678,5 @@ class MangaController : const val UPDATE_EXTRA = "update" const val SMART_SEARCH_CONFIG_EXTRA = "smartSearchConfig" // EXH <-- - - /** - * Key to change the cover of a manga in [onActivityResult]. - */ - const val REQUEST_IMAGE_OPEN = 101 - - const val REQUEST_EDIT_MANGA_COVER = 201 } } 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 d15379165..dd253e173 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,31 +1,38 @@ package eu.kanade.tachiyomi.ui.manga import android.content.Context -import android.net.Uri 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.GetMergedChapterByMangaId +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.GetFlatMetadataById import eu.kanade.domain.manga.interactor.GetMangaWithChapters +import eu.kanade.domain.manga.interactor.GetMergedMangaById +import eu.kanade.domain.manga.interactor.GetMergedReferencesById +import eu.kanade.domain.manga.interactor.SetMangaChapterFlags +import eu.kanade.domain.manga.interactor.SetMangaFilteredScanlators 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.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.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.library.CustomMangaManager 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 @@ -36,16 +43,14 @@ import eu.kanade.tachiyomi.source.model.toSChapter import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.all.MergedSource 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 @@ -58,13 +63,15 @@ import exh.log.xLogE import exh.md.utils.FollowStatus import exh.md.utils.MdUtil import exh.merged.sql.models.MergedMangaReference +import exh.metadata.MetadataUtil import exh.metadata.metadata.base.FlatMetadata import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.metadata.metadata.base.getFlatMetadataForManga import exh.source.MERGED_SOURCE_ID import exh.source.getMainSource +import exh.source.isEhBasedManga import exh.source.isEhBasedSource import exh.source.mangaDexSourceIds +import exh.util.nullIfEmpty import exh.util.trimOrNull import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -72,66 +79,63 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import logcat.LogPriority import rx.Observable -import rx.Single import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy -import java.util.Date -import eu.kanade.domain.category.model.Category as DomainCategory +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, + val smartSearched: 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(), // SY --> private val sourceManager: SourceManager = Injekt.get(), // SY <-- - private val coverCache: CoverCache = Injekt.get(), - private val getMangaWithChapters: GetMangaWithChapters = Injekt.get(), + private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(), // SY --> + private val setMangaFilteredScanlators: SetMangaFilteredScanlators = Injekt.get(), private val getMergedChapterByMangaId: GetMergedChapterByMangaId = Injekt.get(), + private val getMergedMangaById: GetMergedMangaById = Injekt.get(), + private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(), + private val getFlatMetadata: GetFlatMetadataById = Injekt.get(), // SY <-- 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. */ @@ -148,10 +152,24 @@ 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 + // EXH --> private val customMangaManager: CustomMangaManager by injectLazy() @@ -159,136 +177,161 @@ class MangaPresenter( val redirectFlow: MutableSharedFlow = MutableSharedFlow() - data class EXHRedirect(val manga: Manga, val update: Boolean) - - val meta: MutableStateFlow = MutableStateFlow(null) - - var mergedManga = emptyMap() - private set - var mergedMangaReferences = emptyList() - private set + data class EXHRedirect(val mangaId: Long, val update: Boolean) var dedupe: Boolean = true - var allChapterScanlators: Set = emptySet() + var allChapterScanlators: List = emptyList() // EXH <-- + private data class CombineState( + val manga: DomainManga, + val chapters: List, + val flatMetadata: FlatMetadata?, + val mergedData: MergedMangaData? = null, + ) { + constructor(pair: Pair>, flatMetadata: FlatMetadata?) : + this(pair.first, pair.second, flatMetadata) + } + override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - // SY --> - if (source is MergedSource) { - launchIO { mergedManga = db.getMergedMangas(manga.id!!).executeAsBlocking().associateBy { it.id!! } } - launchIO { mergedMangaReferences = db.getMergedMangaReferences(manga.id!!).executeAsBlocking() } - } - // SY <-- - - if (!manga.favorite) { - ChapterSettingsHelper.applySettingDefaults(manga) - } - // Manga info - start - getMangaObservable() - .observeOn(AndroidSchedulers.mainThread()) - // SY --> - .flatMap { manga -> - if (manga.initialized && source.getMainSource() is MetadataSource<*, *>) { - getMangaMetaSingle().map { - manga to it - }.toObservable() - } else { - Observable.just(manga to null) - } - } - .subscribeLatestCache({ view, (manga, flatMetadata) -> - val mainSource = source.getMainSource>() - val meta = if (mainSource != null) { - flatMetadata?.raise(mainSource.metaClass) - } else null - this.meta.value = meta - // SY <-- - view.onNextMangaInfo(manga, source, meta) - },) - 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 -> - if (source is MergedSource) { - getMergedChapterByMangaId.subscribe(mangaId) - .map { manga to source.transformMergedChapters(mangaId, it, true, dedupe) } - } else { - getMangaWithChapters.subscribe(mangaId) - } - .collectLatest { (_, chapters) -> - val chapterItems = chapters.map { it.toDbChapter().toModel() } - setDownloadedChapters(chapterItems) - this@MangaPresenter.allChapters = chapterItems - observeDownloads() - - // SY --> - if (chapters.isNotEmpty() && (source.isEhBasedSource()) && DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled) { - // Check for gallery in library and accept manga with lowest id - // Find chapters sharing same root - updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters.map { it.toDbChapter() }) - .onEach { (acceptedChain, _) -> - // Redirect if we are not the accepted root - if (manga.id != acceptedChain.manga.id && acceptedChain.manga.favorite) { - // Update if any of our chapters are not in accepted manga's chapters - xLogD("Found accepted manga %s", manga.url) - val ourChapterUrls = chapters.map { it.url }.toSet() - val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet() - val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty() - redirectFlow.emit( - EXHRedirect( - acceptedChain.manga, - update, - ), - ) - } - }.launchIn(presenterScope) - } - // SY <-- - chaptersRelay.call(chapterItems) - } + if (!getMangaAndChapters.awaitManga(mangaId).favorite) { + ChapterSettingsHelper.applySettingDefaults(mangaId, setMangaChapterFlags) } + + getMangaAndChapters.subscribe(mangaId) + // SY --> + .combine(getMergedChapterByMangaId.subscribe(mangaId)) { (manga, chapters), mergedChapters -> + if (manga.source == MERGED_SOURCE_ID) { + manga to mergedChapters + } else manga to chapters + } + .onEach { (manga, chapters) -> + if (chapters.isNotEmpty() && manga.isEhBasedManga() && DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled) { + // Check for gallery in library and accept manga with lowest id + // Find chapters sharing same root + updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters.map { it.toDbChapter() }) + .onEach { (acceptedChain, _) -> + // Redirect if we are not the accepted root + if (manga.id != acceptedChain.manga.id && acceptedChain.manga.favorite) { + // Update if any of our chapters are not in accepted manga's chapters + xLogD("Found accepted manga %s", manga.url) + val ourChapterUrls = chapters.map { it.url }.toSet() + val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet() + val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty() + redirectFlow.emit( + EXHRedirect( + acceptedChain.manga.id!!, + update, + ), + ) + } + }.launchIn(presenterScope) + } + allChapterScanlators = chapters.flatMap { MdUtil.getScanlators(it.scanlator) }.distinct() + } + .combine(getFlatMetadata.subscribe(mangaId)) { pair, flatMetadata -> + CombineState(pair, flatMetadata) + } + .combine( + combine( + getMergedMangaById.subscribe(mangaId), + getMergedReferencesById.subscribe(mangaId), + ) { manga, references -> + if (manga.isNotEmpty()) { + MergedMangaData(references, manga.associateBy { it.id }) + } else null + }, + ) { state, mergedData -> + state.copy(mergedData = mergedData) + } + // SY <-- + .collectLatest { (manga, chapters, flatMetadata, mergedData) -> + val chapterItems = chapters.toChapterItems(manga, mergedData) + val currentState = _state.value + _state.value = when (currentState) { + // Initialize success state + MangaScreenState.Loading -> { + val source = Injekt.get().getOrStub(manga.source) + MangaScreenState.Success( + manga = manga, + source = source, + dateRelativeTime = if (source.isEhBasedSource()) 0 else preferences.relativeTime().get(), + dateFormat = if (source.isEhBasedSource()) { + MetadataUtil.EX_DATE_FORMAT + } else { + preferences.dateFormat() + }, + isFromSource = isFromSource, + trackingAvailable = trackManager.hasLoggedServices(), + chapters = chapterItems, + meta = raiseMetadata(flatMetadata, source), + mergedData = mergedData, + showRecommendationsInOverflow = preferences.recommendsInOverflow().get(), + showMergeWithAnother = smartSearched, + ).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, + // SY --> + meta = raiseMetadata(flatMetadata, currentState.source), + mergedData = mergedData, + // SY <-- + ) + } + + 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) } @@ -305,96 +348,79 @@ class MangaPresenter( .map { it.size } } - // SY --> - private fun getMangaMetaSingle(): Single { - val mangaId = manga.id - return if (mangaId != null) { - db.getFlatMetadataForManga(mangaId).asRxSingle() - } else Single.just(null) - } - // SY <-- - /** * 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) { this@MangaPresenter.xLogE("Error getting manga details", e) withUIContext { view?.onFetchMangaInfoError(e) } } + _state.value = successState.copy(isRefreshingInfo = false) } } // SY --> + private fun raiseMetadata(flatMetadata: FlatMetadata?, source: Source): RaisedSearchMetadata? { + return if (flatMetadata != null) { + val metaClass = source.getMainSource>()?.metaClass + if (metaClass != null) flatMetadata.raise(metaClass) else null + } else null + } + fun updateMangaInfo( - context: Context, title: String?, author: String?, artist: String?, description: String?, tags: List?, - status: Int?, - uri: Uri?, - resetCover: Boolean = false, + status: Long?, ) { - if (manga.isLocal()) { - manga.title = if (title.isNullOrBlank()) manga.url else title.trim() - manga.author = author?.trimOrNull() - manga.artist = artist?.trimOrNull() - manga.description = description?.trimOrNull() - val tagsString = tags?.joinToString() - manga.genre = if (tags.isNullOrEmpty()) null else tagsString?.trim() - manga.status = status ?: 0 - (sourceManager.get(LocalSource.ID) as LocalSource).updateMangaInfo(manga) - db.updateMangaInfo(manga).executeAsBlocking() + val state = successState ?: return + var manga = state.manga + if (state.manga.isLocal()) { + manga = manga.copy( + ogTitle = if (title.isNullOrBlank()) manga.url else title.trim(), + ogAuthor = author?.trimOrNull(), + ogArtist = artist?.trimOrNull(), + ogDescription = description?.trimOrNull(), + ogGenre = tags?.nullIfEmpty(), + ogStatus = status ?: 0, + ) + (sourceManager.get(LocalSource.ID) as LocalSource).updateMangaInfo(manga.toSManga()) + // TODO + db.updateMangaInfo(manga.toDbManga()).executeAsBlocking() } else { - val genre = if (!tags.isNullOrEmpty() && tags.joinToString() != manga.originalGenre) { + val genre = if (!tags.isNullOrEmpty() && tags != state.manga.ogGenre) { tags } else { null } - val manga = CustomMangaManager.MangaJson( - manga.id!!, - title?.trimOrNull(), - author?.trimOrNull(), - artist?.trimOrNull(), - description?.trimOrNull(), - genre, - status.takeUnless { it == manga.originalStatus }, + customMangaManager.saveMangaInfo( + CustomMangaManager.MangaJson( + state.manga.id, + title?.trimOrNull(), + author?.trimOrNull(), + artist?.trimOrNull(), + description?.trimOrNull(), + genre, + status.takeUnless { it == state.manga.ogStatus }, + ), ) - customMangaManager.saveMangaInfo(manga) + manga = manga.copy() } - /*if (uri != null) { - editCover(context, uri) - } else if (resetCover) { - coverCache.deleteCustomCover(manga.id) - manga.updateCoverLastModified(db) - }*/ - - if (uri == null && resetCover) { - launchUI { - view?.setRefreshing() - } - fetchMangaFromSource(manualFetch = true) - } else { - launchUI { - view?.onNextMangaInfo(manga, source, meta.value) - } - } + _state.value = state.copy(manga = manga) } - suspend fun smartSearchMerge(context: Context, manga: Manga, originalMangaId: Long): Manga { + suspend fun smartSearchMerge(context: Context, manga: DomainManga, originalMangaId: Long): Manga { val originalManga = db.getManga(originalMangaId).executeAsBlocking() ?: throw IllegalArgumentException(context.getString(R.string.merge_unknown_manga, originalMangaId)) if (originalManga.source == MERGED_SOURCE_ID) { @@ -413,7 +439,7 @@ class MangaPresenter( downloadChapters = true, mergeId = originalManga.id!!, mergeUrl = originalManga.url, - mangaId = manga.id!!, + mangaId = manga.id, mangaUrl = manga.url, mangaSourceId = manga.source, ), @@ -497,7 +523,7 @@ class MangaPresenter( downloadChapters = true, mergeId = mergedManga.id!!, mergeUrl = mergedManga.url, - mangaId = manga.id!!, + mangaId = manga.id, mangaUrl = manga.url, mangaSourceId = manga.source, ) @@ -540,40 +566,105 @@ class MangaPresenter( /** * 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() { + val state = successState ?: return // SY --> - if (source is MergedSource) { - val mergedManga = mergedManga.map { it.value to sourceManager.getOrStub(it.value.source) } - mergedManga.forEach { (manga, source) -> - downloadManager.deleteManga(manga, source) + if (state.source is MergedSource) { + val mergedManga = state.mergedData?.manga?.map { it.value to sourceManager.getOrStub(it.value.source) } + mergedManga?.forEach { (manga, source) -> + downloadManager.deleteManga(manga.toDbManga(), source) } - } else /* SY <-- */ downloadManager.deleteManga(manga, source) + } else /* SY <-- */ downloadManager.deleteManga(state.manga.toDbManga(), state.source) } /** @@ -581,8 +672,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() } /** @@ -591,9 +682,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) + } } /** @@ -602,7 +700,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)) } @@ -613,7 +711,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)) } @@ -624,19 +722,16 @@ class MangaPresenter( private fun observeDownloads() { // SY --> val isMergedSource = source is MergedSource - val mergedIds = if (isMergedSource) mergedManga.keys else emptySet() + val mergedIds = if (isMergedSource) successState?.mergedData?.manga?.keys.orEmpty() else emptySet() // SY <-- observeDownloadsStatusSubscription?.let { remove(it) } observeDownloadsStatusSubscription = downloadManager.queue.getStatusObservable() .observeOn(Schedulers.io()) .onBackpressureBuffer() - .filter { download -> /* SY --> */ if (isMergedSource) download.manga.id in mergedIds else /* SY <-- */ download.manga.id == manga.id } + .filter { download -> /* SY --> */ if (isMergedSource) download.manga.id in mergedIds else /* SY <-- */ 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) }, @@ -646,208 +741,152 @@ class MangaPresenter( observeDownloadsPageSubscription = downloadManager.queue.getProgressObservable() .observeOn(Schedulers.io()) .onBackpressureBuffer() - .filter { download -> /* SY --> */ if (isMergedSource) download.manga.id in mergedIds else /* SY <-- */ download.manga.id == manga.id } + .filter { download -> /* SY --> */ if (isMergedSource) download.manga.id in mergedIds else /* SY <-- */ 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) { - // SY --> - val isMergedSource = source is MergedSource - // SY <-- - chapters - .filter { downloadManager.isChapterDownloaded(/* SY --> */ if (isMergedSource) it.toMergedDownloadChapter() else it, if (isMergedSource) mergedManga[it.manga_id] ?: manga else /* SY <-- */ manga) } - .forEach { it.status = Download.State.DOWNLOADED } + private fun List.toChapterItems(manga: DomainManga, mergedData: MergedMangaData?): List { + return map { chapter -> + val activeDownload = downloadManager.queue.find { chapter.id == it.chapter.id } + val downloaded = downloadManager.isChapterDownloaded( + // SY --> + chapter.let { if (mergedData != null) it.toMergedDownloadedChapter() else it } + .toDbChapter(), + (mergedData?.manga?.get(chapter.mangaId) ?: manga).toDbManga(), + // SY <-- + ) + 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, + ) + } } - private fun Chapter.toMergedDownloadChapter() = Chapter.create().apply { - url = this@toMergedDownloadChapter.url - name = this@toMergedDownloadChapter.name - id = this@toMergedDownloadChapter.id - scanlator = this@toMergedDownloadChapter.scanlator?.substringAfter(": ") - } + private fun DomainChapter.toMergedDownloadedChapter() = copy( + scanlator = scanlator?.substringAfter(": "), + ) /** * 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 { - if (source !is MergedSource) { - val chapters = source.getChapterList(manga.toMangaInfo()) + if (successState.source !is MergedSource) { + val chapters = successState.source.getChapterList(successState.manga.toMangaInfo()) .map { it.toSChapter() } - val (newChapters, _) = syncChaptersWithSource(chapters, manga, source) + val (newChapters, _) = syncChaptersWithSource.await( + chapters, + successState.manga, + successState.source, + ) + if (manualFetch) { - downloadNewChapters(newChapters) + val dbChapters = newChapters.map { it.toDbChapter() } + downloadNewChapters(dbChapters) } } else { - source.fetchChaptersForMergedManga(manga.toDomainManga()!!, manualFetch, true, dedupe) + successState.source.fetchChaptersForMergedManga(successState.manga, manualFetch, true, dedupe) } - - withUIContext { view?.onFetchChaptersDone() } } 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 } - } - - // SY --> - manga.filtered_scanlators?.let { filteredScanlatorString -> - val filteredScanlators = MdUtil.getScanlators(filteredScanlatorString) - observable = observable.filter { MdUtil.getScanlators(it.scanlator).any { group -> filteredScanlators.contains(group) } } - } - // SY <-- - - 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 (source.isEhBasedSource()) { - if (sortDescending()) { - filteredAndSortedChapters.firstOrNull()?.takeUnless { 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 { - filteredAndSortedChapters.lastOrNull()?.takeUnless { it.read } - } - } else { - if (sortDescending()) { - return filteredAndSortedChapters.findLast { !it.read } - } else { - filteredAndSortedChapters.find { !it.read } + 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 } + fun getUnreadChapters(): List { + return successState?.processedChapters + ?.filter { (chapter, dlStatus) -> !chapter.read && dlStatus == Download.State.NOT_DOWNLOADED } + ?.map { it.chapter } + ?.toList() + ?: emptyList() + } + + fun getUnreadChaptersSorted(): List { + val manga = successState?.manga ?: return emptyList() + val chapters = getUnreadChapters().sortedWith(getChapterSort(manga)) // SY --> .let { - if (source.isEhBasedSource()) { - it.reversed() - } else { - it - } + if (manga.isEhBasedManga()) it.reversed() else it } // SY <-- - return if (sortDescending()) { - chapters.reversed() - } else { - chapters - } + return if (manga.sortDescending()) chapters.reversed() else chapters } - fun startDownloadingNow(chapter: Chapter) { - downloadManager.startDownloadNow(chapter.id) + 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) } } } @@ -856,27 +895,29 @@ class MangaPresenter( * Downloads the given list of chapters with the manager. * @param chapters the list of chapters to download. */ - fun downloadChapters(chapters: List) { - // SY --> - if (source is MergedSource) { - chapters.groupBy { it.manga_id }.forEach { map -> - val manga = mergedManga[map.key] ?: return@forEach - downloadManager.downloadChapters(manga, map.value.map { it.toMergedDownloadChapter() }) + fun downloadChapters(chapters: List) { + val state = successState ?: return + if (state.source is MergedSource) { + chapters.groupBy { it.mangaId }.forEach { map -> + val manga = state.mergedData?.manga?.get(map.key) ?: return@forEach + downloadManager.downloadChapters(manga.toDbManga(), map.value.map { it.toMergedDownloadedChapter().toDbChapter() }) } - } else /* SY <-- */ downloadManager.downloadChapters(manga, chapters) + } else { /* SY <-- */ + val manga = state.manga + 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) } } } @@ -884,40 +925,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) { - if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(db, preferences) || source.isEhBasedSource()) 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() + val manga = successState?.manga ?: return + if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(db, preferences) || manga.isEhBasedManga()) return + downloadChapters(chapters.map { it.toDomainChapter()!! }) } /** @@ -925,13 +963,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() } /** @@ -939,13 +979,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() } /** @@ -953,21 +995,23 @@ 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() } // SY --> - fun setScanlatorFilter(filteredScanlators: Set) { - val manga = manga - manga.filtered_scanlators = if (filteredScanlators.size == allChapterScanlators.size) null else MdUtil.getScanlatorString(filteredScanlators) - db.updateMangaFilteredScanlators(manga).executeAsBlocking() - refreshChapters() + fun setScanlatorFilter(filteredScanlators: List) { + val manga = manga ?: return + presenterScope.launchIO { + setMangaFilteredScanlators.awaitSetFilteredScanlators(manga, filteredScanlators) + } } // SY <-- @@ -975,70 +1019,22 @@ class MangaPresenter( * 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 @@ -1046,6 +1042,8 @@ class MangaPresenter( // Track sheet - start private fun fetchTrackers() { + val state = successState ?: return + val manga = successState?.manga ?: return trackSubscription?.let { remove(it) } trackSubscription = db.getTracks(manga.id) .asRxObservable() @@ -1057,7 +1055,7 @@ class MangaPresenter( .observeOn(AndroidSchedulers.mainThread()) // SY --> .map { trackItems -> - if (manga.source in mangaDexSourceIds || mergedManga.values.any { it.source in mangaDexSourceIds }) { + if (manga.source in mangaDexSourceIds || state.mergedData?.manga?.values.orEmpty().any { it.source in mangaDexSourceIds }) { val mdTrack = trackItems.firstOrNull { it.service.id == TrackManager.MDLIST } when { mdTrack == null -> { @@ -1077,8 +1075,11 @@ class MangaPresenter( // SY --> private fun createMdListTrack(): TrackItem { - val mdManga = mergedManga.values.find { it.source in mangaDexSourceIds } - val track = trackManager.mdList.createInitialTracker(manga, mdManga ?: manga) + val state = successState!! + val mdManga = state.manga.takeIf { it.source in mangaDexSourceIds } + ?: state.mergedData?.manga?.values?.find { it.source in mangaDexSourceIds } + ?: throw IllegalArgumentException("Could not create initial track") + val track = trackManager.mdList.createInitialTracker(state.manga.toDbManga(), mdManga.toDbManga()) track.id = db.insertTrack(track).executeAsBlocking().insertedId() return TrackItem(track, trackManager.mdList) } @@ -1097,6 +1098,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) } } @@ -1126,10 +1129,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() @@ -1148,7 +1154,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) { @@ -1208,3 +1215,83 @@ class MangaPresenter( // Track sheet - end } + +data class MergedMangaData(val references: List, val manga: Map) + +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, + // SY --> + val meta: RaisedSearchMetadata?, + val mergedData: MergedMangaData?, + val showRecommendationsInOverflow: Boolean, + val showMergeWithAnother: Boolean, + // SY <-- + ) : 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 + val filteredScanlators = manga.filteredScanlators.orEmpty() + 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 + } + } + .filter { (chapter) -> + filteredScanlators.isEmpty() || MdUtil.getScanlators(chapter.scanlator).any { group -> filteredScanlators.contains(group) } + } + .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 100755 index 286f533a6..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt +++ /dev/null @@ -1,132 +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 exh.metadata.MetadataUtil -import exh.source.isEhBasedManga -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) { - // SY --> - if (manga.isEhBasedManga()) { - descriptions.add(MetadataUtil.EX_DATE_FORMAT.format(Date(chapter.date_upload))) - } else /* SY <-- */ descriptions.add(Date(chapter.date_upload).toRelativeString(itemView.context, adapter.relativeTime, adapter.dateFormat)) - } - if ((!chapter.read || (adapter.preserveReadingPosition && manga.isEhBasedManga())) && 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 d63f7d602..000000000 --- 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 100755 index b62267a15..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt +++ /dev/null @@ -1,50 +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() - - // SY --> - val preserveReadingPosition: Boolean = preferences.preserveReadingPosition().get() - // SY <-- - - 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 2c6b9dae6..d464a66bf 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 @@ -8,21 +8,21 @@ import androidx.core.view.isVisible import com.bluelinelabs.conductor.Router import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withUIContext +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 import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog -import exh.md.utils.MdUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel -import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch class ChaptersSettingsSheet( private val router: Router, @@ -33,7 +33,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) @@ -47,8 +47,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() { @@ -68,17 +74,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) } } }, @@ -101,11 +103,7 @@ class ChaptersSettingsSheet( * Returns true if there's at least one filter from [FilterGroup] active. */ fun hasActiveFilters(): Boolean { - return filterGroup.items.any { it.state != State.IGNORE.value } || presenter.manga.filtered_scanlators != null - } - - fun updateScanlatorFilter() { - filterGroup.updateScanlatorFilter() + return filterGroup.items.any { it.state != State.IGNORE.value } || presenter.manga?.filteredScanlators != null } override fun updateView() { @@ -135,7 +133,7 @@ class ChaptersSettingsSheet( unread.state = manga.unreadFilter.toTriStateGroupState().value bookmarked.state = manga.bookmarkedFilter.toTriStateGroupState().value // SY --> - updateScanlatorFilter() + scanlatorFilters.isVisible = presenter.allChapterScanlators.size > 1 // SY <-- } @@ -144,16 +142,11 @@ class ChaptersSettingsSheet( adapter.notifyItemRangeChanged(0, 3) } - // SY --> - fun updateScanlatorFilter() { - scanlatorFilters.isVisible = presenter.allChapterScanlators.size > 1 - } - // SY <-- - override fun onItemClicked(item: Item) { + // SY --> if (item is Item.DrawableSelection) { val scanlators = presenter.allChapterScanlators.toTypedArray() - val filteredScanlators = presenter.manga.filtered_scanlators?.let(MdUtil::getScanlators) ?: scanlators.toSet() + val filteredScanlators = presenter.manga?.filteredScanlators?.toSet() ?: scanlators.toSet() val selection = scanlators.map { it in filteredScanlators }.toBooleanArray() @@ -164,25 +157,16 @@ class ChaptersSettingsSheet( selection[which] = selected } .setPositiveButton(android.R.string.ok) { _, _ -> - launchIO { - supervisorScope { - val selected = scanlators.filterIndexed { index, s -> selection[index] }.toSet() - presenter.setScanlatorFilter(selected) - withUIContext { onGroupClicked(this@FilterGroup) } - } - } + val selected = scanlators.filterIndexed { index, _ -> selection[index] } + presenter.setScanlatorFilter(selected) } .setNegativeButton(R.string.action_reset) { _, _ -> - launchIO { - supervisorScope { - presenter.setScanlatorFilter(presenter.allChapterScanlators) - withUIContext { onGroupClicked(this@FilterGroup) } - } - } + presenter.setScanlatorFilter(presenter.allChapterScanlators) } .show() return } + // SY <-- item as Item.TriStateGroup val newState = when (item.state) { State.IGNORE.value -> State.INCLUDE @@ -196,10 +180,6 @@ class ChaptersSettingsSheet( bookmarked -> presenter.setBookmarkedFilter(newState) else -> {} } - - // TODO: Remove - updateManga() - updateView() } } } @@ -254,16 +234,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() } } } @@ -309,14 +284,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 81177d46a..000000000 --- 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 0ee049342..000000000 --- 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/MangaInfoButtonsAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoButtonsAdapter.kt deleted file mode 100644 index 7ad6dd634..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoButtonsAdapter.kt +++ /dev/null @@ -1,58 +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.recyclerview.widget.RecyclerView -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.databinding.MangaInfoButtonsBinding -import eu.kanade.tachiyomi.ui.manga.MangaController -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.android.view.clicks -import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy - -class MangaInfoButtonsAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private val preferences: PreferencesHelper by injectLazy() - - private lateinit var binding: MangaInfoButtonsBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { - binding = MangaInfoButtonsBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return HeaderViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) { - holder.bind() - } - - inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - // EXH --> - if (controller.smartSearchConfig == null) { - binding.recommendBtn.isVisible = !preferences.recommendsInOverflow().get() - binding.recommendBtn.clicks() - .onEach { controller.openRecommends() } - .launchIn(controller.viewScope) - } else { - if (controller.smartSearchConfig.origMangaId != null) { - binding.mergeBtn.isVisible = true - } - binding.mergeBtn.clicks() - .onEach { - controller.mergeWithAnother() - } - .launchIn(controller.viewScope) - } - // EXH <-- - } - } -} 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 e16cac84d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt +++ /dev/null @@ -1,362 +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.LinearLayoutManager -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.source.online.MetadataSource -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 exh.merged.sql.models.MergedMangaReference -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.source.MERGED_SOURCE_ID -import exh.source.getMainSource -import exh.util.SourceTagsUtil -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 - - // SY --> - private var meta: RaisedSearchMetadata? = controller.presenter.meta.value - private var mergedMangaReferences: List = controller.presenter.mergedMangaReferences - - // SY <-- - private var trackCount: Int = 0 - private var metaInfoAdapter: RecyclerView.Adapter<*>? = null - - 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 - - // SY --> - metaInfoAdapter = source.getMainSource>()?.getDescriptionAdapter(controller)?.apply { - setHasStableIds(true) - } - binding.metadataView.isVisible = if (metaInfoAdapter != null) { - binding.metadataView.layoutManager = LinearLayoutManager(binding.root.context) - binding.metadataView.adapter = metaInfoAdapter - true - } else { - false - } - // SY <-- - - 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, meta: RaisedSearchMetadata?, mergedMangaReferences: List) { - this.manga = manga - this.source = source - // SY --> - this.meta = meta - this.mergedMangaReferences = mergedMangaReferences - // SY <-- - update() - updateMetaAdapter() - } - - 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 - } - } - - private fun updateMetaAdapter() { - metaInfoAdapter?.notifyDataSetChanged() - } - - 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 { - if (controller.presenter.source.id == MERGED_SOURCE_ID) { - controller.openMergedMangaWebview() - } else controller.openMangaInWebView() - } - .launchIn(controller.viewScope) - } - - // SY --> - binding.btnMerge.isVisible = controller.presenter.manga.favorite - binding.btnMerge.clicks() - .onEach { controller.openSmartSearch() } - .launchIn(controller.viewScope) - // SY <-- - - 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 { - // SY --> - val author = binding.mangaAuthor.text.toString() - controller.activity?.copyToClipboard( - author, - SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = author) ?: author, - ) - // SY <-- - } - .launchIn(controller.viewScope) - - binding.mangaAuthor.clicks() - .onEach { - // SY --> - val author = binding.mangaAuthor.text.toString() - controller.performGlobalSearch(SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = author) ?: author) - // SY <-- - } - .launchIn(controller.viewScope) - - binding.mangaArtist.longClicks() - .onEach { - // SY --> - val artist = binding.mangaArtist.text.toString() - controller.activity?.copyToClipboard( - artist, - SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = artist) ?: artist, - ) - // SY <-- - } - .launchIn(controller.viewScope) - - binding.mangaArtist.clicks() - .onEach { - // SY --> - val artist = binding.mangaArtist.text.toString() - controller.performGlobalSearch(SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = artist) ?: artist) - // SY <-- - } - .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(source, ::getMergedSourcesString) - - 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, 61 -> R.drawable.ic_done_24dp to R.string.publishing_finished - SManga.CANCELLED, 62 -> R.drawable.ic_close_24dp to R.string.cancelled - SManga.ON_HIATUS, 63 -> 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 - // SY --> - binding.mangaSummarySection.setTags( - manga.getGenres(), - meta, - controller::performGenreSearch, - controller::performGlobalSearch, - source, - ) - // SY <-- - 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 - } - } - - // SY --> - private fun getMergedSourcesString(enabledLangs: List, onlyName: Boolean = false): String { - return if (onlyName) { - mergedMangaReferences.map { - val source = sourceManager.getOrStub(it.mangaSourceId) - if (source.lang !in enabledLangs) { - source.toString() - } else { - source.name - } - }.distinct().joinToString() - } else { - mergedMangaReferences.map { - sourceManager.getOrStub(it.mangaSourceId).toString() - }.distinct().joinToString() - } - } - // SY <-- - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/NamespaceTagsHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/NamespaceTagsHolder.kt deleted file mode 100644 index 03df91cff..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/NamespaceTagsHolder.kt +++ /dev/null @@ -1,54 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.info - -import android.view.View -import android.widget.LinearLayout -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import com.google.android.material.chip.Chip -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.databinding.MangaInfoGenreGroupingBinding -import eu.kanade.tachiyomi.util.system.dpToPx -import exh.util.makeSearchChip - -class NamespaceTagsHolder( - view: View, - adapter: FlexibleAdapter<*>, -) : FlexibleViewHolder(view, adapter) { - val binding = MangaInfoGenreGroupingBinding.bind(view) - - fun bind(item: NamespaceTagsItem) { - binding.namespace.removeAllViews() - val namespace = item.namespace - binding.namespace.isVisible = if (namespace != null) { - binding.namespace.addView( - Chip(binding.root.context).apply { - text = namespace - }, - ) - binding.tags.updateLayoutParams { - marginStart = 8.dpToPx - } - true - } else { - binding.tags.updateLayoutParams { - marginStart = 0.dpToPx - } - false - } - - binding.tags.removeAllViews() - item.tags.map { (tag, type) -> - binding.root.context.makeSearchChip( - tag, - item.onClick, - item.onLongClick, - item.source.id, - namespace, - type, - ) - }.forEach { - binding.tags.addView(it) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/NamespaceTagsItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/NamespaceTagsItem.kt deleted file mode 100644 index 34d7c6f09..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/NamespaceTagsItem.kt +++ /dev/null @@ -1,51 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.info - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import tachiyomi.source.Source - -class NamespaceTagsItem( - val namespace: String?, - val tags: List>, - val onClick: (item: String) -> Unit, - val onLongClick: (item: String) -> Unit, - val source: Source, -) : - AbstractFlexibleItem() { - - override fun getLayoutRes(): Int { - return R.layout.manga_info_genre_grouping - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): NamespaceTagsHolder { - return NamespaceTagsHolder(view, adapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: NamespaceTagsHolder, - position: Int, - payloads: List?, - ) { - holder.bind(this) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as NamespaceTagsItem - - if (namespace != other.namespace) return false - - return true - } - - override fun hashCode(): Int { - return namespace.hashCode() - } -} 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 e918ca6cd..f90378b53 100755 --- 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 43783e4dd..0d8cbeac5 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 50ea2f45b..670b743ac 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 f22199a22..9fdb0dea4 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 b71d74586..5d1e6da34 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 a069a2c7c..81f498833 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 0de66b20d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt +++ /dev/null @@ -1,304 +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 androidx.recyclerview.widget.LinearLayoutManager -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.MangaSummaryBinding -import eu.kanade.tachiyomi.ui.manga.info.NamespaceTagsItem -import eu.kanade.tachiyomi.util.system.animatorDurationScale -import eu.kanade.tachiyomi.util.system.copyToClipboard -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.util.setChipsExtended -import tachiyomi.source.Source -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 - - // SY --> - private var mangaTagsInfoAdapter = FlexibleAdapter(emptyList()) - // SY <-- - - 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) - // SY --> - } else if (value == "meta") { - "" - // SY <-- - } else { - value - } - binding.descriptionText.text = field - recalculateHeights = true - doOnNextLayout { - updateExpandState() - } - if (!isInLayout) { - requestLayout() - } - } - } - - // SY --> - fun setTags( - items: List?, - meta: RaisedSearchMetadata?, - onClick: (item: String) -> Unit, - onLongClick: (item: String) -> Unit, - source: Source, - ) { - binding.tagChipsShrunk.setChipsExtended( - items, - onClick, - onLongClick, - source.id, - ) - // binding.tagChipsExpanded.setChips(items, onClick) - setChipsWithNamespace( - items, - meta, - onClick, - onLongClick, - source, - ) - } - // SY <-- - - 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 - } - } - - // SY --> - private fun setChipsWithNamespace( - genre: List?, - meta: RaisedSearchMetadata?, - onClick: (item: String) -> Unit, - onLongClick: (item: String) -> Unit, - source: Source, - ) { - val namespaceTags = when { - meta != null -> { - meta.tags - .filterNot { it.type == RaisedSearchMetadata.TAG_TYPE_VIRTUAL } - .groupBy { it.namespace } - .map { (namespace, tags) -> - NamespaceTagsItem( - namespace, - tags.map { - it.name to it.type - }, - onClick, - onLongClick, - source, - ) - } - } - genre != null -> { - if (genre.all { it.contains(':') }) { - genre - .map { tag -> - val index = tag.indexOf(':') - tag.substring(0, index).trim() to tag.substring(index + 1).trim() - } - .groupBy { - it.first - } - .mapValues { group -> - group.value.map { it.second to 0 } - } - .map { (namespace, tags) -> - NamespaceTagsItem( - namespace, - tags, - onClick, - onLongClick, - source, - ) - } - } else { - listOf( - NamespaceTagsItem( - null, - genre.map { it to null }, - onClick, - onLongClick, - source, - ), - ) - } - } - else -> emptyList() - } - - mangaTagsInfoAdapter.updateDataSet(namespaceTags) - } - // SY <-- - - 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 - } - } - - // SY --> - binding.tagChipsExpanded.layoutManager = LinearLayoutManager(binding.root.context) - binding.tagChipsExpanded.adapter = mangaTagsInfoAdapter - - mangaTagsInfoAdapter.mItemClickListener = FlexibleAdapter.OnItemClickListener { _, _ -> - expanded = !expanded - false - } - // SY <-- - - 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 33a18380d..0a7d8a770 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/java/exh/md/similar/MangaDexSimilarController.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarController.kt index c08822915..852702080 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarController.kt +++ b/app/src/main/java/exh/md/similar/MangaDexSimilarController.kt @@ -3,8 +3,8 @@ package exh.md.similar import android.os.Bundle import android.view.Menu import androidx.core.os.bundleOf +import eu.kanade.domain.manga.model.Manga import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter @@ -16,7 +16,7 @@ class MangaDexSimilarController(bundle: Bundle) : BrowseSourceController(bundle) constructor(manga: Manga, source: CatalogueSource) : this( bundleOf( - MANGA_ID to manga.id!!, + MANGA_ID to manga.id, MANGA_TITLE to manga.title, SOURCE_ID_KEY to source.id, ), diff --git a/app/src/main/java/exh/recs/RecommendsController.kt b/app/src/main/java/exh/recs/RecommendsController.kt index 32fdf9e50..fc97e12ff 100644 --- a/app/src/main/java/exh/recs/RecommendsController.kt +++ b/app/src/main/java/exh/recs/RecommendsController.kt @@ -4,8 +4,8 @@ import android.os.Bundle import android.view.Menu import android.view.View import androidx.core.os.bundleOf +import eu.kanade.domain.manga.model.Manga import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.browse.source.SourcesController @@ -19,7 +19,7 @@ class RecommendsController(bundle: Bundle) : BrowseSourceController(bundle) { constructor(manga: Manga, source: CatalogueSource) : this( bundleOf( - MANGA_ID to manga.id!!, + MANGA_ID to manga.id, SOURCE_ID_KEY to source.id, ), ) diff --git a/app/src/main/java/exh/ui/base/CoroutinePresenter.kt b/app/src/main/java/exh/ui/base/CoroutinePresenter.kt index 2a8a87761..b6ee26642 100644 --- a/app/src/main/java/exh/ui/base/CoroutinePresenter.kt +++ b/app/src/main/java/exh/ui/base/CoroutinePresenter.kt @@ -1,5 +1,6 @@ package exh.ui.base +import android.os.Bundle import androidx.annotation.CallSuper import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.withUIContext @@ -7,26 +8,36 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive import nucleus.presenter.Presenter import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @Suppress("DEPRECATION", "unused") open class CoroutinePresenter( - scope: CoroutineScope = MainScope(), -) : Presenter(), CoroutineScope by scope { + private val scope: () -> CoroutineScope = ::MainScope, +) : Presenter() { + var presenterScope: CoroutineScope = scope() + + @CallSuper + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + if (!presenterScope.isActive) { + presenterScope = scope() + } + } + @Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("Use launchInView, Flow.inView, Flow.mapView") override fun getView(): V? { return super.getView() } - fun launchInView(block: (CoroutineScope, V) -> Unit) = launchUI { + fun launchInView(block: (CoroutineScope, V) -> Unit) = presenterScope.launchUI { view?.let { block.invoke(this, it) } } @@ -44,14 +55,14 @@ open class CoroutinePresenter( } } - fun Flow<*>.launchUnderContext(context: CoroutineContext = EmptyCoroutineContext) = - launch(context) { this@launchUnderContext.collect() } + fun Flow<*>.launchUnderContext(context: CoroutineContext = EmptyCoroutineContext) = flowOn(context) + .launch() - fun Flow<*>.launch() = launchIn(this@CoroutinePresenter) + fun Flow<*>.launch() = launchIn(presenterScope) @CallSuper - override fun destroy() { - super.destroy() - cancel() + override fun onDestroy() { + super.onDestroy() + presenterScope.cancel() } } diff --git a/app/src/main/java/exh/ui/metadata/MetadataViewController.kt b/app/src/main/java/exh/ui/metadata/MetadataViewController.kt index b89e90fc1..5cdf1b368 100644 --- a/app/src/main/java/exh/ui/metadata/MetadataViewController.kt +++ b/app/src/main/java/exh/ui/metadata/MetadataViewController.kt @@ -6,31 +6,30 @@ import android.view.View import androidx.core.os.bundleOf import androidx.recyclerview.widget.LinearLayoutManager import dev.chrisbanes.insetter.applyInsetter -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.domain.manga.interactor.GetMangaById +import eu.kanade.domain.manga.model.Manga import eu.kanade.tachiyomi.databinding.MetadataViewControllerBinding import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.manga.MangaController import exh.metadata.metadata.base.RaisedSearchMetadata +import kotlinx.coroutines.runBlocking import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class MetadataViewController : NucleusController { - constructor(manga: Manga?) : super( + constructor(manga: Manga) : super( bundleOf( - MangaController.MANGA_EXTRA to (manga?.id ?: 0), + MangaController.MANGA_EXTRA to manga.id, ), ) { this.manga = manga - if (manga != null) { - source = Injekt.get().getOrStub(manga.source) - } + source = Injekt.get().getOrStub(manga.source) } constructor(mangaId: Long) : this( - Injekt.get().getManga(mangaId).executeAsBlocking(), + runBlocking { Injekt.get().await(mangaId)!! }, ) @Suppress("unused") diff --git a/app/src/main/java/exh/ui/metadata/MetadataViewPresenter.kt b/app/src/main/java/exh/ui/metadata/MetadataViewPresenter.kt index e29129f6f..98e43bc05 100644 --- a/app/src/main/java/exh/ui/metadata/MetadataViewPresenter.kt +++ b/app/src/main/java/exh/ui/metadata/MetadataViewPresenter.kt @@ -1,17 +1,16 @@ package exh.ui.metadata import android.os.Bundle -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.data.DatabaseHandler +import eu.kanade.domain.manga.model.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.util.lang.launchIO import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.metadata.metadata.base.getFlatMetadataForManga +import exh.metadata.metadata.base.awaitFlatMetadataForManga import exh.source.getMainSource import exh.ui.base.CoroutinePresenter -import exh.util.executeOnIO import kotlinx.coroutines.flow.MutableStateFlow import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -20,7 +19,7 @@ class MetadataViewPresenter( val manga: Manga, val source: Source, val preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), + private val db: DatabaseHandler = Injekt.get(), ) : CoroutinePresenter() { val meta = MutableStateFlow(null) @@ -29,7 +28,7 @@ class MetadataViewPresenter( super.onCreate(savedState) launchIO { - val flatMetadata = db.getFlatMetadataForManga(manga.id!!).executeOnIO() ?: return@launchIO + val flatMetadata = db.awaitFlatMetadataForManga(manga.id) ?: return@launchIO val mainSource = source.getMainSource>() if (mainSource != null) { meta.value = flatMetadata.raise(mainSource.metaClass) diff --git a/app/src/main/java/exh/ui/metadata/adapters/EHentaiDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/EHentaiDescriptionAdapter.kt index 4bacfd735..05f0f297f 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/EHentaiDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/EHentaiDescriptionAdapter.kt @@ -2,132 +2,29 @@ package exh.ui.metadata.adapters import android.annotation.SuppressLint import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterEhBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.MetadataUtil import exh.metadata.bindDrawable import exh.metadata.metadata.EHentaiSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController - -class EHentaiDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterEhBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EHentaiDescriptionViewHolder { - binding = DescriptionAdapterEhBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return EHentaiDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: EHentaiDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class EHentaiDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is EHentaiSearchMetadata) return - - binding.genre.text = - meta.genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it) } - ?.let { - binding.genre.setBackgroundColor(it.first) - it.second - } - ?: meta.genre - ?: itemView.context.getString(R.string.unknown) - - binding.visible.text = itemView.context.getString(R.string.is_visible, meta.visible ?: itemView.context.getString(R.string.unknown)) - - binding.favorites.text = (meta.favorites ?: 0).toString() - binding.favorites.bindDrawable(itemView.context, R.drawable.ic_book_24dp) - - binding.uploader.text = meta.uploader ?: itemView.context.getString(R.string.unknown) - - binding.size.text = MetadataUtil.humanReadableByteCount(meta.size ?: 0, true) - binding.size.bindDrawable(itemView.context, R.drawable.ic_outline_sd_card_24) - - val length = meta.length ?: 0 - binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, length, length) - binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24) - - val language = meta.language ?: itemView.context.getString(R.string.unknown) - binding.language.text = if (meta.translated == true) { - itemView.context.getString(R.string.language_translated, language) - } else { - language - } - - val ratingFloat = meta.averageRating?.toFloat() - binding.ratingBar.rating = ratingFloat ?: 0F - @SuppressLint("SetTextI18n") - binding.rating.text = (ratingFloat ?: 0F).toString() + " - " + MetadataUtil.getRatingString(itemView.context, ratingFloat?.times(2)) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - listOf( - binding.favorites, - binding.genre, - binding.language, - binding.pages, - binding.rating, - binding.uploader, - binding.visible, - ).forEach { textView -> - textView.setOnLongClickListener { - itemView.context.copyToClipboard( - textView.text.toString(), - textView.text.toString(), - ) - true - } - } - - binding.uploader.setOnClickListener { - meta.uploader?.let { controller.performSearch("uploader:\"$it\"") } - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} @Composable -fun EHentaiDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - EHentaiDescription(controller = controller, meta = meta) -} - -@Composable -private fun EHentaiDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun EHentaiDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterEhBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is EHentaiSearchMetadata) return@AndroidView val binding = DescriptionAdapterEhBinding.bind(it) @@ -187,15 +84,11 @@ private fun EHentaiDescription(controller: MangaController, meta: RaisedSearchMe } binding.uploader.setOnClickListener { - meta.uploader?.let { controller.performSearch("uploader:\"$it\"") } + meta.uploader?.let { search("uploader:\"$it\"") } } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/EightMusesDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/EightMusesDescriptionAdapter.kt index 0f87c3e51..b916f576b 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/EightMusesDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/EightMusesDescriptionAdapter.kt @@ -1,84 +1,28 @@ package exh.ui.metadata.adapters import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapter8mBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.bindDrawable import exh.metadata.metadata.EightMusesSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController - -class EightMusesDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapter8mBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EightMusesDescriptionViewHolder { - binding = DescriptionAdapter8mBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return EightMusesDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: EightMusesDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class EightMusesDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is EightMusesSearchMetadata) return - - binding.title.text = meta.title ?: itemView.context.getString(R.string.unknown) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - binding.title.setOnLongClickListener { - itemView.context.copyToClipboard( - binding.title.text.toString(), - binding.title.text.toString(), - ) - true - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} @Composable -fun EightMusesDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - EightMusesDescription(controller = controller, meta = meta) -} - -@Composable -private fun EightMusesDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun EightMusesDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapter8mBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is EightMusesSearchMetadata) return@AndroidView val binding = DescriptionAdapter8mBinding.bind(it) @@ -95,11 +39,7 @@ private fun EightMusesDescription(controller: MangaController, meta: RaisedSearc } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/HBrowseDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/HBrowseDescriptionAdapter.kt index 3a435b9ff..26f18bd73 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/HBrowseDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/HBrowseDescriptionAdapter.kt @@ -1,85 +1,28 @@ package exh.ui.metadata.adapters import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterHbBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.bindDrawable import exh.metadata.metadata.HBrowseSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController - -class HBrowseDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterHbBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HBrowseDescriptionViewHolder { - binding = DescriptionAdapterHbBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return HBrowseDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: HBrowseDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class HBrowseDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is HBrowseSearchMetadata) return - - binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.length ?: 0, meta.length ?: 0) - binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - binding.pages.setOnLongClickListener { - itemView.context.copyToClipboard( - binding.pages.text.toString(), - binding.pages.text.toString(), - ) - true - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} @Composable -fun HBrowseDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - HBrowseDescription(controller = controller, meta = meta) -} - -@Composable -private fun HBrowseDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun HBrowseDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterHbBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is HBrowseSearchMetadata) return@AndroidView val binding = DescriptionAdapterHbBinding.bind(it) @@ -97,11 +40,7 @@ private fun HBrowseDescription(controller: MangaController, meta: RaisedSearchMe } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/HitomiDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/HitomiDescriptionAdapter.kt index 235c653ba..2c26269d6 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/HitomiDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/HitomiDescriptionAdapter.kt @@ -1,98 +1,30 @@ package exh.ui.metadata.adapters import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterHiBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.MetadataUtil import exh.metadata.bindDrawable import exh.metadata.metadata.HitomiSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController import java.util.Date -class HitomiDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterHiBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HitomiDescriptionViewHolder { - binding = DescriptionAdapterHiBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return HitomiDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: HitomiDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class HitomiDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is HitomiSearchMetadata) return - - binding.genre.text = meta.genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let { - binding.genre.setBackgroundColor(it.first) - it.second - } ?: meta.genre ?: itemView.context.getString(R.string.unknown) - - binding.whenPosted.text = MetadataUtil.EX_DATE_FORMAT.format(Date(meta.uploadDate ?: 0)) - binding.language.text = meta.language ?: itemView.context.getString(R.string.unknown) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - listOf( - binding.genre, - binding.language, - binding.whenPosted, - ).forEach { textView -> - textView.setOnLongClickListener { - itemView.context.copyToClipboard( - textView.text.toString(), - textView.text.toString(), - ) - true - } - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} - @Composable -fun HitomiDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - HitomiDescription(controller = controller, meta = meta) -} - -@Composable -private fun HitomiDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun HitomiDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterHiBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is HitomiSearchMetadata) return@AndroidView val binding = DescriptionAdapterHiBinding.bind(it) @@ -121,11 +53,7 @@ private fun HitomiDescription(controller: MangaController, meta: RaisedSearchMet } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/MangaDexDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/MangaDexDescriptionAdapter.kt index 3b49b30c0..ad7324d06 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/MangaDexDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/MangaDexDescriptionAdapter.kt @@ -2,94 +2,31 @@ package exh.ui.metadata.adapters import android.annotation.SuppressLint import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterMdBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.MetadataUtil.getRatingString import exh.metadata.bindDrawable import exh.metadata.metadata.MangaDexSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController import kotlin.math.round -class MangaDexDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterMdBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MangaDexDescriptionViewHolder { - binding = DescriptionAdapterMdBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return MangaDexDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: MangaDexDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class MangaDexDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is MangaDexSearchMetadata) return - - // todo - val ratingFloat = meta.rating - binding.ratingBar.rating = ratingFloat?.div(2F) ?: 0F - @SuppressLint("SetTextI18n") - binding.rating.text = (round((ratingFloat ?: 0F) * 100.0) / 100.0).toString() + " - " + getRatingString(itemView.context, ratingFloat) - binding.rating.isVisible = ratingFloat != null - binding.ratingBar.isVisible = ratingFloat != null - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - binding.rating.setOnLongClickListener { - itemView.context.copyToClipboard( - binding.rating.text.toString(), - binding.rating.text.toString(), - ) - true - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} - @Composable -fun MangaDexDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - MangaDexDescription(controller = controller, meta = meta) -} - -@Composable -private fun MangaDexDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun MangaDexDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterMdBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is MangaDexSearchMetadata) return@AndroidView val binding = DescriptionAdapterMdBinding.bind(it) @@ -112,11 +49,7 @@ private fun MangaDexDescription(controller: MangaController, meta: RaisedSearchM } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt index b35b0a507..c3eb68a08 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt @@ -2,116 +2,30 @@ package exh.ui.metadata.adapters import android.annotation.SuppressLint import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterNhBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.MetadataUtil import exh.metadata.bindDrawable import exh.metadata.metadata.NHentaiSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController import java.util.Date -class NHentaiDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterNhBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NHentaiDescriptionViewHolder { - binding = DescriptionAdapterNhBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return NHentaiDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: NHentaiDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class NHentaiDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is NHentaiSearchMetadata) return - - binding.genre.text = meta.tags.filter { it.namespace == NHentaiSearchMetadata.NHENTAI_CATEGORIES_NAMESPACE }.let { tags -> - if (tags.isNotEmpty()) tags.joinToString(transform = { it.name }) else null - }.let { categoriesString -> - categoriesString?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let { - binding.genre.setBackgroundColor(it.first) - it.second - } ?: categoriesString ?: itemView.context.getString(R.string.unknown) - } - - meta.favoritesCount?.let { - if (it == 0L) return@let - binding.favorites.text = it.toString() - binding.favorites.bindDrawable(itemView.context, R.drawable.ic_book_24dp) - } - - binding.whenPosted.text = MetadataUtil.EX_DATE_FORMAT.format(Date((meta.uploadDate ?: 0) * 1000)) - - binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.pageImageTypes.size, meta.pageImageTypes.size) - binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24) - - @SuppressLint("SetTextI18n") - binding.id.text = "#" + (meta.nhId ?: 0) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - listOf( - binding.favorites, - binding.genre, - binding.id, - binding.pages, - binding.whenPosted, - ).forEach { textView -> - textView.setOnLongClickListener { - itemView.context.copyToClipboard( - textView.text.toString(), - textView.text.toString(), - ) - true - } - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} - @Composable -fun NHentaiDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - NHentaiDescription(controller = controller, meta = meta) -} - -@Composable -private fun NHentaiDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun NHentaiDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterNhBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is NHentaiSearchMetadata) return@AndroidView val binding = DescriptionAdapterNhBinding.bind(it) @@ -157,11 +71,7 @@ private fun NHentaiDescription(controller: MangaController, meta: RaisedSearchMe } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/PervEdenDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/PervEdenDescriptionAdapter.kt index 6da17c7ad..29b34ee0c 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/PervEdenDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/PervEdenDescriptionAdapter.kt @@ -2,106 +2,31 @@ package exh.ui.metadata.adapters import android.annotation.SuppressLint import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterPeBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.MetadataUtil import exh.metadata.bindDrawable import exh.metadata.metadata.PervEdenSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController import java.util.Locale import kotlin.math.round -class PervEdenDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterPeBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PervEdenDescriptionViewHolder { - binding = DescriptionAdapterPeBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return PervEdenDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: PervEdenDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class PervEdenDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is PervEdenSearchMetadata) return - - binding.genre.text = meta.genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let { - binding.genre.setBackgroundColor(it.first) - it.second - } ?: meta.genre ?: itemView.context.getString(R.string.unknown) - - val language = meta.lang - binding.language.text = if (language != null) { - val local = Locale(language) - local.displayName - } else itemView.context.getString(R.string.unknown) - - binding.ratingBar.rating = meta.rating ?: 0F - @SuppressLint("SetTextI18n") - binding.rating.text = (round((meta.rating ?: 0F) * 100.0) / 100.0).toString() + " - " + MetadataUtil.getRatingString(itemView.context, meta.rating?.times(2)) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - listOf( - binding.genre, - binding.language, - binding.rating, - ).forEach { textView -> - textView.setOnLongClickListener { - itemView.context.copyToClipboard( - textView.text.toString(), - textView.text.toString(), - ) - true - } - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} - @Composable -fun PervEdenDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - PervEdenDescription(controller = controller, meta = meta) -} - -@Composable -private fun PervEdenDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun PervEdenDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterPeBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is PervEdenSearchMetadata) return@AndroidView val binding = DescriptionAdapterPeBinding.bind(it) @@ -137,11 +62,7 @@ private fun PervEdenDescription(controller: MangaController, meta: RaisedSearchM } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/PururinDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/PururinDescriptionAdapter.kt index 0a6837517..29b1dc326 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/PururinDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/PururinDescriptionAdapter.kt @@ -2,112 +2,30 @@ package exh.ui.metadata.adapters import android.annotation.SuppressLint import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterPuBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.MetadataUtil import exh.metadata.bindDrawable import exh.metadata.metadata.PururinSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController import kotlin.math.round -class PururinDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterPuBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PururinDescriptionViewHolder { - binding = DescriptionAdapterPuBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return PururinDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: PururinDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class PururinDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is PururinSearchMetadata) return - - binding.genre.text = meta.tags.find { it.namespace == PururinSearchMetadata.TAG_NAMESPACE_CATEGORY }.let { genre -> - genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it.name) }?.let { - binding.genre.setBackgroundColor(it.first) - it.second - } ?: genre?.name ?: itemView.context.getString(R.string.unknown) - } - - binding.uploader.text = meta.uploaderDisp ?: meta.uploader.orEmpty() - - binding.size.text = meta.fileSize ?: itemView.context.getString(R.string.unknown) - binding.size.bindDrawable(itemView.context, R.drawable.ic_outline_sd_card_24) - - binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.pages ?: 0, meta.pages ?: 0) - binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24) - - val ratingFloat = meta.averageRating?.toFloat() - binding.ratingBar.rating = ratingFloat ?: 0F - @SuppressLint("SetTextI18n") - binding.rating.text = (round((ratingFloat ?: 0F) * 100.0) / 100.0).toString() + " - " + MetadataUtil.getRatingString(itemView.context, ratingFloat?.times(2)) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - listOf( - binding.genre, - binding.pages, - binding.rating, - binding.size, - binding.uploader, - ).forEach { textView -> - textView.setOnLongClickListener { - itemView.context.copyToClipboard( - textView.text.toString(), - textView.text.toString(), - ) - true - } - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} - @Composable -fun PururinDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - PururinDescription(controller = controller, meta = meta) -} - -@Composable -private fun PururinDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun PururinDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterPuBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is PururinSearchMetadata) return@AndroidView val binding = DescriptionAdapterPuBinding.bind(it) @@ -150,11 +68,7 @@ private fun PururinDescription(controller: MangaController, meta: RaisedSearchMe } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/metadata/adapters/TsuminoDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/TsuminoDescriptionAdapter.kt index e3b8506b6..690ae5f6e 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/TsuminoDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/TsuminoDescriptionAdapter.kt @@ -2,113 +2,31 @@ package exh.ui.metadata.adapters import android.annotation.SuppressLint import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.DescriptionAdapterTsBinding -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreenState import eu.kanade.tachiyomi.util.system.copyToClipboard import exh.metadata.MetadataUtil import exh.metadata.bindDrawable import exh.metadata.metadata.TsuminoSearchMetadata -import exh.metadata.metadata.base.RaisedSearchMetadata -import exh.ui.metadata.MetadataViewController import java.util.Date import kotlin.math.round -class TsuminoDescriptionAdapter( - private val controller: MangaController, -) : - RecyclerView.Adapter() { - - private lateinit var binding: DescriptionAdapterTsBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TsuminoDescriptionViewHolder { - binding = DescriptionAdapterTsBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return TsuminoDescriptionViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: TsuminoDescriptionViewHolder, position: Int) { - holder.bind() - } - - inner class TsuminoDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val meta = controller.presenter.meta.value - if (meta == null || meta !is TsuminoSearchMetadata) return - - binding.genre.text = meta.category?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let { - binding.genre.setBackgroundColor(it.first) - it.second - } ?: meta.category ?: itemView.context.getString(R.string.unknown) - - binding.favorites.text = (meta.favorites ?: 0).toString() - binding.favorites.bindDrawable(itemView.context, R.drawable.ic_book_24dp) - - binding.whenPosted.text = TsuminoSearchMetadata.TSUMINO_DATE_FORMAT.format(Date(meta.uploadDate ?: 0)) - - binding.uploader.text = meta.uploader ?: itemView.context.getString(R.string.unknown) - - binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.length ?: 0, meta.length ?: 0) - binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24) - - binding.ratingBar.rating = meta.averageRating ?: 0F - @SuppressLint("SetTextI18n") - binding.rating.text = (round((meta.averageRating ?: 0F) * 100.0) / 100.0).toString() + " - " + MetadataUtil.getRatingString(itemView.context, meta.averageRating?.times(2)) - - binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp) - - listOf( - binding.favorites, - binding.genre, - binding.pages, - binding.rating, - binding.uploader, - binding.whenPosted, - ).forEach { textView -> - textView.setOnLongClickListener { - itemView.context.copyToClipboard( - textView.text.toString(), - textView.text.toString(), - ) - true - } - } - - binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) - } - } - } -} - @Composable -fun TsuminoDescription(controller: MangaController) { - val meta by controller.presenter.meta.collectAsState() - TsuminoDescription(controller = controller, meta = meta) -} - -@Composable -private fun TsuminoDescription(controller: MangaController, meta: RaisedSearchMetadata?) { +fun TsuminoDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) { val context = LocalContext.current AndroidView( + modifier = Modifier.fillMaxWidth(), factory = { factoryContext -> DescriptionAdapterTsBinding.inflate(LayoutInflater.from(factoryContext)).root }, update = { + val meta = state.meta if (meta == null || meta !is TsuminoSearchMetadata) return@AndroidView val binding = DescriptionAdapterTsBinding.bind(it) @@ -151,11 +69,7 @@ private fun TsuminoDescription(controller: MangaController, meta: RaisedSearchMe } binding.moreInfo.setOnClickListener { - controller.router?.pushController( - MetadataViewController( - controller.manga, - ), - ) + openMetadataViewer() } }, ) diff --git a/app/src/main/java/exh/ui/smartsearch/SmartSearchPresenter.kt b/app/src/main/java/exh/ui/smartsearch/SmartSearchPresenter.kt index 14f8bcd2b..24f283508 100644 --- a/app/src/main/java/exh/ui/smartsearch/SmartSearchPresenter.kt +++ b/app/src/main/java/exh/ui/smartsearch/SmartSearchPresenter.kt @@ -22,7 +22,7 @@ class SmartSearchPresenter(private val source: CatalogueSource, private val conf override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - launchIO { + presenterScope.launchIO { val result = try { val resultManga = smartSearchEngine.smartSearch(source, config.origTitle) if (resultManga != null) { 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 78b817a16..000000000 --- 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 52f58ce4c..000000000 --- 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 c0dbff7a5..000000000 --- a/app/src/main/res/layout-sw720dp/manga_info_header.xml +++ /dev/null @@ -1,229 +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 aac308b0a..000000000 --- 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 100755 index 6c114dc6e..000000000 --- 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 f2768743b..000000000 --- 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_buttons.xml b/app/src/main/res/layout/manga_info_buttons.xml deleted file mode 100644 index 4fb4e4331..000000000 --- a/app/src/main/res/layout/manga_info_buttons.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - -