Update parsers and filters

This commit is contained in:
Koitharu
2024-09-21 08:22:32 +03:00
parent d9d11d685e
commit 6f45a44070
48 changed files with 800 additions and 780 deletions

View File

@@ -83,7 +83,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:aba8a80d8f') { implementation('com.github.KotatsuApp:kotatsu-parsers:336c4a4d49') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@@ -96,10 +96,10 @@ dependencies {
implementation 'androidx.activity:activity-ktx:1.9.2' implementation 'androidx.activity:activity-ktx:1.9.2'
implementation 'androidx.fragment:fragment-ktx:1.8.3' implementation 'androidx.fragment:fragment-ktx:1.8.3'
implementation 'androidx.transition:transition-ktx:1.5.1' implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.3' implementation 'androidx.collection:collection-ktx:1.4.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.lifecycle:lifecycle-service:2.8.5' implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
implementation 'androidx.lifecycle:lifecycle-process:2.8.5' implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -107,7 +107,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0' 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.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.1' implementation 'androidx.work:work-runtime:2.9.1'

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -36,13 +37,13 @@ class AlternativesUseCase @Inject constructor(
return channelFlow { return channelFlow {
for (source in sources) { for (source in sources) {
val repository = mangaRepositoryFactory.create(source) val repository = mangaRepositoryFactory.create(source)
if (!repository.isSearchSupported) { if (!repository.filterCapabilities.isSearchSupported) {
continue continue
} }
launch { launch {
val list = runCatchingCancellable { val list = runCatchingCancellable {
semaphore.withPermit { semaphore.withPermit {
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title)) repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
} }
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
for (item in list) { for (item in list) {

View File

@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@Deprecated("")
enum class GenericSortOrder( enum class GenericSortOrder(
@StringRes val titleResId: Int, @StringRes val titleResId: Int,
val ascending: SortOrder, val ascending: SortOrder,

View File

@@ -56,6 +56,9 @@ val ContentType.titleResId
ContentType.HENTAI -> R.string.content_type_hentai ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other 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) { fun MangaSource.getSummary(context: Context): String? = when (this) {

View File

@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter 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.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet import java.util.EnumSet
@@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
override val availableSortOrders: Set<SortOrder> override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) 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 getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null) override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null) override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
private fun stub(manga: Manga?): Nothing { private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga) throw UnsupportedSourceException("Usage of Dummy parser", manga)
} }

View File

@@ -1,37 +1,29 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter 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.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource 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.SortOrder
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository { class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)
override val states: Set<MangaState>
get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST get() = SortOrder.NEWEST
set(value) = Unit 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<Manga> = stub(null) override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga) 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 getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getTags(): Set<MangaTag> = stub(null) override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed) override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)

View File

@@ -61,7 +61,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) { if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title)) val list = getList(0, null, MangaListFilter(query = title))
if (url != null) { if (url != null) {
list.find { it.url == url }?.let { list.find { it.url == url }?.let {
return it return it
@@ -80,7 +80,7 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty { }.ifNullOrEmpty {
seed.author seed.author
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle)) val seedList = getList(0, null, MangaListFilter(query = seedTitle))
seedList.first { x -> x.url == url } seedList.first { x -> x.url == url }
}.getOrThrow() }.getOrThrow()
} }

View File

@@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter 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.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource 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.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set import kotlin.collections.set
@@ -35,19 +33,11 @@ interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean val filterCapabilities: MangaListFilterCapabilities
val isTagsExclusionSupported: Boolean suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga>
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga
@@ -55,14 +45,12 @@ interface MangaRepository {
suspend fun getPageUrl(page: MangaPage): String suspend fun getPageUrl(page: MangaPage): String
suspend fun getTags(): Set<MangaTag> suspend fun getFilterOptions(): MangaListFilterOptions
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? { 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 } return list.find { x -> x.id == manga.id }
} }

View File

@@ -13,11 +13,14 @@ import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter 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.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder 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.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale import java.util.Locale
@@ -28,17 +31,20 @@ class ParserMangaRepository(
cache: MemoryContentCache, cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor { ) : CachingMangaRepository(cache), Interceptor {
private val filterOptionsLazy = SuspendLazy {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFilterOptions()
}
}
override val source: MangaParserSource override val source: MangaParserSource
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = parser.availableSortOrders get() = parser.availableSortOrders
override val states: Set<MangaState> override val filterCapabilities: MangaListFilterCapabilities
get() = parser.availableStates get() = parser.filterCapabilities
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first() get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -46,15 +52,6 @@ class ParserMangaRepository(
getConfig().defaultSortOrder = value 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 var domain: String
get() = parser.domain get() = parser.domain
set(value) { set(value) {
@@ -72,9 +69,9 @@ class ParserMangaRepository(
} }
} }
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> { override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching { return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, filter) parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
} }
} }
@@ -88,13 +85,7 @@ class ParserMangaRepository(
parser.getPageUrl(page) parser.getPageUrl(page)
} }
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
parser.getAvailableTags()
}
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons() parser.getFavicons()

View File

@@ -6,16 +6,14 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter 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.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.model.SortOrder
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository( class ExternalMangaRepository(
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
@@ -36,28 +34,39 @@ class ExternalMangaRepository(
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL) get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState> override val filterCapabilities: MangaListFilterCapabilities
get() = capabilities?.availableStates.orEmpty() get() = capabilities.let {
MangaListFilterCapabilities(
override val contentRatings: Set<ContentRating> isMultipleTagsSupported = it?.isMultipleTagsSupported == true,
get() = capabilities?.availableContentRating.orEmpty() isTagsExclusionSupported = it?.isTagsExclusionSupported == true,
isSearchSupported = it?.isSearchSupported == true,
isSearchWithFiltersSupported = false, // TODO
isYearSupported = false, // TODO
isYearRangeSupported = false, // TODO
isOriginalLocaleSupported = false, // TODO
)
}
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit set(value) = Unit
override val isMultipleTagsSupported: Boolean override suspend fun getFilterOptions(): MangaListFilterOptions = capabilities.let {
get() = capabilities?.isMultipleTagsSupported ?: true 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 override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> =
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.IO) { 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) { 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 getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
contentSource.getTags()
}
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
} }

View File

@@ -31,25 +31,18 @@ class ExternalPluginContentSource(
@Blocking @Blocking
@WorkerThread @WorkerThread
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> { fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val uri = "content://${source.authority}/manga".toUri().buildUpon() val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString()) uri.appendQueryParameter("offset", offset.toString())
when (filter) { filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
is MangaListFilter.Advanced -> { filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") } filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") } filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.states.forEach { uri.appendQueryParameter("state", it.name) } filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) } if (!filter.query.isNullOrEmpty()) {
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) } uri.appendQueryParameter("query", filter.query)
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
} }
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name) return contentResolver.query(uri.build(), null, null, null, order.name)
.safe() .safe()
.use { cursor -> .use { cursor ->
val result = ArrayList<Manga>(cursor.count) val result = ArrayList<Manga>(cursor.count)

View File

@@ -4,14 +4,22 @@ import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.SortDirection import org.koitharu.kotatsu.core.model.SortDirection
import org.koitharu.kotatsu.parsers.model.SortOrder 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
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC 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
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC 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
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC 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
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC 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
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
@@ -28,6 +36,14 @@ val SortOrder.titleRes: Int
POPULARITY_ASC -> R.string.unpopular POPULARITY_ASC -> R.string.unpopular
RATING_ASC -> R.string.low_rating RATING_ASC -> R.string.low_rating
NEWEST_ASC -> R.string.order_oldest 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 val SortOrder.direction: SortDirection
@@ -36,11 +52,19 @@ val SortOrder.direction: SortDirection
POPULARITY_ASC, POPULARITY_ASC,
RATING_ASC, RATING_ASC,
NEWEST_ASC, NEWEST_ASC,
ADDED_ASC,
ALPHABETICAL -> SortDirection.ASC ALPHABETICAL -> SortDirection.ASC
UPDATED, UPDATED,
POPULARITY, POPULARITY,
POPULARITY_HOUR,
POPULARITY_TODAY,
POPULARITY_WEEK,
POPULARITY_MONTH,
POPULARITY_YEAR,
RATING, RATING,
NEWEST, NEWEST,
ADDED,
RELEVANCE,
ALPHABETICAL_DESC -> SortDirection.DESC ALPHABETICAL_DESC -> SortDirection.DESC
} }

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.transformWhile
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -132,3 +133,5 @@ suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x !
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null } suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it } fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) }

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.core.util.ext 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 inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
fun longOf(a: Int, b: Int): Long { fun longOf(a: Int, b: Int): Long {

View File

@@ -70,15 +70,14 @@ class ExploreRepository @Inject constructor(
): List<Manga> = runCatchingCancellable { ): List<Manga> = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(source) val repository = mangaRepositoryFactory.create(source)
val order = repository.sortOrders.random() val order = repository.sortOrders.random()
val availableTags = repository.getTags() val availableTags = repository.getFilterOptions().availableTags
val tag = tags.firstNotNullOfOrNull { title -> val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, 0.4f) } availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
} }
val list = repository.getList( val list = repository.getList(
offset = 0, offset = 0,
filter = MangaListFilter.Advanced.Builder(order) order = order,
.tags(setOfNotNull(tag)) filter = MangaListFilter(tags = setOfNotNull(tag))
.build(),
).asArrayList() ).asArrayList()
if (settings.isSuggestionsExcludeNsfw) { if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }

View File

@@ -19,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor(
return@runCatchingCancellable null return@runCatchingCancellable null
} }
val repository = repositoryFactory.create(manga.source) 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 { val newManga = list.find { x -> x.title == manga.title }?.let {
repository.getDetails(it) repository.getDetails(it)
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null

View File

@@ -1,272 +1,300 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import android.view.View
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus 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.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.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.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.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal 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.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
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.parsers.model.ContentRating 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.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource 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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder 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.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import java.text.Collator import java.util.Calendar
import java.util.EnumSet
import java.util.LinkedList
import java.util.Locale import java.util.Locale
import java.util.TreeSet
import javax.inject.Inject import javax.inject.Inject
@ViewModelScoped @ViewModelScoped
class FilterCoordinator @Inject constructor( class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
dataRepository: MangaDataRepository,
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle, 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 repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val currentState = MutableStateFlow( private val sourceLocale = (repository.source as? MangaParserSource)?.locale
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
override val allTags = MutableStateFlow<List<ListModel>>(listOf(LoadingState)) private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
get() { private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) {
loadAllTags()
}
return field
}
override val filterTags: StateFlow<FilterProperty<MangaTag>> = combine( private val availableSortOrders = repository.sortOrders
currentState.distinctUntilChangedBy { it.tags }, private val capabilities = repository.filterCapabilities
getTopTagsAsFlow(currentState.map { it.tags }, 16), private val filterOptions = SuspendLazy { repository.getFilterOptions() }
) { state, tags ->
val mangaSource: MangaSource
get() = repository.source
val isFilterApplied: Boolean
get() = !currentListFilter.value.isEmpty()
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty( FilterProperty(
availableItems = tags.items.asArrayList(), availableItems = availableSortOrders.sortedByOrdinal(),
selectedItems = state.tags, selectedItem = selected,
isLoading = tags.isLoading,
error = tags.error,
) )
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
override val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (repository.isTagsExclusionSupported) { val tags: StateFlow<FilterProperty<MangaTag>> = 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<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
combine( combine(
currentState.distinctUntilChangedBy { it.tagsExclude }, getBottomTags(TAGS_LIMIT),
getBottomTagsAsFlow(4), currentListFilter.distinctUntilChangedBy { it.tagsExclude },
) { state, tags -> ) { available, selected ->
FilterProperty( available.fold(
availableItems = tags.items.asArrayList(), onSuccess = {
selectedItems = state.tagsExclude, FilterProperty(
isLoading = tags.isLoading, availableItems = it.addFirstDistinct(selected.tagsExclude),
error = tags.error, selectedItems = selected.tagsExclude,
)
},
onFailure = {
FilterProperty.error(it)
},
) )
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else { } else {
MutableStateFlow(emptyProperty()) MutableStateFlow(FilterProperty.EMPTY)
} }
override val filterSortOrder: StateFlow<FilterProperty<GenericSortOrder>> = val states: StateFlow<FilterProperty<MangaState>> = combine(
currentState.distinctUntilChangedBy { it.sortOrder }.map { state -> filterOptions.asFlow(),
val orders = repository.sortOrders currentListFilter.distinctUntilChangedBy { it.states },
FilterProperty( ) { available, selected ->
availableItems = orders.mapTo(EnumSet.noneOf(GenericSortOrder::class.java)) { available.fold(
GenericSortOrder.of(it) onSuccess = {
}.sortedByOrdinal(), FilterProperty(
selectedItems = setOf(GenericSortOrder.of(state.sortOrder)), availableItems = it.availableStates.sortedByOrdinal(),
isLoading = false, selectedItems = selected.states,
error = null, )
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentRating: StateFlow<FilterProperty<ContentRating>> = 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<FilterProperty<ContentType>> = 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<FilterProperty<Demographic>> = 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<FilterProperty<Locale?>> = 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<FilterProperty<Locale?>> = 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<FilterProperty<SortDirection>> = val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
currentState.distinctUntilChangedBy { it.sortOrder }.map { state -> currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
val orders = repository.sortOrders
FilterProperty( FilterProperty(
availableItems = state.sortOrder.let { availableItems = listOf(YEAR_MIN, MAX_YEAR),
val genericOrder = GenericSortOrder.of(it) selectedItems = setOf(selected.year),
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,
) )
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
override val filterState: StateFlow<FilterProperty<MangaState>> = combine( val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
currentState.distinctUntilChangedBy { it.states }, currentListFilter.distinctUntilChanged { old, new ->
flowOf(repository.states), old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
) { state, states -> }.map { selected ->
FilterProperty( FilterProperty(
availableItems = states.sortedByOrdinal(), availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = state.states, selectedItems = setOf(selected.yearFrom, selected.yearTo),
isLoading = false, )
error = null, }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
) } else {
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) MutableStateFlow(FilterProperty.EMPTY)
}
override val filterContentRating: StateFlow<FilterProperty<ContentRating>> = combine( fun reset() {
currentState.distinctUntilChangedBy { it.contentRating }, currentListFilter.value = MangaListFilter.EMPTY
flowOf(repository.contentRatings), }
) { rating, ratings ->
FilterProperty(
availableItems = ratings.sortedByOrdinal(),
selectedItems = rating.contentRating,
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine( fun snapshot() = Snapshot(
currentState.distinctUntilChangedBy { it.locale }, sortOrder = currentSortOrder.value,
getLocalesAsFlow(), listFilter = currentListFilter.value,
) { state, locales ->
val list = if (locales.items.isNotEmpty()) {
val l = ArrayList<Locale?>(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<FilterHeaderModel> = getHeaderFlow().stateIn(
scope = coroutineScope + Dispatchers.Default,
started = SharingStarted.Lazily,
initialValue = FilterHeaderModel(
chips = emptyList(),
sortOrder = repository.defaultSortOrder,
isFilterApplied = false,
),
) )
override fun applyFilter(tags: Set<MangaTag>) { fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
setTags(tags)
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
} }
override fun setSortOrder(value: SortOrder) { fun set(value: MangaListFilter) {
val available = repository.sortOrders currentListFilter.value = value
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
} }
override fun setLanguage(value: Locale?) { fun setLocale(value: Locale?) {
currentState.update { oldValue -> currentListFilter.update { oldValue ->
oldValue.copy(locale = value) oldValue.copy(locale = value)
} }
} }
override fun setTag(value: MangaTag, addOrRemove: Boolean) { fun setYear(value: Int) {
currentState.update { oldValue -> currentListFilter.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) { oldValue.copy(year = value)
if (addOrRemove) { }
oldValue.tags + value }
} else {
oldValue.tags - 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 { } else {
if (addOrRemove) { if (isSelected) setOf(value) else emptySet()
setOf(value)
} else {
emptySet()
}
} }
oldValue.copy( oldValue.copy(
tags = newTags, tags = newTags,
@@ -275,266 +303,91 @@ class FilterCoordinator @Inject constructor(
} }
} }
override fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) { fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
currentState.update { oldValue -> currentListFilter.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) { val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
if (addOrRemove) { if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
oldValue.tagsExclude + value
} else {
oldValue.tagsExclude - value
}
} else { } else {
if (addOrRemove) { if (isSelected) setOf(value) else emptySet()
setOf(value)
} else {
emptySet()
}
} }
oldValue.copy( oldValue.copy(
tagsExclude = newTags, tags = oldValue.tags - newTagsExclude,
tags = oldValue.tags - newTags, tagsExclude = newTagsExclude,
) )
} }
} }
override fun setState(value: MangaState, addOrRemove: Boolean) { fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
currentState.update { oldValue -> it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
val newStates = if (addOrRemove) { }
oldValue.states + value
} else { private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
oldValue.states - value flow { emit(searchRepository.getTopTags(repository.source, limit)) },
} filterOptions.asFlow(),
oldValue.copy(states = newStates) ) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
} }
} if (result.isNotEmpty()) {
Result.success(result)
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<Set<MangaTag>?> = flow {
if (!availableTagsDeferred.isCompleted) {
emit(emptySet())
}
emit(availableTagsDeferred.await().getOrNull())
}
fun observeState() = currentState.asStateFlow()
fun setTags(tags: Set<MangaTag>) {
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<PendingData<Locale>> = 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<Set<MangaTag>>, limit: Int): Flow<PendingData<MangaTag>> = 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<PendingData<MangaTag>> = 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<MangaTag>,
limit: Int,
): List<ChipsView.ChipModel> {
val selectedTags = filterState.tags.toMutableSet()
var tags = if (selectedTags.isEmpty()) {
searchRepository.getTagsSuggestion("", limit, repository.source)
} else { } 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<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
filterOptions.asFlow(),
) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
} }
if (tags.isEmpty() && selectedTags.isEmpty()) { if (result.isNotEmpty()) {
return emptyList() Result.success(result)
} else {
options.map { result }
} }
val result = LinkedList<ChipsView.ChipModel>() }
for (tag in tags) {
val model = ChipsView.ChipModel( private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
title = tag.title, val result = ArrayDeque<T>(this.size + other.size)
isChecked = selectedTags.remove(tag), result.addAll(this)
data = tag, for (item in other) {
) if (item !in result) {
if (model.isChecked) { result.addFirst(item)
result.addFirst(model)
} else {
result.addLast(model)
} }
} }
for (tag in selectedTags) { return result
val model = ChipsView.ChipModel( }
title = tag.title,
isChecked = true, private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
data = tag, val result = ArrayDeque<T>(this.size + 1)
) result.addAll(this)
result.addFirst(model) if (item !in result) {
result.addFirst(item)
} }
return result return result
} }
private suspend fun tryLoadTags(): Result<Set<MangaTag>> { data class Snapshot(
val shouldRetryOnError = availableTagsDeferred.isCompleted val sortOrder: SortOrder,
val result = availableTagsDeferred.await() val listFilter: MangaListFilter,
if (result.isFailure && shouldRetryOnError) {
availableTagsDeferred = loadTagsAsync()
return availableTagsDeferred.await()
}
return result
}
private suspend fun tryLoadLocales(): Result<Set<Locale>> {
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<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
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<TagCatalogItem>() + e.toErrorFooter()
}
}
}
private fun appendTagsList(newTags: Collection<MangaTag>, isLoading: Boolean) = allTags.update { oldList ->
val oldTags = oldList.filterIsInstance<TagCatalogItem>()
buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) {
addAll(oldTags)
newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) }
val tempSet = HashSet<MangaTag>(size)
removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) }
sortBy { (it as TagCatalogItem).tag.title }
if (isLoading) {
add(LoadingFooter())
}
}
}
private data class PendingData<T>(
val items: Collection<T>,
val isLoading: Boolean,
val error: Throwable?,
) )
private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null) interface Owner {
private fun <T> emptyProperty() = FilterProperty<T>(emptyList(), emptySet(), false, null) val filterCoordinator: FilterCoordinator
}
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> { private companion object {
private val collator = lc?.let { Collator.getInstance(Locale(it)) } const val TAGS_LIMIT = 12
val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
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)
}
} }
} }

View File

@@ -6,6 +6,9 @@ import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.chip.Chip 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.R
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.widgets.ChipsView 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.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener { class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
private val filter: MangaFilter @Inject
get() = (requireActivity() as FilterOwner).filter lateinit var filterHeaderProducer: FilterHeaderProducer
private val filter: FilterCoordinator
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false) return FragmentFilterHeaderBinding.inflate(inflater, container, false)
@@ -29,7 +37,9 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
filter.header.observe(viewLifecycleOwner, ::onDataChanged) filterHeaderProducer.observeHeader(filter)
.flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, ::onDataChanged)
} }
override fun onWindowInsetsChanged(insets: Insets) = Unit override fun onWindowInsetsChanged(insets: Insets) = Unit
@@ -39,7 +49,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
if (tag == null) { if (tag == null) {
TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false)
} else { } else {
filter.setTag(tag, !chip.isChecked) filter.toggleTag(tag, !chip.isChecked)
} }
} }

View File

@@ -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<FilterHeaderModel> {
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<MangaTag>,
limit: Int,
): List<ChipsView.ChipModel> {
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<ChipsView.ChipModel>()
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
}
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.filter.ui
interface FilterOwner {
val filter: MangaFilter
}

View File

@@ -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<List<ListModel>>
val filterTags: StateFlow<FilterProperty<MangaTag>>
val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>>
val filterSortOrder: StateFlow<FilterProperty<GenericSortOrder>>
val filterSortDirection: StateFlow<FilterProperty<SortDirection>>
val filterState: StateFlow<FilterProperty<MangaState>>
val filterContentRating: StateFlow<FilterProperty<ContentRating>>
val filterLocale: StateFlow<FilterProperty<Locale?>>
val header: StateFlow<FilterHeaderModel>
fun applyFilter(tags: Set<MangaTag>)
}

View File

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

View File

@@ -1,11 +1,53 @@
package org.koitharu.kotatsu.filter.ui.model package org.koitharu.kotatsu.filter.ui.model
data class FilterProperty<T>( data class FilterProperty<out T>(
val availableItems: List<T>, val availableItems: List<T>,
val selectedItems: Set<T>, val selectedItems: Set<T>,
val isLoading: Boolean, val isLoading: Boolean,
val error: Throwable?, val error: Throwable?,
) { ) {
constructor(
availableItems: List<T>,
selectedItems: Set<T>,
) : this(
availableItems = availableItems,
selectedItems = selectedItems,
isLoading = false,
error = null,
)
constructor(
availableItems: List<T>,
selectedItem: T,
) : this(
availableItems = availableItems,
selectedItems = setOf(selectedItem),
isLoading = false,
error = null,
)
fun isEmpty(): Boolean = availableItems.isEmpty() fun isEmpty(): Boolean = availableItems.isEmpty()
companion object {
val LOADING = FilterProperty<Nothing>(
availableItems = emptyList(),
selectedItems = emptySet(),
isLoading = true,
error = null,
)
val EMPTY = FilterProperty<Nothing>(
availableItems = emptyList(),
selectedItems = emptySet(),
)
fun error(error: Throwable) = FilterProperty<Nothing>(
availableItems = emptyList(),
selectedItems = emptySet(),
isLoading = false,
error = error,
)
}
} }

View File

@@ -13,31 +13,35 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.slider.Slider
import org.koitharu.kotatsu.R 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.SortDirection
import org.koitharu.kotatsu.core.model.titleResId 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.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView 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.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.SheetFilterBinding 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.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag 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 java.util.Locale
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(), class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener, AdapterView.OnItemSelectedListener,
ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener { ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, Slider.OnChangeListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false) return SheetFilterBinding.inflate(inflater, container, false)
@@ -52,13 +56,14 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
} }
} }
val filter = requireFilter() val filter = requireFilter()
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged) // filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged)
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged) filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged) filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged) filter.states.observe(viewLifecycleOwner, this::onStateChanged)
filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
binding.spinnerLocale.onItemSelectedListener = this binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this
@@ -66,12 +71,13 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.chipsContentRating.onChipClickListener = this binding.chipsContentRating.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this
binding.sliderYear.addOnChangeListener(this)
binding.layoutSortDirection.addOnButtonCheckedListener(this) binding.layoutSortDirection.addOnButtonCheckedListener(this)
} }
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
if (isChecked) { if (isChecked) {
setSortDirection(getSortDirection(checkedId) ?: return) // setSortDirection(getSortDirection(checkedId) ?: return)
} }
} }
@@ -79,33 +85,43 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
val filter = requireFilter() val filter = requireFilter()
when (parent.id) { when (parent.id) {
R.id.spinner_order -> { R.id.spinner_order -> {
val genericOrder = filter.filterSortOrder.value.availableItems[position] val value = filter.sortOrder.value.availableItems[position]
val direction = getSortDirection(requireViewBinding().layoutSortDirection.checkedButtonId) filter.setSortOrder(value)
filter.setSortOrder(genericOrder[direction ?: SortDirection.DESC])
} }
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 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?) { override fun onChipClick(chip: Chip, data: Any?) {
val filter = requireFilter() val filter = requireFilter()
when (data) { 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) { is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
filter.setTagExcluded(data, !chip.isChecked) filter.toggleTagExclude(data, !chip.isChecked)
} else { } 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) null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude)
} }
} }
private fun onSortOrderChanged(value: FilterProperty<GenericSortOrder>) { private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.textViewOrderTitle.isGone = value.isEmpty() b.textViewOrderTitle.isGone = value.isEmpty()
b.cardOrder.isGone = value.isEmpty() b.cardOrder.isGone = value.isEmpty()
@@ -117,7 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
b.spinnerOrder.context, b.spinnerOrder.context,
android.R.layout.simple_spinner_dropdown_item, android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1, 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) val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) { if (selectedIndex >= 0) {
@@ -271,15 +287,20 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
b.chipsContentRating.setChips(chips) b.chipsContentRating.setChips(chips)
} }
private fun requireFilter() = (requireActivity() as FilterOwner).filter private fun onYearChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return
private fun setSortDirection(direction: SortDirection) { b.textViewYear.isGone = value.isEmpty()
val filter = requireFilter() b.sliderYear.isGone = value.isEmpty()
val currentOrder = filter.filterSortOrder.value.selectedItems.singleOrNull() ?: return if (value.isEmpty()) {
val newOrder = currentOrder[direction] return
filter.setSortOrder(newOrder) }
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) { private fun getSortDirection(@IdRes buttonId: Int): SortDirection? = when (buttonId) {
R.id.button_order_asc -> SortDirection.ASC R.id.button_order_asc -> SortDirection.ASC
R.id.button_order_desc -> SortDirection.DESC R.id.button_order_desc -> SortDirection.DESC

View File

@@ -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<MangaTag> {
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)
}
}

View File

@@ -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.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetTagsBinding 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 import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@AndroidEntryPoint @AndroidEntryPoint
@@ -32,7 +32,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
extrasProducer = { extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory -> defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
factory.create( factory.create(
filter = (requireActivity() as FilterOwner).filter, filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator,
isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE), isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE),
) )
} }

View File

@@ -14,40 +14,43 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.ui.BaseViewModel 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.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem 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.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
class TagsCatalogViewModel @AssistedInject constructor( class TagsCatalogViewModel @AssistedInject constructor(
@Assisted private val filter: MangaFilter, @Assisted private val filter: FilterCoordinator,
@Assisted private val isExcluded: Boolean, @Assisted private val isExcluded: Boolean,
) : BaseViewModel() { ) : BaseViewModel() {
val searchQuery = MutableStateFlow("") val searchQuery = MutableStateFlow("")
private val filterProperty: StateFlow<FilterProperty<MangaTag>> private val filterProperty: StateFlow<FilterProperty<MangaTag>>
get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags get() = if (isExcluded) filter.tagsExcluded else filter.tags
private val tags = combine( private val tags: StateFlow<List<ListModel>> = combine(
filter.allTags, filter.getAllTags(),
filterProperty.map { it.selectedItems }, filterProperty.map { it.selectedItems },
) { all, selected -> ) { all, selected ->
all.map { x -> all.fold(
if (x is TagCatalogItem) { onSuccess = {
val checked = x.tag in selected it.map { tag ->
if (x.isChecked == checked) { TagCatalogItem(
x tag = tag,
} else { isChecked = tag in selected,
x.copy(isChecked = checked) )
} }
} else { },
x onFailure = {
} listOf(it.toErrorState(false))
} },
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value) )
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
val content = combine(tags, searchQuery) { raw, query -> val content = combine(tags, searchQuery) { raw, query ->
raw.filter { x -> raw.filter { x ->
@@ -57,15 +60,15 @@ class TagsCatalogViewModel @AssistedInject constructor(
fun handleTagClick(tag: MangaTag, isChecked: Boolean) { fun handleTagClick(tag: MangaTag, isChecked: Boolean) {
if (isExcluded) { if (isExcluded) {
filter.setTagExcluded(tag, !isChecked) filter.toggleTagExclude(tag, !isChecked)
} else { } else {
filter.setTag(tag, !isChecked) filter.toggleTag(tag, !isChecked)
} }
} }
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel fun create(filter: FilterCoordinator, isExcludeTag: Boolean): TagsCatalogViewModel
} }
} }

View File

@@ -4,7 +4,7 @@ import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon 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( fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState(
exception = this, exception = this,

View File

@@ -28,7 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity 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.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -105,11 +105,11 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return val tag = data as? MangaTag ?: return
val filter = (activity as? FilterOwner)?.filter val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator
if (filter == null) { if (filter == null) {
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
} else { } else {
filter.setTag(tag, true) filter.toggleTag(tag, true)
closeSelf() closeSelf()
} }
} }

View File

@@ -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.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.MangaLock
import org.koitharu.kotatsu.local.domain.model.LocalManga 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter 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.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.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -53,12 +51,15 @@ class LocalMangaRepository @Inject constructor(
override val source = LocalMangaSource override val source = LocalMangaSource
private val localMappingCache = LocalMangaMappingCache() private val localMappingCache = LocalMangaMappingCache()
override val isMultipleTagsSupported: Boolean = true override val filterCapabilities: MangaListFilterCapabilities
override val isTagsExclusionSupported: Boolean = true get() = MangaListFilterCapabilities(
override val isSearchSupported: Boolean = true isMultipleTagsSupported = true,
isTagsExclusionSupported = true,
isSearchSupported = true,
isSearchWithFiltersSupported = true,
)
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
override val states = emptySet<MangaState>()
override val contentRatings = emptySet<ContentRating>()
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = settings.localListOrder get() = settings.localListOrder
@@ -66,7 +67,9 @@ class LocalMangaRepository @Inject constructor(
settings.localListOrder = value settings.localListOrder = value
} }
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> { override suspend fun getFilterOptions() = MangaListFilterOptions()
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
} }
@@ -74,30 +77,25 @@ class LocalMangaRepository @Inject constructor(
if (settings.isNsfwContentDisabled) { if (settings.isNsfwContentDisabled) {
list.removeIf { it.manga.isNsfw } list.removeIf { it.manga.isNsfw }
} }
when (filter) { if (filter != null) {
is MangaListFilter.Search -> { val query = filter.query
list.retainAll { x -> x.isMatchesQuery(filter.query) } if (!query.isNullOrEmpty()) {
list.retainAll { x -> x.isMatchesQuery(query) }
} }
if (filter.tags.isNotEmpty()) {
is MangaListFilter.Advanced -> { list.retainAll { x -> x.containsTags(filter.tags) }
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.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() return list.unwrap()
} }
@@ -173,10 +171,6 @@ class LocalMangaRepository @Inject constructor(
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url
override suspend fun getTags() = emptySet<MangaTag>()
override suspend fun getLocales() = emptySet<Locale>()
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList() override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
suspend fun getOutputDir(manga: Manga): File? { suspend fun getOutputDir(manga: Manga): File? {

View File

@@ -27,7 +27,7 @@ class DeleteLocalMangaUseCase @Inject constructor(
} }
suspend operator fun invoke(ids: Set<Long>) { suspend operator fun invoke(ids: Set<Long>) {
val list = localMangaRepository.getList(0, null) val list = localMangaRepository.getList(0, null, null)
var removed = 0 var removed = 0
for (manga in list) { for (manga in list) {
if (manga.id in ids) { if (manga.id in ids) {

View File

@@ -38,7 +38,7 @@ class DeleteReadChaptersUseCase @Inject constructor(
} }
suspend operator fun invoke(): Int { suspend operator fun invoke(): Int {
val list = localMangaRepository.getList(0, null) val list = localMangaRepository.getList(0, null, null)
if (list.isEmpty()) { if (list.isEmpty()) {
return 0 return 0
} }

View File

@@ -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.tryLaunch
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
class LocalListFragment : MangaListFragment(), FilterOwner { class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
private val permissionRequestLauncher = registerForActivityResult( private val permissionRequestLauncher = registerForActivityResult(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -56,8 +55,8 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
override val viewModel by viewModels<LocalListViewModel>() override val viewModel by viewModels<LocalListViewModel>()
override val filter: MangaFilter override val filterCoordinator: FilterCoordinator
get() = viewModel get() = viewModel.filterCoordinator
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator 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.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -32,7 +33,7 @@ import javax.inject.Inject
class LocalListViewModel @Inject constructor( class LocalListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
filter: FilterCoordinator, filterCoordinator: FilterCoordinator,
private val settings: AppSettings, private val settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
mangaListMapper: MangaListMapper, mangaListMapper: MangaListMapper,
@@ -40,11 +41,12 @@ class LocalListViewModel @Inject constructor(
exploreRepository: ExploreRepository, exploreRepository: ExploreRepository,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val localStorageManager: LocalStorageManager, private val localStorageManager: LocalStorageManager,
filterHeaderProducer: FilterHeaderProducer,
sourcesRepository: MangaSourcesRepository, sourcesRepository: MangaSourcesRepository,
) : RemoteListViewModel( ) : RemoteListViewModel(
savedStateHandle, savedStateHandle,
mangaRepositoryFactory, mangaRepositoryFactory,
filter, filterCoordinator,
settings, settings,
mangaListMapper, mangaListMapper,
downloadScheduler, downloadScheduler,
@@ -58,7 +60,7 @@ class LocalListViewModel @Inject constructor(
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
localStorageChanges localStorageChanges
.collect { .collect {
loadList(filter.snapshot(), append = false).join() loadList(filterCoordinator.snapshot(), append = false).join()
} }
} }
settings.subscribe(this) settings.subscribe(this)

View File

@@ -62,7 +62,6 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage 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.ChaptersLoader
import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
@@ -452,9 +451,9 @@ class ReaderViewModel @Inject constructor(
@WorkerThread @WorkerThread
private fun notifyStateChanged() { private fun notifyStateChanged() {
val state = getCurrentState().assertNotNull("state") ?: return val state = getCurrentState() ?: return
val chapter = chaptersLoader.peekChapter(state.chapterId).assertNotNull("chapter") ?: return val chapter = chaptersLoader.peekChapter(state.chapterId) ?: return
val m = mangaDetails.value.assertNotNull("manga") ?: return val m = mangaDetails.value ?: return
val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1
val newState = ReaderUiState( val newState = ReaderUiState(
mangaName = m.toManga().title, mangaName = m.toManga().title,

View File

@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage 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.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.databinding.ItemPageBinding 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.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder

View File

@@ -11,8 +11,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage 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.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder

View File

@@ -25,8 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity 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.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner 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 import org.koitharu.kotatsu.settings.SettingsActivity
@AndroidEntryPoint @AndroidEntryPoint
class RemoteListFragment : MangaListFragment(), FilterOwner { class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
override val viewModel by viewModels<RemoteListViewModel>() override val viewModel by viewModels<RemoteListViewModel>()
override val filter: MangaFilter override val filterCoordinator: FilterCoordinator
get() = viewModel get() = viewModel.filterCoordinator
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
@@ -49,7 +48,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { viewModel.onOpenManga.observeEvent(viewLifecycleOwner) {
startActivity(DetailsActivity.newIntent(binding.root.context, it)) startActivity(DetailsActivity.newIntent(binding.root.context, it))
} }
viewModel.header.distinctUntilChangedBy { it.isFilterApplied } filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
.drop(1) .drop(1)
.observe(viewLifecycleOwner) { .observe(viewLifecycleOwner) {
activity?.invalidateMenu() activity?.invalidateMenu()
@@ -130,7 +129,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
super.onPrepareMenu(menu) super.onPrepareMenu(menu)
menu.findItem(R.id.action_search)?.isVisible = viewModel.isSearchAvailable menu.findItem(R.id.action_search)?.isVisible = viewModel.isSearchAvailable
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value 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 { override fun onQueryTextSubmit(query: String?): Boolean {

View File

@@ -31,7 +31,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator 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.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -52,13 +51,13 @@ private const val FILTER_MIN_INTERVAL = 250L
open class RemoteListViewModel @Inject constructor( open class RemoteListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
private val filter: FilterCoordinator, override val filterCoordinator: FilterCoordinator,
settings: AppSettings, settings: AppSettings,
mangaListMapper: MangaListMapper, mangaListMapper: MangaListMapper,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
private val exploreRepository: ExploreRepository, private val exploreRepository: ExploreRepository,
sourcesRepository: MangaSourcesRepository, sourcesRepository: MangaSourcesRepository,
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter { ) : MangaListViewModel(settings, downloadScheduler), FilterCoordinator.Owner {
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]) val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
val isRandomLoading = MutableStateFlow(false) val isRandomLoading = MutableStateFlow(false)
@@ -72,7 +71,7 @@ open class RemoteListViewModel @Inject constructor(
private var randomJob: Job? = null private var randomJob: Job? = null
val isSearchAvailable: Boolean val isSearchAvailable: Boolean
get() = repository.isSearchSupported get() = repository.filterCapabilities.isSearchSupported
val browserUrl: String? val browserUrl: String?
get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" } get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" }
@@ -93,7 +92,7 @@ open class RemoteListViewModel @Inject constructor(
) )
list == null -> add(LoadingState) list == null -> add(LoadingState)
list.isEmpty() -> add(createEmptyState(canResetFilter = header.value.isFilterApplied)) list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied))
else -> { else -> {
mangaListMapper.toListModelList(this, list, mode) mangaListMapper.toListModelList(this, list, mode)
when { when {
@@ -107,7 +106,7 @@ open class RemoteListViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
init { init {
filter.observeState() filterCoordinator.observe()
.debounce(FILTER_MIN_INTERVAL) .debounce(FILTER_MIN_INTERVAL)
.onEach { filterState -> .onEach { filterState ->
loadingJob?.cancelAndJoin() loadingJob?.cancelAndJoin()
@@ -123,26 +122,26 @@ open class RemoteListViewModel @Inject constructor(
} }
override fun onRefresh() { override fun onRefresh() {
loadList(filter.snapshot(), append = false) loadList(filterCoordinator.snapshot(), append = false)
} }
override fun onRetry() { override fun onRetry() {
loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty()) loadList(filterCoordinator.snapshot(), append = !mangaList.value.isNullOrEmpty())
} }
fun loadNextPage() { fun loadNextPage() {
if (hasNextPage.value && listError.value == null) { 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<MangaTag>) { override fun onUpdateFilter(tags: Set<MangaTag>) {
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 { loadingJob?.let {
if (it.isActive) return it if (it.isActive) return it
} }
@@ -151,7 +150,8 @@ open class RemoteListViewModel @Inject constructor(
listError.value = null listError.value = null
val list = repository.getList( val list = repository.getList(
offset = if (append) mangaList.value.sizeOrZero() else 0, offset = if (append) mangaList.value.sizeOrZero() else 0,
filter = filterState, order = filterState.sortOrder,
filter = filterState.listFilter,
) )
val prevList = mangaList.value.orEmpty() val prevList = mangaList.value.orEmpty()
if (!append) { if (!append) {

View File

@@ -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.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState 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.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga

View File

@@ -125,6 +125,10 @@ class MangaSearchRepository @Inject constructor(
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList() return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
} }
suspend fun getTopTags(source: MangaSource, limit: Int): List<MangaTag> {
return db.getTagsDao().findPopularTags(source.name, limit).toMangaTagsList()
}
suspend fun getSourcesSuggestion(limit: Int): List<MangaSource> = sourcesRepository.getTopSources(limit) suspend fun getSourcesSuggestion(limit: Int): List<MangaSource> = sourcesRepository.getTopSources(limit)
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> { fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {

View File

@@ -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.observe
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.ActivityMangaListBinding 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.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.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.preview.PreviewFragment import org.koitharu.kotatsu.list.ui.preview.PreviewFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga 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.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
@@ -53,15 +53,15 @@ import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class MangaListActivity : class MangaListActivity :
BaseActivity<ActivityMangaListBinding>(), BaseActivity<ActivityMangaListBinding>(),
AppBarOwner, View.OnClickListener, FilterOwner, AppBarLayout.OnOffsetChangedListener { AppBarOwner, View.OnClickListener, FilterCoordinator.Owner, AppBarLayout.OnOffsetChangedListener {
override val appBar: AppBarLayout override val appBar: AppBarLayout
get() = viewBinding.appbar get() = viewBinding.appbar
override val filter: MangaFilter override val filterCoordinator: FilterCoordinator
get() = checkNotNull(findFilterOwner()) { get() = checkNotNull(findFilterOwner()) {
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}" "Cannot find FilterCoordinator.Owner fragment in ${supportFragmentManager.fragments}"
}.filter }.filterCoordinator
private var source: MangaSource? = null private var source: MangaSource? = null
@@ -122,7 +122,7 @@ class MangaListActivity :
private fun initList(source: MangaSource, tags: Set<MangaTag>?) { private fun initList(source: MangaSource, tags: Set<MangaTag>?) {
val fm = supportFragmentManager val fm = supportFragmentManager
val existingFragment = fm.findFragmentById(R.id.container) val existingFragment = fm.findFragmentById(R.id.container)
if (existingFragment is FilterOwner) { if (existingFragment is FilterCoordinator.Owner) {
initFilter(existingFragment) initFilter(existingFragment)
} else { } else {
fm.commit { fm.commit {
@@ -141,7 +141,7 @@ class MangaListActivity :
} }
} }
private fun initFilter(filterOwner: FilterOwner) { private fun initFilter(filterOwner: FilterCoordinator.Owner) {
if (viewBinding.containerSide != null) { if (viewBinding.containerSide != null) {
if (supportFragmentManager.findFragmentById(R.id.container_side) == null) { if (supportFragmentManager.findFragmentById(R.id.container_side) == null) {
setSideFragment(FilterSheetFragment::class.java, null) setSideFragment(FilterSheetFragment::class.java, null)
@@ -154,18 +154,18 @@ class MangaListActivity :
} }
} }
} }
val filter = filterOwner.filter val filter = filterOwner.filterCoordinator
val chipSort = viewBinding.buttonOrder val chipSort = viewBinding.buttonOrder
if (chipSort != null) { if (chipSort != null) {
val filterBadge = ViewBadge(chipSort, this) val filterBadge = ViewBadge(chipSort, this)
filterBadge.setMaxCharacterCount(0) filterBadge.setMaxCharacterCount(0)
filter.header.observe(this) { filter.observe().observe(this) { snapshot ->
chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0) chipSort.setTextAndVisible(snapshot.sortOrder.titleRes)
filterBadge.counter = if (it.isFilterApplied) 1 else 0 filterBadge.counter = if (snapshot.listFilter.isEmpty()) 0 else 1
} }
} else { } else {
filter.header.map { filter.observe().map {
it.textSummary it.listFilter.tags.joinToString { tag -> tag.title }
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
.observe(this) { .observe(this) {
supportActionBar?.subtitle = it supportActionBar?.subtitle = it
@@ -173,8 +173,8 @@ class MangaListActivity :
} }
} }
private fun findFilterOwner(): FilterOwner? { private fun findFilterOwner(): FilterCoordinator.Owner? {
return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner return supportFragmentManager.findFragmentById(R.id.container) as? FilterCoordinator.Owner
} }
private fun setSideFragment(cls: Class<out Fragment>, args: Bundle?) = if (viewBinding.containerSide != null) { private fun setSideFragment(cls: Class<out Fragment>, args: Bundle?) = if (viewBinding.containerSide != null) {
@@ -188,12 +188,12 @@ class MangaListActivity :
} }
private class ApplyFilterRunnable( private class ApplyFilterRunnable(
private val filterOwner: FilterOwner, private val filterOwner: FilterCoordinator.Owner,
private val tags: Set<MangaTag>, private val tags: Set<MangaTag>,
) : Runnable { ) : Runnable {
override fun run() { override fun run() {
filterOwner.filter.applyFilter(tags) filterOwner.filterCoordinator.set(MangaListFilter(tags = tags))
} }
} }

View File

@@ -42,7 +42,7 @@ class SearchViewModel @Inject constructor(
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler) {
private val query = savedStateHandle.require<String>(SearchFragment.ARG_QUERY) private val query = savedStateHandle.require<String>(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<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
@@ -105,7 +105,8 @@ class SearchViewModel @Inject constructor(
listError.value = null listError.value = null
val list = repository.getList( val list = repository.getList(
offset = if (append) mangaList.value.sizeOrZero() else 0, offset = if (append) mangaList.value.sizeOrZero() else 0,
filter = MangaListFilter.Search(query), order = null,
filter = MangaListFilter(query = query),
) )
val prevList = mangaList.value.orEmpty() val prevList = mangaList.value.orEmpty()
if (!append) { if (!append) {

View File

@@ -116,14 +116,14 @@ class MultiSearchViewModel @Inject constructor(
val semaphore = Semaphore(MAX_PARALLELISM) val semaphore = Semaphore(MAX_PARALLELISM)
sources.mapNotNull { source -> sources.mapNotNull { source ->
val repository = mangaRepositoryFactory.create(source) val repository = mangaRepositoryFactory.create(source)
if (!repository.isSearchSupported) { if (!repository.filterCapabilities.isSearchSupported) {
null null
} else { } else {
launch { launch {
val item = runCatchingCancellable { val item = runCatchingCancellable {
semaphore.withPermit { semaphore.withPermit {
mangaListMapper.toListModelList( mangaListMapper.toListModelList(
manga = repository.getList(offset = 0, filter = MangaListFilter.Search(q)), manga = repository.getList(offset = 0, null, MangaListFilter(query = q)),
mode = ListMode.GRID, mode = ListMode.GRID,
) )
} }

View File

@@ -250,15 +250,14 @@ class SuggestionsWorker @AssistedInject constructor(
val repository = mangaRepositoryFactory.create(source) val repository = mangaRepositoryFactory.create(source)
val availableOrders = repository.sortOrders val availableOrders = repository.sortOrders
val order = preferredSortOrders.first { it in availableOrders } val order = preferredSortOrders.first { it in availableOrders }
val availableTags = repository.getTags() val availableTags = repository.getFilterOptions().availableTags
val tag = tags.firstNotNullOfOrNull { title -> val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) } availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) }
} }
val list = repository.getList( val list = repository.getList(
offset = 0, offset = 0,
filter = MangaListFilter.Advanced.Builder(order) order = order,
.tags(setOfNotNull(tag)) filter = MangaListFilter(tags = setOfNotNull(tag))
.build(),
).asArrayList() ).asArrayList()
if (appSettings.isSuggestionsExcludeNsfw) { if (appSettings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }

View File

@@ -49,7 +49,7 @@
<Spinner <Spinner
android:id="@+id/spinner_order" android:id="@+id/spinner_order"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall" android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp" /> android:paddingHorizontal="8dp" />
@@ -62,9 +62,11 @@
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:baselineAligned="false" android:baselineAligned="false"
android:orientation="horizontal" android:orientation="horizontal"
android:visibility="gone"
android:weightSum="2" android:weightSum="2"
app:selectionRequired="true" app:selectionRequired="true"
app:singleSelection="true"> app:singleSelection="true"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/button_order_asc" android:id="@+id/button_order_asc"
@@ -109,7 +111,38 @@
<Spinner <Spinner
android:id="@+id/spinner_locale" android:id="@+id/spinner_locale"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp"
android:popupBackground="@drawable/m3_spinner_popup_background" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/textView_original_locale_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:singleLine="true"
android:text="@string/original_language"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_original_locale"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:visibility="gone"
tools:visibility="visible">
<Spinner
android:id="@+id/spinner_original_locale"
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall" android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp" android:paddingHorizontal="8dp"
android:popupBackground="@drawable/m3_spinner_popup_background" /> android:popupBackground="@drawable/m3_spinner_popup_background" />
@@ -211,6 +244,28 @@
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView
android:id="@+id/textView_year"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:singleLine="true"
android:text="@string/year"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_year"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:stepSize="1"
android:visibility="gone"
app:labelBehavior="visible"
app:tickVisible="true"
tools:visibility="visible" />
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</LinearLayout> </LinearLayout>

View File

@@ -35,6 +35,7 @@
<dimen name="side_card_offset">8dp</dimen> <dimen name="side_card_offset">8dp</dimen>
<dimen name="webtoon_pages_gap">24dp</dimen> <dimen name="webtoon_pages_gap">24dp</dimen>
<dimen name="details_bs_peek_height">92dp</dimen> <dimen name="details_bs_peek_height">92dp</dimen>
<dimen name="spinner_height">56dp</dimen>
<dimen name="search_suggestions_manga_height">142dp</dimen> <dimen name="search_suggestions_manga_height">142dp</dimen>
<dimen name="search_suggestions_manga_spacing">6dp</dimen> <dimen name="search_suggestions_manga_spacing">6dp</dimen>

View File

@@ -707,4 +707,16 @@
<string name="no_fix_required">No fix required for \"%s\"</string> <string name="no_fix_required">No fix required for \"%s\"</string>
<string name="no_alternatives_found">No alternatives found for \"%s\"</string> <string name="no_alternatives_found">No alternatives found for \"%s\"</string>
<string name="manga_fix_prompt">This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background</string> <string name="manga_fix_prompt">This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background</string>
<string name="content_type_novel">Novel</string>
<string name="content_type_manhua">Manhua</string>
<string name="content_type_manhwa">Manhwa</string>
<string name="recently_added">Recently added</string>
<string name="added_long_ago">Added long ago</string>
<string name="popular_in_hour">Popular this hour</string>
<string name="popular_today">Popular today</string>
<string name="popular_in_week">Popular this week</string>
<string name="popular_in_month">Popular this month</string>
<string name="popular_in_year">Popular this year</string>
<string name="original_language">Original language</string>
<string name="year">Year</string>
</resources> </resources>