Update parsers and filters

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

View File

@@ -83,7 +83,7 @@ afterEvaluate {
}
dependencies {
//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'

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.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) {

View File

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

View File

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

View File

@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.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)
}

View File

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

View File

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

View File

@@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.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 filterCapabilities: MangaListFilterCapabilities
val isTagsExclusionSupported: Boolean
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 }
}

View File

@@ -13,11 +13,14 @@ import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.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()

View File

@@ -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 suspend fun getFilterOptions(): MangaListFilterOptions = capabilities.let {
MangaListFilterOptions(
availableTags = runInterruptible(Dispatchers.IO) {
contentSource.getTags()
},
availableStates = it?.availableStates.orEmpty(),
availableContentRating = it?.availableContentRating.orEmpty(),
availableContentTypes = emptySet(),
availableDemographics = emptySet(),
availableLocales = emptySet(),
)
}
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.filter.ui
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import java.util.LinkedList
import javax.inject.Inject
class FilterHeaderProducer @Inject constructor(
private val searchRepository: MangaSearchRepository,
) {
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return filterCoordinator.tags.mapLatest {
createChipsList(
source = filterCoordinator.mangaSource,
property = it,
limit = 8,
)
}.combine(filterCoordinator.observe()) { chipList, snapshot ->
FilterHeaderModel(
chips = chipList,
sortOrder = snapshot.sortOrder,
isFilterApplied = !snapshot.listFilter.isEmpty(),
)
}
}
private suspend fun createChipsList(
source: MangaSource,
property: FilterProperty<MangaTag>,
limit: Int,
): List<ChipsView.ChipModel> {
val selectedTags = property.selectedItems.toMutableSet()
var tags = if (selectedTags.isEmpty()) {
searchRepository.getTagsSuggestion("", limit, source)
} else {
searchRepository.getTagsSuggestion(selectedTags).take(limit)
}
if (tags.size < limit) {
tags = tags + property.availableItems.take(limit - tags.size)
}
if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList()
}
val result = LinkedList<ChipsView.ChipModel>()
for (tag in tags) {
val model = ChipsView.ChipModel(
title = tag.title,
isChecked = selectedTags.remove(tag),
data = tag,
)
if (model.isChecked) {
result.addFirst(model)
} else {
result.addLast(model)
}
}
for (tag in selectedTags) {
val model = ChipsView.ChipModel(
title = tag.title,
isChecked = true,
data = tag,
)
result.addFirst(model)
}
return result
}
}

View File

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

View File

@@ -1,35 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.model.GenericSortOrder
import org.koitharu.kotatsu.core.model.SortDirection
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.util.Locale
interface MangaFilter : OnFilterChangedListener {
val allTags: StateFlow<List<ListModel>>
val filterTags: StateFlow<FilterProperty<MangaTag>>
val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>>
val filterSortOrder: StateFlow<FilterProperty<GenericSortOrder>>
val filterSortDirection: StateFlow<FilterProperty<SortDirection>>
val filterState: StateFlow<FilterProperty<MangaState>>
val filterContentRating: StateFlow<FilterProperty<ContentRating>>
val filterLocale: StateFlow<FilterProperty<Locale?>>
val header: StateFlow<FilterHeaderModel>
fun applyFilter(tags: Set<MangaTag>)
}

View File

@@ -1,23 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Locale
interface OnFilterChangedListener : ListHeaderClickListener {
fun setSortOrder(value: SortOrder)
fun setLanguage(value: Locale?)
fun setTag(value: MangaTag, addOrRemove: Boolean)
fun setTagExcluded(value: MangaTag, addOrRemove: Boolean)
fun setState(value: MangaState, addOrRemove: Boolean)
fun setContentRating(value: ContentRating, addOrRemove: Boolean)
}

View File

@@ -1,11 +1,53 @@
package org.koitharu.kotatsu.filter.ui.model
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,
)
}
}

View File

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

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.filter.ui.tags
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.text.Collator
import java.util.Locale
class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
private val collator = lc?.let { Collator.getInstance(Locale(it)) }
override fun compare(o1: MangaTag, o2: MangaTag): Int {
val t1 = o1.title.lowercase()
val t2 = o2.title.lowercase()
return collator?.compare(t1, t2) ?: compareValues(t1, t2)
}
}

View File

@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.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),
)
}

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.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()
}
}

View File

@@ -25,18 +25,16 @@ import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.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? {

View 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) {

View File

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

View File

@@ -23,15 +23,14 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.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)

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.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)

View File

@@ -62,7 +62,6 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.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,

View File

@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.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

View File

@@ -11,8 +11,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.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

View File

@@ -25,8 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.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 {

View File

@@ -31,7 +31,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.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) {

View File

@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.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

View File

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

View File

@@ -36,14 +36,14 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.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))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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