Update parsers and filters

master
Koitharu 2 years ago
parent d9d11d685e
commit 6f45a44070
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

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

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

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

@ -56,6 +56,9 @@ val ContentType.titleResId
ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other
ContentType.MANHWA -> R.string.content_type_manhwa
ContentType.MANHUA -> R.string.content_type_manhua
ContentType.NOVEL -> R.string.content_type_novel
}
fun MangaSource.getSummary(context: Context): String? = when (this) {

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

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

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

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

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

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

@ -31,25 +31,18 @@ class ExternalPluginContentSource(
@Blocking
@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()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
if (!filter.query.isNullOrEmpty()) {
uri.appendQueryParameter("query", filter.query)
}
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
return contentResolver.query(uri.build(), null, null, null, order.name)
.safe()
.use { cursor ->
val result = ArrayList<Manga>(cursor.count)

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

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

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

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

@ -19,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor(
return@runCatchingCancellable null
}
val repository = repositoryFactory.create(manga.source)
val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
val list = repository.getList(offset = 0, null, MangaListFilter(query = manga.title))
val newManga = list.find { x -> x.title == manga.title }?.let {
repository.getDetails(it)
} ?: return@runCatchingCancellable null

@ -1,540 +1,393 @@
package org.koitharu.kotatsu.filter.ui
import android.view.View
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.GenericSortOrder
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.SortDirection
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.model.direction
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.asFlow
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.ErrorFooter
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_MIN
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import java.text.Collator
import java.util.EnumSet
import java.util.LinkedList
import java.util.Calendar
import java.util.Locale
import java.util.TreeSet
import javax.inject.Inject
@ViewModelScoped
class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
dataRepository: MangaDataRepository,
private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle,
) : MangaFilter {
) {
private val coroutineScope = lifecycle.lifecycleScope
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val currentState = MutableStateFlow(
MangaListFilter.Advanced(
sortOrder = repository.defaultSortOrder,
tags = emptySet(),
tagsExclude = emptySet(),
locale = null,
states = emptySet(),
contentRating = emptySet(),
),
)
private val localTags = SuspendLazy {
dataRepository.findTags(repository.source)
}
private val tagsFlow = flow {
val localTags = localTags.get()
emit(PendingData(localTags, isLoading = true, error = null))
tryLoadTags()
.onSuccess { remoteTags ->
emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null))
}.onFailure {
emit(PendingData(localTags, isLoading = false, error = it))
}
}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), PendingData(emptySet(), true, null))
private var availableTagsDeferred = loadTagsAsync()
private var availableLocalesDeferred = loadLocalesAsync()
private var allTagsLoadJob: Job? = null
override val allTags = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
get() {
if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) {
loadAllTags()
}
return field
}
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val availableSortOrders = repository.sortOrders
private val capabilities = repository.filterCapabilities
private val filterOptions = SuspendLazy { repository.getFilterOptions() }
override val filterTags: StateFlow<FilterProperty<MangaTag>> = combine(
currentState.distinctUntilChangedBy { it.tags },
getTopTagsAsFlow(currentState.map { it.tags }, 16),
) { state, tags ->
val mangaSource: MangaSource
get() = repository.source
val isFilterApplied: Boolean
get() = !currentListFilter.value.isEmpty()
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty(
availableItems = tags.items.asArrayList(),
selectedItems = state.tags,
isLoading = tags.isLoading,
error = tags.error,
availableItems = availableSortOrders.sortedByOrdinal(),
selectedItem = selected,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
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)
override val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (repository.isTagsExclusionSupported) {
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
combine(
currentState.distinctUntilChangedBy { it.tagsExclude },
getBottomTagsAsFlow(4),
) { state, tags ->
FilterProperty(
availableItems = tags.items.asArrayList(),
selectedItems = state.tagsExclude,
isLoading = tags.isLoading,
error = tags.error,
getBottomTags(TAGS_LIMIT),
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.addFirstDistinct(selected.tagsExclude),
selectedItems = selected.tagsExclude,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.states },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableStates.sortedByOrdinal(),
selectedItems = selected.states,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentRating: StateFlow<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(emptyProperty())
MutableStateFlow(FilterProperty.EMPTY)
}
override val filterSortOrder: StateFlow<FilterProperty<GenericSortOrder>> =
currentState.distinctUntilChangedBy { it.sortOrder }.map { state ->
val orders = repository.sortOrders
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
FilterProperty(
availableItems = orders.mapTo(EnumSet.noneOf(GenericSortOrder::class.java)) {
GenericSortOrder.of(it)
}.sortedByOrdinal(),
selectedItems = setOf(GenericSortOrder.of(state.sortOrder)),
isLoading = false,
error = null,
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.year),
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
override val filterSortDirection: StateFlow<FilterProperty<SortDirection>> =
currentState.distinctUntilChangedBy { it.sortOrder }.map { state ->
val orders = repository.sortOrders
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
currentListFilter.distinctUntilChanged { old, new ->
old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
}.map { selected ->
FilterProperty(
availableItems = state.sortOrder.let {
val genericOrder = GenericSortOrder.of(it)
val result = EnumSet.noneOf(SortDirection::class.java)
if (genericOrder.ascending in orders) result.add(SortDirection.ASC)
if (genericOrder.descending in orders) result.add(SortDirection.DESC)
result
}?.sortedByOrdinal().orEmpty(),
selectedItems = setOf(state.sortOrder.direction),
isLoading = false,
error = null,
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.yearFrom, selected.yearTo),
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
override val filterState: StateFlow<FilterProperty<MangaState>> = combine(
currentState.distinctUntilChangedBy { it.states },
flowOf(repository.states),
) { state, states ->
FilterProperty(
availableItems = states.sortedByOrdinal(),
selectedItems = state.states,
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
fun reset() {
currentListFilter.value = MangaListFilter.EMPTY
}
override val filterContentRating: StateFlow<FilterProperty<ContentRating>> = combine(
currentState.distinctUntilChangedBy { it.contentRating },
flowOf(repository.contentRatings),
) { rating, ratings ->
FilterProperty(
availableItems = ratings.sortedByOrdinal(),
selectedItems = rating.contentRating,
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine(
currentState.distinctUntilChangedBy { it.locale },
getLocalesAsFlow(),
) { 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,
),
fun snapshot() = Snapshot(
sortOrder = currentSortOrder.value,
listFilter = currentListFilter.value,
)
override fun applyFilter(tags: Set<MangaTag>) {
setTags(tags)
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
}
override fun setSortOrder(value: SortOrder) {
val available = repository.sortOrders
val sortOrder = if (value !in available) {
val generic = GenericSortOrder.of(value)
when {
generic.ascending in available -> generic.ascending
generic.descending in available -> generic.descending
else -> return
}
} else {
value
}
currentState.update { oldValue ->
oldValue.copy(sortOrder = sortOrder)
}
repository.defaultSortOrder = sortOrder
fun set(value: MangaListFilter) {
currentListFilter.value = value
}
override fun setLanguage(value: Locale?) {
currentState.update { oldValue ->
fun setLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(locale = value)
}
}
override fun setTag(value: MangaTag, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) {
if (addOrRemove) {
oldValue.tags + value
} else {
oldValue.tags - value
}
} else {
if (addOrRemove) {
setOf(value)
} else {
emptySet()
}
}
oldValue.copy(
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
)
fun setYear(value: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(year = value)
}
}
override fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) {
if (addOrRemove) {
oldValue.tagsExclude + value
} else {
oldValue.tagsExclude - value
}
} else {
if (addOrRemove) {
setOf(value)
} else {
emptySet()
}
}
fun toggleState(value: MangaState, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
tagsExclude = newTags,
tags = oldValue.tags - newTags,
states = if (isSelected) oldValue.states + value else oldValue.states - value,
)
}
}
override fun setState(value: MangaState, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newStates = if (addOrRemove) {
oldValue.states + value
} else {
oldValue.states - value
}
oldValue.copy(states = newStates)
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
)
}
}
override fun setContentRating(value: ContentRating, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newRating = if (addOrRemove) {
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 {
oldValue.contentRating - value
if (isSelected) setOf(value) else emptySet()
}
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,
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
)
}
}
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 ->
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
} else {
if (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = tags,
tagsExclude = oldValue.tagsExclude - tags,
tags = oldValue.tags - newTagsExclude,
tagsExclude = newTagsExclude,
)
}
}
fun reset() {
currentState.update { oldValue ->
MangaListFilter.Advanced.Builder(oldValue.sortOrder).build()
}
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
}
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))
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getTopTags(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 (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
PendingData(res, all.isLoading, all.error.takeIf { res.size < limit })
}
private fun getBottomTagsAsFlow(limit: Int): Flow<PendingData<MangaTag>> = combine(
private fun getBottomTags(limit: Int): Flow<Result<List<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))
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))
}
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)
if (result.isNotEmpty()) {
Result.success(result)
} else {
searchRepository.getTagsSuggestion(selectedTags).take(limit)
}
if (tags.size < limit) {
tags = tags + availableTags.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)
}
options.map { result }
}
for (tag in selectedTags) {
val model = ChipsView.ChipModel(
title = tag.title,
isChecked = true,
data = tag,
)
result.addFirst(model)
}
return result
}
private suspend fun tryLoadTags(): Result<Set<MangaTag>> {
val shouldRetryOnError = availableTagsDeferred.isCompleted
val result = availableTagsDeferred.await()
if (result.isFailure && shouldRetryOnError) {
availableTagsDeferred = loadTagsAsync()
return availableTagsDeferred.await()
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
val result = ArrayDeque<T>(this.size + other.size)
result.addAll(this)
for (item in other) {
if (item !in result) {
result.addFirst(item)
}
}
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()
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
val result = ArrayDeque<T>(this.size + 1)
result.addAll(this)
if (item !in result) {
result.addFirst(item)
}
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?,
data class Snapshot(
val sortOrder: SortOrder,
val listFilter: MangaListFilter,
)
private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null)
interface Owner {
private fun <T> emptyProperty() = FilterProperty<T>(emptyList(), emptySet(), false, null)
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
val filterCoordinator: FilterCoordinator
}
private val collator = lc?.let { Collator.getInstance(Locale(it)) }
private companion object {
override fun compare(o1: MangaTag, o2: MangaTag): Int {
val t1 = o1.title.lowercase()
val t2 = o2.title.lowercase()
return collator?.compare(t1, t2) ?: compareValues(t1, t2)
}
const val TAGS_LIMIT = 12
val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
}
}

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

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

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

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

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

@ -1,11 +1,53 @@
package org.koitharu.kotatsu.filter.ui.model
data class FilterProperty<T>(
data class FilterProperty<out T>(
val availableItems: List<T>,
val selectedItems: Set<T>,
val isLoading: Boolean,
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()
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,
)
}
}

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

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

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

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

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

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

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

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

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

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

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

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

@ -16,9 +16,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.ifZero
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder

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

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

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

@ -28,6 +28,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga

@ -125,6 +125,10 @@ class MangaSearchRepository @Inject constructor(
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)
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {

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

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

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

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

@ -49,7 +49,7 @@
<Spinner
android:id="@+id/spinner_order"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp" />
@ -62,9 +62,11 @@
android:layout_marginTop="12dp"
android:baselineAligned="false"
android:orientation="horizontal"
android:visibility="gone"
android:weightSum="2"
app:selectionRequired="true"
app:singleSelection="true">
app:singleSelection="true"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_order_asc"
@ -109,7 +111,38 @@
<Spinner
android:id="@+id/spinner_locale"
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:paddingHorizontal="8dp"
android:popupBackground="@drawable/m3_spinner_popup_background" />
@ -211,6 +244,28 @@
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
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>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

@ -35,6 +35,7 @@
<dimen name="side_card_offset">8dp</dimen>
<dimen name="webtoon_pages_gap">24dp</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_spacing">6dp</dimen>

@ -707,4 +707,16 @@
<string name="no_fix_required">No fix required 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="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>

Loading…
Cancel
Save