diff --git a/app/build.gradle b/app/build.gradle index adb776406..b23267ead 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:aba8a80d8f') { + implementation('com.github.KotatsuApp:kotatsu-parsers:336c4a4d49') { exclude group: 'org.json', module: 'json' } @@ -96,10 +96,10 @@ dependencies { implementation 'androidx.activity:activity-ktx:1.9.2' implementation 'androidx.fragment:fragment-ktx:1.8.3' implementation 'androidx.transition:transition-ktx:1.5.1' - implementation 'androidx.collection:collection-ktx:1.4.3' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5' - implementation 'androidx.lifecycle:lifecycle-service:2.8.5' - implementation 'androidx.lifecycle:lifecycle-process:2.8.5' + implementation 'androidx.collection:collection-ktx:1.4.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6' + implementation 'androidx.lifecycle:lifecycle-service:2.8.6' + implementation 'androidx.lifecycle:lifecycle-process:2.8.6' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' @@ -107,7 +107,7 @@ dependencies { implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.5' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6' implementation 'androidx.webkit:webkit:1.11.0' implementation 'androidx.work:work-runtime:2.9.1' diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt index ed5444bcf..e822efbbd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @@ -36,13 +37,13 @@ class AlternativesUseCase @Inject constructor( return channelFlow { for (source in sources) { val repository = mangaRepositoryFactory.create(source) - if (!repository.isSearchSupported) { + if (!repository.filterCapabilities.isSearchSupported) { continue } launch { val list = runCatchingCancellable { semaphore.withPermit { - repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title)) + repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) } }.getOrDefault(emptyList()) for (item in list) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt index 34d5a169b..389dc08b4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.model.SortOrder +@Deprecated("") enum class GenericSortOrder( @StringRes val titleResId: Int, val ascending: SortOrder, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index 5e847ad11..66c348689 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -56,6 +56,9 @@ val ContentType.titleResId ContentType.HENTAI -> R.string.content_type_hentai ContentType.COMICS -> R.string.content_type_comics ContentType.OTHER -> R.string.content_type_other + ContentType.MANHWA -> R.string.content_type_manhwa + ContentType.MANHUA -> R.string.content_type_manhua + ContentType.NOVEL -> R.string.content_type_novel } fun MangaSource.getSummary(context: Context): String? = when (this) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt index 8bad91af4..74371571c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt @@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.util.EnumSet @@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse override val availableSortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities() + + override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) + override suspend fun getDetails(manga: Manga): Manga = stub(manga) - override suspend fun getList(offset: Int, filter: MangaListFilter?): List = stub(null) + override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List = stub(null) override suspend fun getPages(chapter: MangaChapter): List = stub(null) - override suspend fun getAvailableTags(): Set = stub(null) - private fun stub(manga: Manga?): Nothing { throw UnsupportedSourceException("Usage of Dummy parser", manga) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt index 833b87edd..af90994f8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt @@ -1,37 +1,29 @@ package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.util.EnumSet -import java.util.Locale class EmptyMangaRepository(override val source: MangaSource) : MangaRepository { override val sortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) - override val states: Set - get() = emptySet() - override val contentRatings: Set - get() = emptySet() + override var defaultSortOrder: SortOrder get() = SortOrder.NEWEST set(value) = Unit - override val isMultipleTagsSupported: Boolean - get() = false - override val isTagsExclusionSupported: Boolean - get() = false - override val isSearchSupported: Boolean - get() = false - override suspend fun getList(offset: Int, filter: MangaListFilter?): List = stub(null) + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities() + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = stub(null) override suspend fun getDetails(manga: Manga): Manga = stub(manga) @@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository { override suspend fun getPageUrl(page: MangaPage): String = stub(null) - override suspend fun getTags(): Set = stub(null) - - override suspend fun getLocales(): Set = stub(null) + override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) override suspend fun getRelated(seed: Manga): List = stub(seed) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt index 065529cc2..54bdced1d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt @@ -61,7 +61,7 @@ class MangaLinkResolver @Inject constructor( private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { if (!title.isNullOrEmpty()) { - val list = getList(0, MangaListFilter.Search(title)) + val list = getList(0, null, MangaListFilter(query = title)) if (url != null) { list.find { it.url == url }?.let { return it @@ -80,7 +80,7 @@ class MangaLinkResolver @Inject constructor( }.ifNullOrEmpty { seed.author } ?: return@runCatchingCancellable null - val seedList = getList(0, MangaListFilter.Search(seedTitle)) + val seedList = getList(0, null, MangaListFilter(query = seedTitle)) seedList.first { x -> x.url == url } }.getOrThrow() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt index ae2bef8a9..580113c83 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.lang.ref.WeakReference -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.collections.set @@ -35,19 +33,11 @@ interface MangaRepository { val sortOrders: Set - val states: Set - - val contentRatings: Set - var defaultSortOrder: SortOrder - val isMultipleTagsSupported: Boolean + val filterCapabilities: MangaListFilterCapabilities - val isTagsExclusionSupported: Boolean - - val isSearchSupported: Boolean - - suspend fun getList(offset: Int, filter: MangaListFilter?): List + suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List suspend fun getDetails(manga: Manga): Manga @@ -55,14 +45,12 @@ interface MangaRepository { suspend fun getPageUrl(page: MangaPage): String - suspend fun getTags(): Set - - suspend fun getLocales(): Set + suspend fun getFilterOptions(): MangaListFilterOptions suspend fun getRelated(seed: Manga): List suspend fun find(manga: Manga): Manga? { - val list = getList(0, MangaListFilter.Search(manga.title)) + val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) return list.find { x -> x.id == manga.id } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt index c54e35d97..82a614891 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt @@ -13,11 +13,14 @@ import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.Locale @@ -28,17 +31,20 @@ class ParserMangaRepository( cache: MemoryContentCache, ) : CachingMangaRepository(cache), Interceptor { + private val filterOptionsLazy = SuspendLazy { + mirrorSwitchInterceptor.withMirrorSwitching { + parser.getFilterOptions() + } + } + override val source: MangaParserSource get() = parser.source override val sortOrders: Set get() = parser.availableSortOrders - override val states: Set - get() = parser.availableStates - - override val contentRatings: Set - get() = parser.availableContentRating + override val filterCapabilities: MangaListFilterCapabilities + get() = parser.filterCapabilities override var defaultSortOrder: SortOrder get() = getConfig().defaultSortOrder ?: sortOrders.first() @@ -46,15 +52,6 @@ class ParserMangaRepository( getConfig().defaultSortOrder = value } - override val isMultipleTagsSupported: Boolean - get() = parser.isMultipleTagsSupported - - override val isSearchSupported: Boolean - get() = parser.isSearchSupported - - override val isTagsExclusionSupported: Boolean - get() = parser.isTagsExclusionSupported - var domain: String get() = parser.domain set(value) { @@ -72,9 +69,9 @@ class ParserMangaRepository( } } - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { return mirrorSwitchInterceptor.withMirrorSwitching { - parser.getList(offset, filter) + parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) } } @@ -88,13 +85,7 @@ class ParserMangaRepository( parser.getPageUrl(page) } - override suspend fun getTags(): Set = mirrorSwitchInterceptor.withMirrorSwitching { - parser.getAvailableTags() - } - - override suspend fun getLocales(): Set { - return parser.getAvailableLocales() - } + override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get() suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { parser.getFavicons() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt index c888495e4..148e5abff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt @@ -6,16 +6,14 @@ import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.util.EnumSet -import java.util.Locale class ExternalMangaRepository( private val contentResolver: ContentResolver, @@ -36,28 +34,39 @@ class ExternalMangaRepository( override val sortOrders: Set get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL) - override val states: Set - get() = capabilities?.availableStates.orEmpty() - - override val contentRatings: Set - get() = capabilities?.availableContentRating.orEmpty() + override val filterCapabilities: MangaListFilterCapabilities + get() = capabilities.let { + MangaListFilterCapabilities( + isMultipleTagsSupported = it?.isMultipleTagsSupported == true, + isTagsExclusionSupported = it?.isTagsExclusionSupported == true, + isSearchSupported = it?.isSearchSupported == true, + isSearchWithFiltersSupported = false, // TODO + isYearSupported = false, // TODO + isYearRangeSupported = false, // TODO + isOriginalLocaleSupported = false, // TODO + ) + } override var defaultSortOrder: SortOrder get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL set(value) = Unit - override val isMultipleTagsSupported: Boolean - get() = capabilities?.isMultipleTagsSupported ?: true + override suspend fun getFilterOptions(): MangaListFilterOptions = capabilities.let { + MangaListFilterOptions( + availableTags = runInterruptible(Dispatchers.IO) { + contentSource.getTags() + }, + availableStates = it?.availableStates.orEmpty(), + availableContentRating = it?.availableContentRating.orEmpty(), + availableContentTypes = emptySet(), + availableDemographics = emptySet(), + availableLocales = emptySet(), + ) + } - override val isTagsExclusionSupported: Boolean - get() = capabilities?.isTagsExclusionSupported ?: false - - override val isSearchSupported: Boolean - get() = capabilities?.isSearchSupported ?: true - - override suspend fun getList(offset: Int, filter: MangaListFilter?): List = + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = runInterruptible(Dispatchers.IO) { - contentSource.getList(offset, filter) + contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) } override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) { @@ -70,11 +79,5 @@ class ExternalMangaRepository( override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO - override suspend fun getTags(): Set = runInterruptible(Dispatchers.IO) { - contentSource.getTags() - } - - override suspend fun getLocales(): Set = emptySet() // TODO - override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() // TODO } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt index 4bb2c666f..c032b05a4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt @@ -31,25 +31,18 @@ class ExternalPluginContentSource( @Blocking @WorkerThread - fun getList(offset: Int, filter: MangaListFilter?): List { + fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { val uri = "content://${source.authority}/manga".toUri().buildUpon() uri.appendQueryParameter("offset", offset.toString()) - when (filter) { - is MangaListFilter.Advanced -> { - filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") } - filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") } - filter.states.forEach { uri.appendQueryParameter("state", it.name) } - filter.locale?.let { uri.appendQueryParameter("locale", it.language) } - filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) } - } - - is MangaListFilter.Search -> { - uri.appendQueryParameter("query", filter.query) - } - - null -> Unit + filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") } + filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") } + filter.states.forEach { uri.appendQueryParameter("state", it.name) } + filter.locale?.let { uri.appendQueryParameter("locale", it.language) } + filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) } + if (!filter.query.isNullOrEmpty()) { + uri.appendQueryParameter("query", filter.query) } - return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name) + return contentResolver.query(uri.build(), null, null, null, order.name) .safe() .use { cursor -> val result = ArrayList(cursor.count) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt index e324b1a4a..f8558d3a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt @@ -4,14 +4,22 @@ import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.SortDirection import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED +import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_HOUR +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_MONTH +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_TODAY +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_WEEK +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_YEAR import org.koitharu.kotatsu.parsers.model.SortOrder.RATING import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC +import org.koitharu.kotatsu.parsers.model.SortOrder.RELEVANCE import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC @@ -28,6 +36,14 @@ val SortOrder.titleRes: Int POPULARITY_ASC -> R.string.unpopular RATING_ASC -> R.string.low_rating NEWEST_ASC -> R.string.order_oldest + ADDED -> R.string.recently_added + ADDED_ASC -> R.string.added_long_ago + RELEVANCE -> R.string.by_relevance + POPULARITY_HOUR -> R.string.popular_in_hour + POPULARITY_TODAY -> R.string.popular_today + POPULARITY_WEEK -> R.string.popular_in_week + POPULARITY_MONTH -> R.string.popular_in_month + POPULARITY_YEAR -> R.string.popular_in_year } val SortOrder.direction: SortDirection @@ -36,11 +52,19 @@ val SortOrder.direction: SortDirection POPULARITY_ASC, RATING_ASC, NEWEST_ASC, + ADDED_ASC, ALPHABETICAL -> SortDirection.ASC UPDATED, POPULARITY, + POPULARITY_HOUR, + POPULARITY_TODAY, + POPULARITY_WEEK, + POPULARITY_MONTH, + POPULARITY_YEAR, RATING, NEWEST, + ADDED, + RELEVANCE, ALPHABETICAL_DESC -> SortDirection.DESC } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index 2ebd4ce7f..a8db54adc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformWhile import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.parsers.util.SuspendLazy import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -132,3 +133,5 @@ suspend fun Flow.firstNotNull(): T = checkNotNull(first { x -> x ! suspend fun Flow.firstNotNullOrNull(): T? = firstOrNull { x -> x != null } fun Flow>.flattenLatest() = flatMapLatest { it } + +fun SuspendLazy.asFlow() = flow { emit(tryGet()) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt index 4068968a4..78ba499fc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.core.util.ext -inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this - inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this fun longOf(a: Int, b: Int): Long { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index 19446b1ee..f09f89f72 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -70,15 +70,14 @@ class ExploreRepository @Inject constructor( ): List = runCatchingCancellable { val repository = mangaRepositoryFactory.create(source) val order = repository.sortOrders.random() - val availableTags = repository.getTags() + val availableTags = repository.getFilterOptions().availableTags val tag = tags.firstNotNullOfOrNull { title -> availableTags.find { x -> x.title.almostEquals(title, 0.4f) } } val list = repository.getList( offset = 0, - filter = MangaListFilter.Advanced.Builder(order) - .tags(setOfNotNull(tag)) - .build(), + order = order, + filter = MangaListFilter(tags = setOfNotNull(tag)) ).asArrayList() if (settings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt index fbdb0f045..cb61e7f72 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt @@ -19,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor( return@runCatchingCancellable null } val repository = repositoryFactory.create(manga.source) - val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title)) + val list = repository.getList(offset = 0, null, MangaListFilter(query = manga.title)) val newManga = list.find { x -> x.title == manga.title }?.let { repository.getDetails(it) } ?: return@runCatchingCancellable null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 529a1094d..c2680c6de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -1,272 +1,300 @@ package org.koitharu.kotatsu.filter.ui -import android.view.View import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.GenericSortOrder import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.model.SortDirection -import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.ui.model.direction -import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.LocaleComparator -import org.koitharu.kotatsu.core.util.ext.asArrayList +import org.koitharu.kotatsu.core.util.ext.asFlow import org.koitharu.kotatsu.core.util.ext.lifecycleScope -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal -import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel +import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.filter.ui.model.FilterProperty -import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem -import org.koitharu.kotatsu.list.ui.model.ErrorFooter -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingFooter -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorFooter +import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.YEAR_MIN import org.koitharu.kotatsu.parsers.util.SuspendLazy -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import java.text.Collator -import java.util.EnumSet -import java.util.LinkedList +import java.util.Calendar import java.util.Locale -import java.util.TreeSet import javax.inject.Inject @ViewModelScoped class FilterCoordinator @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - dataRepository: MangaDataRepository, private val searchRepository: MangaSearchRepository, lifecycle: ViewModelLifecycle, -) : MangaFilter { +) { - private val coroutineScope = lifecycle.lifecycleScope + private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])) - private val currentState = MutableStateFlow( - MangaListFilter.Advanced( - sortOrder = repository.defaultSortOrder, - tags = emptySet(), - tagsExclude = emptySet(), - locale = null, - states = emptySet(), - contentRating = emptySet(), - ), - ) - private val localTags = SuspendLazy { - dataRepository.findTags(repository.source) - } - private val tagsFlow = flow { - val localTags = localTags.get() - emit(PendingData(localTags, isLoading = true, error = null)) - tryLoadTags() - .onSuccess { remoteTags -> - emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null)) - }.onFailure { - emit(PendingData(localTags, isLoading = false, error = it)) - } - }.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), PendingData(emptySet(), true, null)) - private var availableTagsDeferred = loadTagsAsync() - private var availableLocalesDeferred = loadLocalesAsync() - private var allTagsLoadJob: Job? = null + private val sourceLocale = (repository.source as? MangaParserSource)?.locale - override val allTags = MutableStateFlow>(listOf(LoadingState)) - get() { - if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) { - loadAllTags() - } - return field - } + private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY) + private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder) - override val filterTags: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.tags }, - getTopTagsAsFlow(currentState.map { it.tags }, 16), - ) { state, tags -> + private val availableSortOrders = repository.sortOrders + private val capabilities = repository.filterCapabilities + private val filterOptions = SuspendLazy { repository.getFilterOptions() } + + val mangaSource: MangaSource + get() = repository.source + + val isFilterApplied: Boolean + get() = !currentListFilter.value.isEmpty() + + val sortOrder: StateFlow> = currentSortOrder.map { selected -> FilterProperty( - availableItems = tags.items.asArrayList(), - selectedItems = state.tags, - isLoading = tags.isLoading, - error = tags.error, + availableItems = availableSortOrders.sortedByOrdinal(), + selectedItem = selected, ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) - override val filterTagsExcluded: StateFlow> = if (repository.isTagsExclusionSupported) { + val tags: StateFlow> = combine( + getTopTags(TAGS_LIMIT), + currentListFilter.distinctUntilChangedBy { it.tags }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.addFirstDistinct(selected.tags), + selectedItems = selected.tags, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val tagsExcluded: StateFlow> = if (capabilities.isTagsExclusionSupported) { combine( - currentState.distinctUntilChangedBy { it.tagsExclude }, - getBottomTagsAsFlow(4), - ) { state, tags -> - FilterProperty( - availableItems = tags.items.asArrayList(), - selectedItems = state.tagsExclude, - isLoading = tags.isLoading, - error = tags.error, + getBottomTags(TAGS_LIMIT), + currentListFilter.distinctUntilChangedBy { it.tagsExclude }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.addFirstDistinct(selected.tagsExclude), + selectedItems = selected.tagsExclude, + ) + }, + onFailure = { + FilterProperty.error(it) + }, ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { - MutableStateFlow(emptyProperty()) + MutableStateFlow(FilterProperty.EMPTY) } - override val filterSortOrder: StateFlow> = - currentState.distinctUntilChangedBy { it.sortOrder }.map { state -> - val orders = repository.sortOrders - FilterProperty( - availableItems = orders.mapTo(EnumSet.noneOf(GenericSortOrder::class.java)) { - GenericSortOrder.of(it) - }.sortedByOrdinal(), - selectedItems = setOf(GenericSortOrder.of(state.sortOrder)), - isLoading = false, - error = null, + val states: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.states }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableStates.sortedByOrdinal(), + selectedItems = selected.states, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val contentRating: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.contentRating }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableContentRating.sortedByOrdinal(), + selectedItems = selected.contentRating, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val contentTypes: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.types }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableContentTypes.sortedByOrdinal(), + selectedItems = selected.types, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val demographics: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.demographics }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableDemographics.sortedByOrdinal(), + selectedItems = selected.demographics, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val locale: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.locale }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), + selectedItems = setOfNotNull(selected.locale), + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val originalLocale: StateFlow> = if (capabilities.isOriginalLocaleSupported) { + combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.originalLocale }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), + selectedItems = setOfNotNull(selected.originalLocale), + ) + }, + onFailure = { + FilterProperty.error(it) + }, ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + } else { + MutableStateFlow(FilterProperty.EMPTY) + } - override val filterSortDirection: StateFlow> = - currentState.distinctUntilChangedBy { it.sortOrder }.map { state -> - val orders = repository.sortOrders + val year: StateFlow> = if (capabilities.isYearSupported) { + currentListFilter.distinctUntilChangedBy { it.year }.map { selected -> FilterProperty( - availableItems = state.sortOrder.let { - val genericOrder = GenericSortOrder.of(it) - val result = EnumSet.noneOf(SortDirection::class.java) - if (genericOrder.ascending in orders) result.add(SortDirection.ASC) - if (genericOrder.descending in orders) result.add(SortDirection.DESC) - result - }?.sortedByOrdinal().orEmpty(), - selectedItems = setOf(state.sortOrder.direction), - isLoading = false, - error = null, + availableItems = listOf(YEAR_MIN, MAX_YEAR), + selectedItems = setOf(selected.year), ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + } else { + MutableStateFlow(FilterProperty.EMPTY) + } - override val filterState: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.states }, - flowOf(repository.states), - ) { state, states -> - FilterProperty( - availableItems = states.sortedByOrdinal(), - selectedItems = state.states, - isLoading = false, - error = null, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + val yearRange: StateFlow> = if (capabilities.isYearRangeSupported) { + currentListFilter.distinctUntilChanged { old, new -> + old.yearTo == new.yearTo && old.yearFrom == new.yearFrom + }.map { selected -> + FilterProperty( + availableItems = listOf(YEAR_MIN, MAX_YEAR), + selectedItems = setOf(selected.yearFrom, selected.yearTo), + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + } else { + MutableStateFlow(FilterProperty.EMPTY) + } - override val filterContentRating: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.contentRating }, - flowOf(repository.contentRatings), - ) { rating, ratings -> - FilterProperty( - availableItems = ratings.sortedByOrdinal(), - selectedItems = rating.contentRating, - isLoading = false, - error = null, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + fun reset() { + currentListFilter.value = MangaListFilter.EMPTY + } - override val filterLocale: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.locale }, - getLocalesAsFlow(), - ) { state, locales -> - val list = if (locales.items.isNotEmpty()) { - val l = ArrayList(locales.items.size + 1) - l.add(null) - l.addAll(locales.items) - try { - l.sortWith(nullsFirst(LocaleComparator())) - } catch (e: IllegalArgumentException) { - e.printStackTraceDebug() - } - l - } else { - emptyList() - } - FilterProperty( - availableItems = list, - selectedItems = setOf(state.locale), - isLoading = locales.isLoading, - error = locales.error, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) - - override val header: StateFlow = getHeaderFlow().stateIn( - scope = coroutineScope + Dispatchers.Default, - started = SharingStarted.Lazily, - initialValue = FilterHeaderModel( - chips = emptyList(), - sortOrder = repository.defaultSortOrder, - isFilterApplied = false, - ), + fun snapshot() = Snapshot( + sortOrder = currentSortOrder.value, + listFilter = currentListFilter.value, ) - override fun applyFilter(tags: Set) { - setTags(tags) + fun observe(): Flow = combine(currentSortOrder, currentListFilter, ::Snapshot) + + fun setSortOrder(newSortOrder: SortOrder) { + currentSortOrder.value = newSortOrder } - override fun setSortOrder(value: SortOrder) { - val available = repository.sortOrders - val sortOrder = if (value !in available) { - val generic = GenericSortOrder.of(value) - when { - generic.ascending in available -> generic.ascending - generic.descending in available -> generic.descending - else -> return - } - } else { - value - } - currentState.update { oldValue -> - oldValue.copy(sortOrder = sortOrder) - } - repository.defaultSortOrder = sortOrder + fun set(value: MangaListFilter) { + currentListFilter.value = value } - override fun setLanguage(value: Locale?) { - currentState.update { oldValue -> + fun setLocale(value: Locale?) { + currentListFilter.update { oldValue -> oldValue.copy(locale = value) } } - override fun setTag(value: MangaTag, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newTags = if (repository.isMultipleTagsSupported) { - if (addOrRemove) { - oldValue.tags + value - } else { - oldValue.tags - value - } + fun setYear(value: Int) { + currentListFilter.update { oldValue -> + oldValue.copy(year = value) + } + } + + fun toggleState(value: MangaState, isSelected: Boolean) { + currentListFilter.update { oldValue -> + oldValue.copy( + states = if (isSelected) oldValue.states + value else oldValue.states - value, + ) + } + } + + fun toggleContentRating(value: ContentRating, isSelected: Boolean) { + currentListFilter.update { oldValue -> + oldValue.copy( + contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value, + ) + } + } + + fun toggleTag(value: MangaTag, isSelected: Boolean) { + currentListFilter.update { oldValue -> + val newTags = if (capabilities.isMultipleTagsSupported) { + if (isSelected) oldValue.tags + value else oldValue.tags - value } else { - if (addOrRemove) { - setOf(value) - } else { - emptySet() - } + if (isSelected) setOf(value) else emptySet() } oldValue.copy( tags = newTags, @@ -275,266 +303,91 @@ class FilterCoordinator @Inject constructor( } } - override fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newTags = if (repository.isMultipleTagsSupported) { - if (addOrRemove) { - oldValue.tagsExclude + value - } else { - oldValue.tagsExclude - value - } + fun toggleTagExclude(value: MangaTag, isSelected: Boolean) { + currentListFilter.update { oldValue -> + val newTagsExclude = if (capabilities.isMultipleTagsSupported) { + if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value } else { - if (addOrRemove) { - setOf(value) - } else { - emptySet() - } + if (isSelected) setOf(value) else emptySet() } oldValue.copy( - tagsExclude = newTags, - tags = oldValue.tags - newTags, + tags = oldValue.tags - newTagsExclude, + tagsExclude = newTagsExclude, ) } } - override fun setState(value: MangaState, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newStates = if (addOrRemove) { - oldValue.states + value - } else { - oldValue.states - value - } - oldValue.copy(states = newStates) + fun getAllTags(): Flow>> = filterOptions.asFlow().map { + it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) } + } + + private fun getTopTags(limit: Int): Flow>> = combine( + flow { emit(searchRepository.getTopTags(repository.source, limit)) }, + filterOptions.asFlow(), + ) { suggested, options -> + val all = options.getOrNull()?.availableTags.orEmpty() + val result = ArrayList(limit) + result.addAll(suggested.take(limit)) + if (result.size < limit) { + result.addAll(all.shuffled().take(limit - result.size)) } - } - - override fun setContentRating(value: ContentRating, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newRating = if (addOrRemove) { - oldValue.contentRating + value - } else { - oldValue.contentRating - value - } - oldValue.copy(contentRating = newRating) - } - } - - override fun onListHeaderClick(item: ListHeader, view: View) { - currentState.update { oldValue -> - oldValue.copy( - sortOrder = oldValue.sortOrder, - tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags, - locale = if (item.payload == R.string.language) null else oldValue.locale, - states = if (item.payload == R.string.state) emptySet() else oldValue.states, - ) - } - } - - fun observeAvailableTags(): Flow?> = flow { - if (!availableTagsDeferred.isCompleted) { - emit(emptySet()) - } - emit(availableTagsDeferred.await().getOrNull()) - } - - fun observeState() = currentState.asStateFlow() - - fun setTags(tags: Set) { - currentState.update { oldValue -> - oldValue.copy( - tags = tags, - tagsExclude = oldValue.tagsExclude - tags, - ) - } - } - - fun reset() { - currentState.update { oldValue -> - MangaListFilter.Advanced.Builder(oldValue.sortOrder).build() - } - } - - fun snapshot() = currentState.value - - private fun getHeaderFlow() = combine( - observeState(), - observeAvailableTags(), - ) { state, available -> - val chips = createChipsList(state, available.orEmpty(), 8) - FilterHeaderModel( - chips = chips, - sortOrder = state.sortOrder, - isFilterApplied = !state.isEmpty(), - ) - } - - private fun getLocalesAsFlow(): Flow> = flow { - emit(PendingData(emptySet(), isLoading = true, error = null)) - tryLoadLocales() - .onSuccess { locales -> - emit(PendingData(locales, isLoading = false, error = null)) - }.onFailure { - emit(PendingData(emptySet(), isLoading = false, error = it)) - } - } - - private fun getTopTagsAsFlow(selectedTags: Flow>, limit: Int): Flow> = combine( - selectedTags.map { - if (it.isEmpty()) { - searchRepository.getTagsSuggestion("", limit, repository.source) - } else { - searchRepository.getTagsSuggestion(it).take(limit) - } - }, - tagsFlow, - ) { suggested, all -> - val res = suggested.toMutableList() - if (res.size < limit) { - res.addAll(all.items.shuffled().take(limit - res.size)) - } - PendingData(res, all.isLoading, all.error.takeIf { res.size < limit }) - } - - private fun getBottomTagsAsFlow(limit: Int): Flow> = combine( - flow { emit(searchRepository.getRareTags(repository.source, limit)) }, - tagsFlow, - ) { suggested, all -> - val res = suggested.toMutableList() - if (res.size < limit) { - res.addAll(all.items.shuffled().take(limit - res.size)) - } - PendingData(res, all.isLoading, all.error.takeIf { res.size < limit }) - } - - private suspend fun createChipsList( - filterState: MangaListFilter.Advanced, - availableTags: Set, - limit: Int, - ): List { - val selectedTags = filterState.tags.toMutableSet() - var tags = if (selectedTags.isEmpty()) { - searchRepository.getTagsSuggestion("", limit, repository.source) + if (result.isNotEmpty()) { + Result.success(result) } else { - searchRepository.getTagsSuggestion(selectedTags).take(limit) + options.map { result } } - if (tags.size < limit) { - tags = tags + availableTags.take(limit - tags.size) + } + + private fun getBottomTags(limit: Int): Flow>> = combine( + flow { emit(searchRepository.getRareTags(repository.source, limit)) }, + filterOptions.asFlow(), + ) { suggested, options -> + val all = options.getOrNull()?.availableTags.orEmpty() + val result = ArrayList(limit) + result.addAll(suggested.take(limit)) + if (result.size < limit) { + result.addAll(all.shuffled().take(limit - result.size)) } - if (tags.isEmpty() && selectedTags.isEmpty()) { - return emptyList() + if (result.isNotEmpty()) { + Result.success(result) + } else { + options.map { result } } - val result = LinkedList() - for (tag in tags) { - val model = ChipsView.ChipModel( - title = tag.title, - isChecked = selectedTags.remove(tag), - data = tag, - ) - if (model.isChecked) { - result.addFirst(model) - } else { - result.addLast(model) + } + + private fun List.addFirstDistinct(other: Collection): List { + val result = ArrayDeque(this.size + other.size) + result.addAll(this) + for (item in other) { + if (item !in result) { + result.addFirst(item) } } - for (tag in selectedTags) { - val model = ChipsView.ChipModel( - title = tag.title, - isChecked = true, - data = tag, - ) - result.addFirst(model) + return result + } + + private fun List.addFirstDistinct(item: T): List { + val result = ArrayDeque(this.size + 1) + result.addAll(this) + if (item !in result) { + result.addFirst(item) } return result } - private suspend fun tryLoadTags(): Result> { - val shouldRetryOnError = availableTagsDeferred.isCompleted - val result = availableTagsDeferred.await() - if (result.isFailure && shouldRetryOnError) { - availableTagsDeferred = loadTagsAsync() - return availableTagsDeferred.await() - } - return result - } - - private suspend fun tryLoadLocales(): Result> { - val shouldRetryOnError = availableLocalesDeferred.isCompleted - val result = availableLocalesDeferred.await() - if (result.isFailure && shouldRetryOnError) { - availableLocalesDeferred = loadLocalesAsync() - return availableLocalesDeferred.await() - } - return result - } - - private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { - runCatchingCancellable { - repository.getTags() - }.onFailure { error -> - error.printStackTraceDebug() - } - } - - private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { - runCatchingCancellable { - repository.getLocales() - }.onFailure { error -> - error.printStackTraceDebug() - } - } - - private fun mergeTags(primary: Set, secondary: Set): Set { - val result = TreeSet(TagTitleComparator((repository.source as? MangaParserSource)?.locale)) - result.addAll(secondary) - result.addAll(primary) - return result - } - - private fun loadAllTags() { - val prevJob = allTagsLoadJob - allTagsLoadJob = coroutineScope.launch(Dispatchers.Default) { - runCatchingCancellable { - prevJob?.cancelAndJoin() - appendTagsList(localTags.get(), isLoading = true) - appendTagsList(availableTagsDeferred.await().getOrThrow(), isLoading = false) - }.onFailure { e -> - allTags.value = allTags.value.filterIsInstance() + e.toErrorFooter() - } - } - } - - private fun appendTagsList(newTags: Collection, isLoading: Boolean) = allTags.update { oldList -> - val oldTags = oldList.filterIsInstance() - buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) { - addAll(oldTags) - newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) } - val tempSet = HashSet(size) - removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) } - sortBy { (it as TagCatalogItem).tag.title } - if (isLoading) { - add(LoadingFooter()) - } - } - } - - private data class PendingData( - val items: Collection, - val isLoading: Boolean, - val error: Throwable?, + data class Snapshot( + val sortOrder: SortOrder, + val listFilter: MangaListFilter, ) - private fun loadingProperty() = FilterProperty(emptyList(), emptySet(), true, null) + interface Owner { - private fun emptyProperty() = FilterProperty(emptyList(), emptySet(), false, null) + val filterCoordinator: FilterCoordinator + } - private class TagTitleComparator(lc: String?) : Comparator { + private companion object { - private val collator = lc?.let { Collator.getInstance(Locale(it)) } - - override fun compare(o1: MangaTag, o2: MangaTag): Int { - val t1 = o1.title.lowercase() - val t2 = o2.title.lowercase() - return collator?.compare(t1, t2) ?: compareValues(t1, t2) - } + const val TAGS_LIMIT = 12 + val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index 1df723a18..e34b872ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -6,6 +6,9 @@ import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.isVisible import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ChipsView @@ -15,12 +18,17 @@ import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.MangaTag +import javax.inject.Inject import com.google.android.material.R as materialR +@AndroidEntryPoint class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener { - private val filter: MangaFilter - get() = (requireActivity() as FilterOwner).filter + @Inject + lateinit var filterHeaderProducer: FilterHeaderProducer + + private val filter: FilterCoordinator + get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { return FragmentFilterHeaderBinding.inflate(inflater, container, false) @@ -29,7 +37,9 @@ class FilterHeaderFragment : BaseFragment(), ChipsV override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.chipsTags.onChipClickListener = this - filter.header.observe(viewLifecycleOwner, ::onDataChanged) + filterHeaderProducer.observeHeader(filter) + .flowOn(Dispatchers.Default) + .observe(viewLifecycleOwner, ::onDataChanged) } override fun onWindowInsetsChanged(insets: Insets) = Unit @@ -39,7 +49,7 @@ class FilterHeaderFragment : BaseFragment(), ChipsV if (tag == null) { TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) } else { - filter.setTag(tag, !chip.isChecked) + filter.toggleTag(tag, !chip.isChecked) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt new file mode 100644 index 000000000..02ea5169f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -0,0 +1,75 @@ +package org.koitharu.kotatsu.filter.ui + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapLatest +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel +import org.koitharu.kotatsu.filter.ui.model.FilterProperty +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import java.util.LinkedList +import javax.inject.Inject + +class FilterHeaderProducer @Inject constructor( + private val searchRepository: MangaSearchRepository, +) { + + fun observeHeader(filterCoordinator: FilterCoordinator): Flow { + return filterCoordinator.tags.mapLatest { + createChipsList( + source = filterCoordinator.mangaSource, + property = it, + limit = 8, + ) + }.combine(filterCoordinator.observe()) { chipList, snapshot -> + FilterHeaderModel( + chips = chipList, + sortOrder = snapshot.sortOrder, + isFilterApplied = !snapshot.listFilter.isEmpty(), + ) + } + } + + private suspend fun createChipsList( + source: MangaSource, + property: FilterProperty, + limit: Int, + ): List { + val selectedTags = property.selectedItems.toMutableSet() + var tags = if (selectedTags.isEmpty()) { + searchRepository.getTagsSuggestion("", limit, source) + } else { + searchRepository.getTagsSuggestion(selectedTags).take(limit) + } + if (tags.size < limit) { + tags = tags + property.availableItems.take(limit - tags.size) + } + if (tags.isEmpty() && selectedTags.isEmpty()) { + return emptyList() + } + val result = LinkedList() + for (tag in tags) { + val model = ChipsView.ChipModel( + title = tag.title, + isChecked = selectedTags.remove(tag), + data = tag, + ) + if (model.isChecked) { + result.addFirst(model) + } else { + result.addLast(model) + } + } + for (tag in selectedTags) { + val model = ChipsView.ChipModel( + title = tag.title, + isChecked = true, + data = tag, + ) + result.addFirst(model) + } + return result + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt deleted file mode 100644 index d75f81c4a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -interface FilterOwner { - - val filter: MangaFilter -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt deleted file mode 100644 index 11dfcef1a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import kotlinx.coroutines.flow.StateFlow -import org.koitharu.kotatsu.core.model.GenericSortOrder -import org.koitharu.kotatsu.core.model.SortDirection -import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel -import org.koitharu.kotatsu.filter.ui.model.FilterProperty -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import java.util.Locale - -interface MangaFilter : OnFilterChangedListener { - - val allTags: StateFlow> - - val filterTags: StateFlow> - - val filterTagsExcluded: StateFlow> - - val filterSortOrder: StateFlow> - - val filterSortDirection: StateFlow> - - val filterState: StateFlow> - - val filterContentRating: StateFlow> - - val filterLocale: StateFlow> - - val header: StateFlow - - fun applyFilter(tags: Set) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt deleted file mode 100644 index 785f32ec6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.Locale - -interface OnFilterChangedListener : ListHeaderClickListener { - - fun setSortOrder(value: SortOrder) - - fun setLanguage(value: Locale?) - - fun setTag(value: MangaTag, addOrRemove: Boolean) - - fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) - - fun setState(value: MangaState, addOrRemove: Boolean) - - fun setContentRating(value: ContentRating, addOrRemove: Boolean) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt index a05157a3d..54777769a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt @@ -1,11 +1,53 @@ package org.koitharu.kotatsu.filter.ui.model -data class FilterProperty( +data class FilterProperty( val availableItems: List, val selectedItems: Set, val isLoading: Boolean, val error: Throwable?, ) { + constructor( + availableItems: List, + selectedItems: Set, + ) : this( + availableItems = availableItems, + selectedItems = selectedItems, + isLoading = false, + error = null, + ) + + constructor( + availableItems: List, + selectedItem: T, + ) : this( + availableItems = availableItems, + selectedItems = setOf(selectedItem), + isLoading = false, + error = null, + ) + fun isEmpty(): Boolean = availableItems.isEmpty() + + companion object { + + val LOADING = FilterProperty( + availableItems = emptyList(), + selectedItems = emptySet(), + isLoading = true, + error = null, + ) + + val EMPTY = FilterProperty( + availableItems = emptyList(), + selectedItems = emptySet(), + ) + + fun error(error: Throwable) = FilterProperty( + availableItems = emptyList(), + selectedItems = emptySet(), + isLoading = false, + error = error, + ) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index e963deb77..84dfd9034 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -13,31 +13,35 @@ import androidx.core.view.updatePadding import androidx.fragment.app.FragmentManager import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.chip.Chip +import com.google.android.material.slider.Slider import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.GenericSortOrder import org.koitharu.kotatsu.core.model.SortDirection import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.parentView +import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.SheetFilterBinding -import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN import java.util.Locale import com.google.android.material.R as materialR class FilterSheetFragment : BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, - ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener { + ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, Slider.OnChangeListener { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) @@ -52,13 +56,14 @@ class FilterSheetFragment : BaseAdaptiveSheet(), } } val filter = requireFilter() - filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) - filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged) - filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged) - filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged) - filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) - filter.filterState.observe(viewLifecycleOwner, this::onStateChanged) - filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) + filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) + // filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged) + filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged) + filter.tags.observe(viewLifecycleOwner, this::onTagsChanged) + filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) + filter.states.observe(viewLifecycleOwner, this::onStateChanged) + filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) + filter.year.observe(viewLifecycleOwner, this::onYearChanged) binding.spinnerLocale.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this @@ -66,12 +71,13 @@ class FilterSheetFragment : BaseAdaptiveSheet(), binding.chipsContentRating.onChipClickListener = this binding.chipsGenres.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this + binding.sliderYear.addOnChangeListener(this) binding.layoutSortDirection.addOnButtonCheckedListener(this) } override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { if (isChecked) { - setSortDirection(getSortDirection(checkedId) ?: return) + // setSortDirection(getSortDirection(checkedId) ?: return) } } @@ -79,33 +85,43 @@ class FilterSheetFragment : BaseAdaptiveSheet(), val filter = requireFilter() when (parent.id) { R.id.spinner_order -> { - val genericOrder = filter.filterSortOrder.value.availableItems[position] - val direction = getSortDirection(requireViewBinding().layoutSortDirection.checkedButtonId) - filter.setSortOrder(genericOrder[direction ?: SortDirection.DESC]) + val value = filter.sortOrder.value.availableItems[position] + filter.setSortOrder(value) } - R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position]) + R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position]) } } override fun onNothingSelected(parent: AdapterView<*>?) = Unit + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (!fromUser) { + return + } + val intValue = value.toInt() + val filter = requireFilter() + when (slider.id) { + R.id.slider_year -> filter.setYear(intValue) + } + } + override fun onChipClick(chip: Chip, data: Any?) { val filter = requireFilter() when (data) { - is MangaState -> filter.setState(data, !chip.isChecked) + is MangaState -> filter.toggleState(data, !chip.isChecked) is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { - filter.setTagExcluded(data, !chip.isChecked) + filter.toggleTagExclude(data, !chip.isChecked) } else { - filter.setTag(data, !chip.isChecked) + filter.toggleTag(data, !chip.isChecked) } - is ContentRating -> filter.setContentRating(data, !chip.isChecked) + is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude) } } - private fun onSortOrderChanged(value: FilterProperty) { + private fun onSortOrderChanged(value: FilterProperty) { val b = viewBinding ?: return b.textViewOrderTitle.isGone = value.isEmpty() b.cardOrder.isGone = value.isEmpty() @@ -117,7 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), b.spinnerOrder.context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, - value.availableItems.map { b.spinnerOrder.context.getString(it.titleResId) }, + value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) }, ) val selectedIndex = value.availableItems.indexOf(selected) if (selectedIndex >= 0) { @@ -271,15 +287,20 @@ class FilterSheetFragment : BaseAdaptiveSheet(), b.chipsContentRating.setChips(chips) } - private fun requireFilter() = (requireActivity() as FilterOwner).filter - - private fun setSortDirection(direction: SortDirection) { - val filter = requireFilter() - val currentOrder = filter.filterSortOrder.value.selectedItems.singleOrNull() ?: return - val newOrder = currentOrder[direction] - filter.setSortOrder(newOrder) + private fun onYearChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewYear.isGone = value.isEmpty() + b.sliderYear.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + b.sliderYear.valueFrom = value.availableItems.first().toFloat() + b.sliderYear.valueTo = value.availableItems.last().toFloat() + b.sliderYear.setValueRounded((value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN).toFloat()) } + private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator + private fun getSortDirection(@IdRes buttonId: Int): SortDirection? = when (buttonId) { R.id.button_order_asc -> SortDirection.ASC R.id.button_order_desc -> SortDirection.DESC diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt new file mode 100644 index 000000000..6d827d2ff --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.filter.ui.tags + +import org.koitharu.kotatsu.parsers.model.MangaTag +import java.text.Collator +import java.util.Locale + +class TagTitleComparator(lc: String?) : Comparator { + + private val collator = lc?.let { Collator.getInstance(Locale(it)) } + + override fun compare(o1: MangaTag, o2: MangaTag): Int { + val t1 = o1.title.lowercase() + val t2 = o2.title.lowercase() + return collator?.compare(t1, t2) ?: compareValues(t1, t2) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt index ea17faec5..24e1e529e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetTagsBinding -import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem @AndroidEntryPoint @@ -32,7 +32,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( - filter = (requireActivity() as FilterOwner).filter, + filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator, isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE), ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt index 092c6950c..5b83142b1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt @@ -14,40 +14,43 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.parsers.model.MangaTag @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) class TagsCatalogViewModel @AssistedInject constructor( - @Assisted private val filter: MangaFilter, + @Assisted private val filter: FilterCoordinator, @Assisted private val isExcluded: Boolean, ) : BaseViewModel() { val searchQuery = MutableStateFlow("") private val filterProperty: StateFlow> - get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags + get() = if (isExcluded) filter.tagsExcluded else filter.tags - private val tags = combine( - filter.allTags, + private val tags: StateFlow> = combine( + filter.getAllTags(), filterProperty.map { it.selectedItems }, ) { all, selected -> - all.map { x -> - if (x is TagCatalogItem) { - val checked = x.tag in selected - if (x.isChecked == checked) { - x - } else { - x.copy(isChecked = checked) + all.fold( + onSuccess = { + it.map { tag -> + TagCatalogItem( + tag = tag, + isChecked = tag in selected, + ) } - } else { - x - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value) + }, + onFailure = { + listOf(it.toErrorState(false)) + }, + ) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) val content = combine(tags, searchQuery) { raw, query -> raw.filter { x -> @@ -57,15 +60,15 @@ class TagsCatalogViewModel @AssistedInject constructor( fun handleTagClick(tag: MangaTag, isChecked: Boolean) { if (isExcluded) { - filter.setTagExcluded(tag, !isChecked) + filter.toggleTagExclude(tag, !isChecked) } else { - filter.setTag(tag, !isChecked) + filter.toggleTag(tag, !isChecked) } } @AssistedFactory interface Factory { - fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel + fun create(filter: FilterCoordinator, isExcludeTag: Boolean): TagsCatalogViewModel } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt index e71253b0b..e266be08b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt @@ -4,7 +4,7 @@ import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.util.ext.getDisplayIcon -import org.koitharu.kotatsu.core.util.ext.ifZero +import org.koitharu.kotatsu.parsers.util.ifZero fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState( exception = this, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt index c28db648e..4512e2488 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -28,7 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag @@ -105,11 +105,11 @@ class PreviewFragment : BaseFragment(), View.OnClickList override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag ?: return - val filter = (activity as? FilterOwner)?.filter + val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator if (filter == null) { startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) } else { - filter.setTag(tag, true) + filter.toggleTag(tag, true) closeSelf() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 7c055de1d..54f8f0030 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -25,18 +25,16 @@ import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.util.EnumSet -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -53,12 +51,15 @@ class LocalMangaRepository @Inject constructor( override val source = LocalMangaSource private val localMappingCache = LocalMangaMappingCache() - override val isMultipleTagsSupported: Boolean = true - override val isTagsExclusionSupported: Boolean = true - override val isSearchSupported: Boolean = true + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) - override val states = emptySet() - override val contentRatings = emptySet() override var defaultSortOrder: SortOrder get() = settings.localListOrder @@ -66,7 +67,9 @@ class LocalMangaRepository @Inject constructor( settings.localListOrder = value } - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + override suspend fun getFilterOptions() = MangaListFilterOptions() + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { if (offset > 0) { return emptyList() } @@ -74,30 +77,25 @@ class LocalMangaRepository @Inject constructor( if (settings.isNsfwContentDisabled) { list.removeIf { it.manga.isNsfw } } - when (filter) { - is MangaListFilter.Search -> { - list.retainAll { x -> x.isMatchesQuery(filter.query) } + if (filter != null) { + val query = filter.query + if (!query.isNullOrEmpty()) { + list.retainAll { x -> x.isMatchesQuery(query) } } - - is MangaListFilter.Advanced -> { - if (filter.tags.isNotEmpty()) { - list.retainAll { x -> x.containsTags(filter.tags) } - } - if (filter.tagsExclude.isNotEmpty()) { - list.removeAll { x -> x.containsAnyTag(filter.tags) } - } - when (filter.sortOrder) { - SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) - SortOrder.RATING -> list.sortByDescending { it.manga.rating } - SortOrder.NEWEST, - SortOrder.UPDATED, - -> list.sortByDescending { it.createdAt } - - else -> Unit - } + if (filter.tags.isNotEmpty()) { + list.retainAll { x -> x.containsTags(filter.tags) } } + if (filter.tagsExclude.isNotEmpty()) { + list.removeAll { x -> x.containsAnyTag(filter.tags) } + } + } + when (order) { + SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) + SortOrder.RATING -> list.sortByDescending { it.manga.rating } + SortOrder.NEWEST, + SortOrder.UPDATED -> list.sortByDescending { it.createdAt } - null -> Unit + else -> Unit } return list.unwrap() } @@ -173,10 +171,6 @@ class LocalMangaRepository @Inject constructor( override suspend fun getPageUrl(page: MangaPage) = page.url - override suspend fun getTags() = emptySet() - - override suspend fun getLocales() = emptySet() - override suspend fun getRelated(seed: Manga): List = emptyList() suspend fun getOutputDir(manga: Manga): File? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt index 506b3a657..d4b5ee251 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt @@ -27,7 +27,7 @@ class DeleteLocalMangaUseCase @Inject constructor( } suspend operator fun invoke(ids: Set) { - val list = localMangaRepository.getList(0, null) + val list = localMangaRepository.getList(0, null, null) var removed = 0 for (manga in list) { if (manga.id in ids) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt index c484f949c..877395a40 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt @@ -38,7 +38,7 @@ class DeleteReadChaptersUseCase @Inject constructor( } suspend operator fun invoke(): Int { - val list = localMangaRepository.getList(0, null) + val list = localMangaRepository.getList(0, null, null) if (list.isEmpty()) { return 0 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 8deaed651..44794afa4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -23,15 +23,14 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity -class LocalListFragment : MangaListFragment(), FilterOwner { +class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { private val permissionRequestLauncher = registerForActivityResult( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -56,8 +55,8 @@ class LocalListFragment : MangaListFragment(), FilterOwner { override val viewModel by viewModels() - override val filter: MangaFilter - get() = viewModel + override val filterCoordinator: FilterCoordinator + get() = viewModel.filterCoordinator override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 7e566c679..ac8993e9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator +import org.koitharu.kotatsu.filter.ui.FilterHeaderProducer import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel @@ -32,7 +33,7 @@ import javax.inject.Inject class LocalListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - filter: FilterCoordinator, + filterCoordinator: FilterCoordinator, private val settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, mangaListMapper: MangaListMapper, @@ -40,11 +41,12 @@ class LocalListViewModel @Inject constructor( exploreRepository: ExploreRepository, @LocalStorageChanges private val localStorageChanges: SharedFlow, private val localStorageManager: LocalStorageManager, + filterHeaderProducer: FilterHeaderProducer, sourcesRepository: MangaSourcesRepository, ) : RemoteListViewModel( savedStateHandle, mangaRepositoryFactory, - filter, + filterCoordinator, settings, mangaListMapper, downloadScheduler, @@ -58,7 +60,7 @@ class LocalListViewModel @Inject constructor( launchJob(Dispatchers.Default) { localStorageChanges .collect { - loadList(filter.snapshot(), append = false).join() + loadList(filterCoordinator.snapshot(), append = false).join() } } settings.subscribe(this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 05bffd1bd..a0341f7ba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -62,7 +62,6 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.assertNotNull import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.PageLoader @@ -452,9 +451,9 @@ class ReaderViewModel @Inject constructor( @WorkerThread private fun notifyStateChanged() { - val state = getCurrentState().assertNotNull("state") ?: return - val chapter = chaptersLoader.peekChapter(state.chapterId).assertNotNull("chapter") ?: return - val m = mangaDetails.value.assertNotNull("manga") ?: return + val state = getCurrentState() ?: return + val chapter = chaptersLoader.peekChapter(state.chapterId) ?: return + val m = mangaDetails.value ?: return val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 val newState = ReaderUiState( mangaName = m.toManga().title, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index e724884b4..21a0d5639 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -16,9 +16,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.ifZero import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.databinding.ItemPageBinding +import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 745a57971..e2629e4a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -11,8 +11,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.ifZero import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding +import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 626dc91f1..b8d7ff663 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -25,8 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner @@ -35,12 +34,12 @@ import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.settings.SettingsActivity @AndroidEntryPoint -class RemoteListFragment : MangaListFragment(), FilterOwner { +class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { override val viewModel by viewModels() - override val filter: MangaFilter - get() = viewModel + override val filterCoordinator: FilterCoordinator + get() = viewModel.filterCoordinator override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) @@ -49,7 +48,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner { viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { startActivity(DetailsActivity.newIntent(binding.root.context, it)) } - viewModel.header.distinctUntilChangedBy { it.isFilterApplied } + filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() } .drop(1) .observe(viewLifecycleOwner) { activity?.invalidateMenu() @@ -130,7 +129,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner { super.onPrepareMenu(menu) menu.findItem(R.id.action_search)?.isVisible = viewModel.isSearchAvailable menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value - menu.findItem(R.id.action_filter_reset)?.isVisible = viewModel.header.value.isFilterApplied + menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied } override fun onQueryTextSubmit(query: String?): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 6c96b8ff7..30906a4c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -31,7 +31,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState @@ -52,13 +51,13 @@ private const val FILTER_MIN_INTERVAL = 250L open class RemoteListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - private val filter: FilterCoordinator, + override val filterCoordinator: FilterCoordinator, settings: AppSettings, mangaListMapper: MangaListMapper, downloadScheduler: DownloadWorker.Scheduler, private val exploreRepository: ExploreRepository, sourcesRepository: MangaSourcesRepository, -) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter { +) : MangaListViewModel(settings, downloadScheduler), FilterCoordinator.Owner { val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]) val isRandomLoading = MutableStateFlow(false) @@ -72,7 +71,7 @@ open class RemoteListViewModel @Inject constructor( private var randomJob: Job? = null val isSearchAvailable: Boolean - get() = repository.isSearchSupported + get() = repository.filterCapabilities.isSearchSupported val browserUrl: String? get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" } @@ -93,7 +92,7 @@ open class RemoteListViewModel @Inject constructor( ) list == null -> add(LoadingState) - list.isEmpty() -> add(createEmptyState(canResetFilter = header.value.isFilterApplied)) + list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied)) else -> { mangaListMapper.toListModelList(this, list, mode) when { @@ -107,7 +106,7 @@ open class RemoteListViewModel @Inject constructor( }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) init { - filter.observeState() + filterCoordinator.observe() .debounce(FILTER_MIN_INTERVAL) .onEach { filterState -> loadingJob?.cancelAndJoin() @@ -123,26 +122,26 @@ open class RemoteListViewModel @Inject constructor( } override fun onRefresh() { - loadList(filter.snapshot(), append = false) + loadList(filterCoordinator.snapshot(), append = false) } override fun onRetry() { - loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty()) + loadList(filterCoordinator.snapshot(), append = !mangaList.value.isNullOrEmpty()) } fun loadNextPage() { if (hasNextPage.value && listError.value == null) { - loadList(filter.snapshot(), append = true) + loadList(filterCoordinator.snapshot(), append = true) } } - fun resetFilter() = filter.reset() + fun resetFilter() = filterCoordinator.reset() override fun onUpdateFilter(tags: Set) { - applyFilter(tags) + filterCoordinator.set(MangaListFilter(tags = tags)) } - protected fun loadList(filterState: MangaListFilter.Advanced, append: Boolean): Job { + protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job { loadingJob?.let { if (it.isActive) return it } @@ -151,7 +150,8 @@ open class RemoteListViewModel @Inject constructor( listError.value = null val list = repository.getList( offset = if (append) mangaList.value.sizeOrZero() else 0, - filter = filterState, + order = filterState.sortOrder, + filter = filterState.listFilter, ) val prevList = mangaList.value.orEmpty() if (!append) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index cf21cdf49..217cee55a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -28,6 +28,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index e3df45981..924334415 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -125,6 +125,10 @@ class MangaSearchRepository @Inject constructor( return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList() } + suspend fun getTopTags(source: MangaSource, limit: Int): List { + return db.getTagsDao().findPopularTags(source.name, limit).toMangaTagsList() + } + suspend fun getSourcesSuggestion(limit: Int): List = sourcesRepository.getTopSources(limit) fun getSourcesSuggestion(query: String, limit: Int): List { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 3819bb9ce..d7bd95c92 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -36,14 +36,14 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ActivityMangaListBinding +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.preview.PreviewFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @@ -53,15 +53,15 @@ import com.google.android.material.R as materialR @AndroidEntryPoint class MangaListActivity : BaseActivity(), - AppBarOwner, View.OnClickListener, FilterOwner, AppBarLayout.OnOffsetChangedListener { + AppBarOwner, View.OnClickListener, FilterCoordinator.Owner, AppBarLayout.OnOffsetChangedListener { override val appBar: AppBarLayout get() = viewBinding.appbar - override val filter: MangaFilter + override val filterCoordinator: FilterCoordinator get() = checkNotNull(findFilterOwner()) { - "Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}" - }.filter + "Cannot find FilterCoordinator.Owner fragment in ${supportFragmentManager.fragments}" + }.filterCoordinator private var source: MangaSource? = null @@ -122,7 +122,7 @@ class MangaListActivity : private fun initList(source: MangaSource, tags: Set?) { val fm = supportFragmentManager val existingFragment = fm.findFragmentById(R.id.container) - if (existingFragment is FilterOwner) { + if (existingFragment is FilterCoordinator.Owner) { initFilter(existingFragment) } else { fm.commit { @@ -141,7 +141,7 @@ class MangaListActivity : } } - private fun initFilter(filterOwner: FilterOwner) { + private fun initFilter(filterOwner: FilterCoordinator.Owner) { if (viewBinding.containerSide != null) { if (supportFragmentManager.findFragmentById(R.id.container_side) == null) { setSideFragment(FilterSheetFragment::class.java, null) @@ -154,18 +154,18 @@ class MangaListActivity : } } } - val filter = filterOwner.filter + val filter = filterOwner.filterCoordinator val chipSort = viewBinding.buttonOrder if (chipSort != null) { val filterBadge = ViewBadge(chipSort, this) filterBadge.setMaxCharacterCount(0) - filter.header.observe(this) { - chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0) - filterBadge.counter = if (it.isFilterApplied) 1 else 0 + filter.observe().observe(this) { snapshot -> + chipSort.setTextAndVisible(snapshot.sortOrder.titleRes) + filterBadge.counter = if (snapshot.listFilter.isEmpty()) 0 else 1 } } else { - filter.header.map { - it.textSummary + filter.observe().map { + it.listFilter.tags.joinToString { tag -> tag.title } }.flowOn(Dispatchers.Default) .observe(this) { supportActionBar?.subtitle = it @@ -173,8 +173,8 @@ class MangaListActivity : } } - private fun findFilterOwner(): FilterOwner? { - return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner + private fun findFilterOwner(): FilterCoordinator.Owner? { + return supportFragmentManager.findFragmentById(R.id.container) as? FilterCoordinator.Owner } private fun setSideFragment(cls: Class, args: Bundle?) = if (viewBinding.containerSide != null) { @@ -188,12 +188,12 @@ class MangaListActivity : } private class ApplyFilterRunnable( - private val filterOwner: FilterOwner, + private val filterOwner: FilterCoordinator.Owner, private val tags: Set, ) : Runnable { override fun run() { - filterOwner.filter.applyFilter(tags) + filterOwner.filterCoordinator.set(MangaListFilter(tags = tags)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 0bd53ef68..e1ceb0db2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -42,7 +42,7 @@ class SearchViewModel @Inject constructor( ) : MangaListViewModel(settings, downloadScheduler) { private val query = savedStateHandle.require(SearchFragment.ARG_QUERY) - private val repository = repositoryFactory.create(MangaSource(savedStateHandle.get(SearchFragment.ARG_SOURCE))) + private val repository = repositoryFactory.create(MangaSource(savedStateHandle[SearchFragment.ARG_SOURCE])) private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) @@ -105,7 +105,8 @@ class SearchViewModel @Inject constructor( listError.value = null val list = repository.getList( offset = if (append) mangaList.value.sizeOrZero() else 0, - filter = MangaListFilter.Search(query), + order = null, + filter = MangaListFilter(query = query), ) val prevList = mangaList.value.orEmpty() if (!append) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index 686271449..155c9eefd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -116,14 +116,14 @@ class MultiSearchViewModel @Inject constructor( val semaphore = Semaphore(MAX_PARALLELISM) sources.mapNotNull { source -> val repository = mangaRepositoryFactory.create(source) - if (!repository.isSearchSupported) { + if (!repository.filterCapabilities.isSearchSupported) { null } else { launch { val item = runCatchingCancellable { semaphore.withPermit { mangaListMapper.toListModelList( - manga = repository.getList(offset = 0, filter = MangaListFilter.Search(q)), + manga = repository.getList(offset = 0, null, MangaListFilter(query = q)), mode = ListMode.GRID, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 51f5f84b1..172db74e7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -250,15 +250,14 @@ class SuggestionsWorker @AssistedInject constructor( val repository = mangaRepositoryFactory.create(source) val availableOrders = repository.sortOrders val order = preferredSortOrders.first { it in availableOrders } - val availableTags = repository.getTags() + val availableTags = repository.getFilterOptions().availableTags val tag = tags.firstNotNullOfOrNull { title -> availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) } } val list = repository.getList( offset = 0, - filter = MangaListFilter.Advanced.Builder(order) - .tags(setOfNotNull(tag)) - .build(), + order = order, + filter = MangaListFilter(tags = setOfNotNull(tag)) ).asArrayList() if (appSettings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw } diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml index 27f1494ac..1c9860652 100644 --- a/app/src/main/res/layout/sheet_filter.xml +++ b/app/src/main/res/layout/sheet_filter.xml @@ -49,7 +49,7 @@ @@ -62,9 +62,11 @@ android:layout_marginTop="12dp" android:baselineAligned="false" android:orientation="horizontal" + android:visibility="gone" android:weightSum="2" app:selectionRequired="true" - app:singleSelection="true"> + app:singleSelection="true" + tools:visibility="visible"> + + + + + + + + + @@ -211,6 +244,28 @@ app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" tools:visibility="visible" /> + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 7c8ba9833..511e2291c 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -35,6 +35,7 @@ 8dp 24dp 92dp + 56dp 142dp 6dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 101eef40e..962b8d8f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -707,4 +707,16 @@ No fix required for \"%s\" No alternatives found for \"%s\" This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background + Novel + Manhua + Manhwa + Recently added + Added long ago + Popular this hour + Popular today + Popular this week + Popular this month + Popular this year + Original language + Year