Update parsers and filters
This commit is contained in:
@@ -83,7 +83,7 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:aba8a80d8f') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:336c4a4d49') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
@@ -96,10 +96,10 @@ dependencies {
|
||||
implementation 'androidx.activity:activity-ktx:1.9.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.3'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.5'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
@@ -107,7 +107,7 @@ dependencies {
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.1'
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -36,13 +37,13 @@ class AlternativesUseCase @Inject constructor(
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.isSearchSupported) {
|
||||
if (!repository.filterCapabilities.isSearchSupported) {
|
||||
continue
|
||||
}
|
||||
launch {
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
for (item in list) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
@Deprecated("")
|
||||
enum class GenericSortOrder(
|
||||
@StringRes val titleResId: Int,
|
||||
val ascending: SortOrder,
|
||||
|
||||
@@ -56,6 +56,9 @@ val ContentType.titleResId
|
||||
ContentType.HENTAI -> R.string.content_type_hentai
|
||||
ContentType.COMICS -> R.string.content_type_comics
|
||||
ContentType.OTHER -> R.string.content_type_other
|
||||
ContentType.MANHWA -> R.string.content_type_manhwa
|
||||
ContentType.MANHUA -> R.string.content_type_manhua
|
||||
ContentType.NOVEL -> R.string.content_type_novel
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
|
||||
@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
@@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
override val states: Set<MangaState>
|
||||
get() = emptySet()
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = emptySet()
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = SortOrder.NEWEST
|
||||
set(value) = Unit
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = false
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = false
|
||||
override val isSearchSupported: Boolean
|
||||
get() = false
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
@@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = stub(null)
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
|
||||
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
|
||||
if (!title.isNullOrEmpty()) {
|
||||
val list = getList(0, MangaListFilter.Search(title))
|
||||
val list = getList(0, null, MangaListFilter(query = title))
|
||||
if (url != null) {
|
||||
list.find { it.url == url }?.let {
|
||||
return it
|
||||
@@ -80,7 +80,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
}.ifNullOrEmpty {
|
||||
seed.author
|
||||
} ?: return@runCatchingCancellable null
|
||||
val seedList = getList(0, MangaListFilter.Search(seedTitle))
|
||||
val seedList = getList(0, null, MangaListFilter(query = seedTitle))
|
||||
seedList.first { x -> x.url == url }
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
@@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.collections.set
|
||||
@@ -35,19 +33,11 @@ interface MangaRepository {
|
||||
|
||||
val sortOrders: Set<SortOrder>
|
||||
|
||||
val states: Set<MangaState>
|
||||
|
||||
val contentRatings: Set<ContentRating>
|
||||
|
||||
var defaultSortOrder: SortOrder
|
||||
|
||||
val isMultipleTagsSupported: Boolean
|
||||
val 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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,14 @@ import org.koitharu.kotatsu.parsers.model.Favicons
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Locale
|
||||
@@ -28,17 +31,20 @@ class ParserMangaRepository(
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache), Interceptor {
|
||||
|
||||
private val filterOptionsLazy = SuspendLazy {
|
||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFilterOptions()
|
||||
}
|
||||
}
|
||||
|
||||
override val source: MangaParserSource
|
||||
get() = parser.source
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = parser.availableSortOrders
|
||||
|
||||
override val states: Set<MangaState>
|
||||
get() = parser.availableStates
|
||||
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = parser.availableContentRating
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = parser.filterCapabilities
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = getConfig().defaultSortOrder ?: sortOrders.first()
|
||||
@@ -46,15 +52,6 @@ class ParserMangaRepository(
|
||||
getConfig().defaultSortOrder = value
|
||||
}
|
||||
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = parser.isMultipleTagsSupported
|
||||
|
||||
override val isSearchSupported: Boolean
|
||||
get() = parser.isSearchSupported
|
||||
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = parser.isTagsExclusionSupported
|
||||
|
||||
var domain: String
|
||||
get() = parser.domain
|
||||
set(value) {
|
||||
@@ -72,9 +69,9 @@ class ParserMangaRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
|
||||
return mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getList(offset, filter)
|
||||
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +85,7 @@ class ParserMangaRepository(
|
||||
parser.getPageUrl(page)
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getAvailableTags()
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> {
|
||||
return parser.getAvailableLocales()
|
||||
}
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
|
||||
|
||||
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFavicons()
|
||||
|
||||
@@ -6,16 +6,14 @@ import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class ExternalMangaRepository(
|
||||
private val contentResolver: ContentResolver,
|
||||
@@ -36,28 +34,39 @@ class ExternalMangaRepository(
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
||||
|
||||
override val states: Set<MangaState>
|
||||
get() = capabilities?.availableStates.orEmpty()
|
||||
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = capabilities?.availableContentRating.orEmpty()
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = capabilities.let {
|
||||
MangaListFilterCapabilities(
|
||||
isMultipleTagsSupported = it?.isMultipleTagsSupported == true,
|
||||
isTagsExclusionSupported = it?.isTagsExclusionSupported == true,
|
||||
isSearchSupported = it?.isSearchSupported == true,
|
||||
isSearchWithFiltersSupported = false, // TODO
|
||||
isYearSupported = false, // TODO
|
||||
isYearRangeSupported = false, // TODO
|
||||
isOriginalLocaleSupported = false, // TODO
|
||||
)
|
||||
}
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
||||
set(value) = Unit
|
||||
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = capabilities?.isMultipleTagsSupported ?: true
|
||||
override 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
|
||||
}
|
||||
|
||||
@@ -31,25 +31,18 @@ class ExternalPluginContentSource(
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||
uri.appendQueryParameter("offset", offset.toString())
|
||||
when (filter) {
|
||||
is MangaListFilter.Advanced -> {
|
||||
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
}
|
||||
|
||||
is MangaListFilter.Search -> {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
if (!filter.query.isNullOrEmpty()) {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
|
||||
return contentResolver.query(uri.build(), null, null, null, order.name)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
|
||||
@@ -4,14 +4,22 @@ import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.SortDirection
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_HOUR
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_MONTH
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_TODAY
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_WEEK
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_YEAR
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RELEVANCE
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
|
||||
|
||||
@@ -28,6 +36,14 @@ val SortOrder.titleRes: Int
|
||||
POPULARITY_ASC -> R.string.unpopular
|
||||
RATING_ASC -> R.string.low_rating
|
||||
NEWEST_ASC -> R.string.order_oldest
|
||||
ADDED -> R.string.recently_added
|
||||
ADDED_ASC -> R.string.added_long_ago
|
||||
RELEVANCE -> R.string.by_relevance
|
||||
POPULARITY_HOUR -> R.string.popular_in_hour
|
||||
POPULARITY_TODAY -> R.string.popular_today
|
||||
POPULARITY_WEEK -> R.string.popular_in_week
|
||||
POPULARITY_MONTH -> R.string.popular_in_month
|
||||
POPULARITY_YEAR -> R.string.popular_in_year
|
||||
}
|
||||
|
||||
val SortOrder.direction: SortDirection
|
||||
@@ -36,11 +52,19 @@ val SortOrder.direction: SortDirection
|
||||
POPULARITY_ASC,
|
||||
RATING_ASC,
|
||||
NEWEST_ASC,
|
||||
ADDED_ASC,
|
||||
ALPHABETICAL -> SortDirection.ASC
|
||||
|
||||
UPDATED,
|
||||
POPULARITY,
|
||||
POPULARITY_HOUR,
|
||||
POPULARITY_TODAY,
|
||||
POPULARITY_WEEK,
|
||||
POPULARITY_MONTH,
|
||||
POPULARITY_YEAR,
|
||||
RATING,
|
||||
NEWEST,
|
||||
ADDED,
|
||||
RELEVANCE,
|
||||
ALPHABETICAL_DESC -> SortDirection.DESC
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -132,3 +133,5 @@ suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x !
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
|
||||
|
||||
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
|
||||
|
||||
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
|
||||
|
||||
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
|
||||
|
||||
fun longOf(a: Int, b: Int): Long {
|
||||
|
||||
@@ -70,15 +70,14 @@ class ExploreRepository @Inject constructor(
|
||||
): List<Manga> = runCatchingCancellable {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
val order = repository.sortOrders.random()
|
||||
val availableTags = repository.getTags()
|
||||
val availableTags = repository.getFilterOptions().availableTags
|
||||
val tag = tags.firstNotNullOfOrNull { title ->
|
||||
availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
|
||||
}
|
||||
val list = repository.getList(
|
||||
offset = 0,
|
||||
filter = MangaListFilter.Advanced.Builder(order)
|
||||
.tags(setOfNotNull(tag))
|
||||
.build(),
|
||||
order = order,
|
||||
filter = MangaListFilter(tags = setOfNotNull(tag))
|
||||
).asArrayList()
|
||||
if (settings.isSuggestionsExcludeNsfw) {
|
||||
list.removeAll { it.isNsfw }
|
||||
|
||||
@@ -19,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor(
|
||||
return@runCatchingCancellable null
|
||||
}
|
||||
val repository = repositoryFactory.create(manga.source)
|
||||
val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||
val list = repository.getList(offset = 0, null, MangaListFilter(query = manga.title))
|
||||
val newManga = list.find { x -> x.title == manga.title }?.let {
|
||||
repository.getDetails(it)
|
||||
} ?: return@runCatchingCancellable null
|
||||
|
||||
@@ -1,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.chip.Chip
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
@@ -15,12 +18,17 @@ import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
|
||||
|
||||
private val filter: MangaFilter
|
||||
get() = (requireActivity() as FilterOwner).filter
|
||||
@Inject
|
||||
lateinit var filterHeaderProducer: FilterHeaderProducer
|
||||
|
||||
private val filter: FilterCoordinator
|
||||
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||
@@ -29,7 +37,9 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.chipsTags.onChipClickListener = this
|
||||
filter.header.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
filterHeaderProducer.observeHeader(filter)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
@@ -39,7 +49,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
if (tag == null) {
|
||||
TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false)
|
||||
} else {
|
||||
filter.setTag(tag, !chip.isChecked)
|
||||
filter.toggleTag(tag, !chip.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
|
||||
class FilterHeaderProducer @Inject constructor(
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
) {
|
||||
|
||||
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
|
||||
return filterCoordinator.tags.mapLatest {
|
||||
createChipsList(
|
||||
source = filterCoordinator.mangaSource,
|
||||
property = it,
|
||||
limit = 8,
|
||||
)
|
||||
}.combine(filterCoordinator.observe()) { chipList, snapshot ->
|
||||
FilterHeaderModel(
|
||||
chips = chipList,
|
||||
sortOrder = snapshot.sortOrder,
|
||||
isFilterApplied = !snapshot.listFilter.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createChipsList(
|
||||
source: MangaSource,
|
||||
property: FilterProperty<MangaTag>,
|
||||
limit: Int,
|
||||
): List<ChipsView.ChipModel> {
|
||||
val selectedTags = property.selectedItems.toMutableSet()
|
||||
var tags = if (selectedTags.isEmpty()) {
|
||||
searchRepository.getTagsSuggestion("", limit, source)
|
||||
} else {
|
||||
searchRepository.getTagsSuggestion(selectedTags).take(limit)
|
||||
}
|
||||
if (tags.size < limit) {
|
||||
tags = tags + property.availableItems.take(limit - tags.size)
|
||||
}
|
||||
if (tags.isEmpty() && selectedTags.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val result = LinkedList<ChipsView.ChipModel>()
|
||||
for (tag in tags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = selectedTags.remove(tag),
|
||||
data = tag,
|
||||
)
|
||||
if (model.isChecked) {
|
||||
result.addFirst(model)
|
||||
} else {
|
||||
result.addLast(model)
|
||||
}
|
||||
}
|
||||
for (tag in selectedTags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
)
|
||||
result.addFirst(model)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
interface FilterOwner {
|
||||
|
||||
val filter: MangaFilter
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.core.model.GenericSortOrder
|
||||
import org.koitharu.kotatsu.core.model.SortDirection
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import java.util.Locale
|
||||
|
||||
interface MangaFilter : OnFilterChangedListener {
|
||||
|
||||
val allTags: StateFlow<List<ListModel>>
|
||||
|
||||
val filterTags: StateFlow<FilterProperty<MangaTag>>
|
||||
|
||||
val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>>
|
||||
|
||||
val filterSortOrder: StateFlow<FilterProperty<GenericSortOrder>>
|
||||
|
||||
val filterSortDirection: StateFlow<FilterProperty<SortDirection>>
|
||||
|
||||
val filterState: StateFlow<FilterProperty<MangaState>>
|
||||
|
||||
val filterContentRating: StateFlow<FilterProperty<ContentRating>>
|
||||
|
||||
val filterLocale: StateFlow<FilterProperty<Locale?>>
|
||||
|
||||
val header: StateFlow<FilterHeaderModel>
|
||||
|
||||
fun applyFilter(tags: Set<MangaTag>)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.Locale
|
||||
|
||||
interface OnFilterChangedListener : ListHeaderClickListener {
|
||||
|
||||
fun setSortOrder(value: SortOrder)
|
||||
|
||||
fun setLanguage(value: Locale?)
|
||||
|
||||
fun setTag(value: MangaTag, addOrRemove: Boolean)
|
||||
|
||||
fun setTagExcluded(value: MangaTag, addOrRemove: Boolean)
|
||||
|
||||
fun setState(value: MangaState, addOrRemove: Boolean)
|
||||
|
||||
fun setContentRating(value: ContentRating, addOrRemove: Boolean)
|
||||
}
|
||||
@@ -1,11 +1,53 @@
|
||||
package org.koitharu.kotatsu.filter.ui.model
|
||||
|
||||
data class FilterProperty<T>(
|
||||
data class FilterProperty<out T>(
|
||||
val availableItems: List<T>,
|
||||
val selectedItems: Set<T>,
|
||||
val isLoading: Boolean,
|
||||
val error: Throwable?,
|
||||
) {
|
||||
|
||||
constructor(
|
||||
availableItems: List<T>,
|
||||
selectedItems: Set<T>,
|
||||
) : this(
|
||||
availableItems = availableItems,
|
||||
selectedItems = selectedItems,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
|
||||
constructor(
|
||||
availableItems: List<T>,
|
||||
selectedItem: T,
|
||||
) : this(
|
||||
availableItems = availableItems,
|
||||
selectedItems = setOf(selectedItem),
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
|
||||
fun isEmpty(): Boolean = availableItems.isEmpty()
|
||||
|
||||
companion object {
|
||||
|
||||
val LOADING = FilterProperty<Nothing>(
|
||||
availableItems = emptyList(),
|
||||
selectedItems = emptySet(),
|
||||
isLoading = true,
|
||||
error = null,
|
||||
)
|
||||
|
||||
val EMPTY = FilterProperty<Nothing>(
|
||||
availableItems = emptyList(),
|
||||
selectedItems = emptySet(),
|
||||
)
|
||||
|
||||
fun error(error: Throwable) = FilterProperty<Nothing>(
|
||||
availableItems = emptyList(),
|
||||
selectedItems = emptySet(),
|
||||
isLoading = false,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,31 +13,35 @@ import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.slider.Slider
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.GenericSortOrder
|
||||
import org.koitharu.kotatsu.core.model.SortDirection
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
AdapterView.OnItemSelectedListener,
|
||||
ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener {
|
||||
ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, Slider.OnChangeListener {
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
@@ -52,13 +56,14 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
}
|
||||
val filter = requireFilter()
|
||||
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||
filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged)
|
||||
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||
filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
|
||||
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
|
||||
filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
|
||||
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||
// filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged)
|
||||
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
|
||||
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
|
||||
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
|
||||
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
|
||||
|
||||
binding.spinnerLocale.onItemSelectedListener = this
|
||||
binding.spinnerOrder.onItemSelectedListener = this
|
||||
@@ -66,12 +71,13 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
binding.chipsContentRating.onChipClickListener = this
|
||||
binding.chipsGenres.onChipClickListener = this
|
||||
binding.chipsGenresExclude.onChipClickListener = this
|
||||
binding.sliderYear.addOnChangeListener(this)
|
||||
binding.layoutSortDirection.addOnButtonCheckedListener(this)
|
||||
}
|
||||
|
||||
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
|
||||
if (isChecked) {
|
||||
setSortDirection(getSortDirection(checkedId) ?: return)
|
||||
// setSortDirection(getSortDirection(checkedId) ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,33 +85,43 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
val filter = requireFilter()
|
||||
when (parent.id) {
|
||||
R.id.spinner_order -> {
|
||||
val genericOrder = filter.filterSortOrder.value.availableItems[position]
|
||||
val direction = getSortDirection(requireViewBinding().layoutSortDirection.checkedButtonId)
|
||||
filter.setSortOrder(genericOrder[direction ?: SortDirection.DESC])
|
||||
val value = filter.sortOrder.value.availableItems[position]
|
||||
filter.setSortOrder(value)
|
||||
}
|
||||
|
||||
R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position])
|
||||
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (!fromUser) {
|
||||
return
|
||||
}
|
||||
val intValue = value.toInt()
|
||||
val filter = requireFilter()
|
||||
when (slider.id) {
|
||||
R.id.slider_year -> filter.setYear(intValue)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val filter = requireFilter()
|
||||
when (data) {
|
||||
is MangaState -> filter.setState(data, !chip.isChecked)
|
||||
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
||||
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
||||
filter.setTagExcluded(data, !chip.isChecked)
|
||||
filter.toggleTagExclude(data, !chip.isChecked)
|
||||
} else {
|
||||
filter.setTag(data, !chip.isChecked)
|
||||
filter.toggleTag(data, !chip.isChecked)
|
||||
}
|
||||
|
||||
is ContentRating -> filter.setContentRating(data, !chip.isChecked)
|
||||
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
|
||||
null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSortOrderChanged(value: FilterProperty<GenericSortOrder>) {
|
||||
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
|
||||
val b = viewBinding ?: return
|
||||
b.textViewOrderTitle.isGone = value.isEmpty()
|
||||
b.cardOrder.isGone = value.isEmpty()
|
||||
@@ -117,7 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
b.spinnerOrder.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { b.spinnerOrder.context.getString(it.titleResId) },
|
||||
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
@@ -271,15 +287,20 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
b.chipsContentRating.setChips(chips)
|
||||
}
|
||||
|
||||
private fun requireFilter() = (requireActivity() as FilterOwner).filter
|
||||
|
||||
private fun setSortDirection(direction: SortDirection) {
|
||||
val filter = requireFilter()
|
||||
val currentOrder = filter.filterSortOrder.value.selectedItems.singleOrNull() ?: return
|
||||
val newOrder = currentOrder[direction]
|
||||
filter.setSortOrder(newOrder)
|
||||
private fun onYearChanged(value: FilterProperty<Int>) {
|
||||
val b = viewBinding ?: return
|
||||
b.textViewYear.isGone = value.isEmpty()
|
||||
b.sliderYear.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
b.sliderYear.valueFrom = value.availableItems.first().toFloat()
|
||||
b.sliderYear.valueTo = value.availableItems.last().toFloat()
|
||||
b.sliderYear.setValueRounded((value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN).toFloat())
|
||||
}
|
||||
|
||||
private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
||||
|
||||
private fun getSortDirection(@IdRes buttonId: Int): SortDirection? = when (buttonId) {
|
||||
R.id.button_order_asc -> SortDirection.ASC
|
||||
R.id.button_order_desc -> SortDirection.DESC
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.filter.ui.tags
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
|
||||
|
||||
private val collator = lc?.let { Collator.getInstance(Locale(it)) }
|
||||
|
||||
override fun compare(o1: MangaTag, o2: MangaTag): Int {
|
||||
val t1 = o1.title.lowercase()
|
||||
val t2 = o2.title.lowercase()
|
||||
return collator?.compare(t1, t2) ?: compareValues(t1, t2)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetTagsBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -32,7 +32,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
|
||||
extrasProducer = {
|
||||
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
|
||||
factory.create(
|
||||
filter = (requireActivity() as FilterOwner).filter,
|
||||
filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator,
|
||||
isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,40 +14,43 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
|
||||
class TagsCatalogViewModel @AssistedInject constructor(
|
||||
@Assisted private val filter: MangaFilter,
|
||||
@Assisted private val filter: FilterCoordinator,
|
||||
@Assisted private val isExcluded: Boolean,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val searchQuery = MutableStateFlow("")
|
||||
|
||||
private val filterProperty: StateFlow<FilterProperty<MangaTag>>
|
||||
get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags
|
||||
get() = if (isExcluded) filter.tagsExcluded else filter.tags
|
||||
|
||||
private val tags = combine(
|
||||
filter.allTags,
|
||||
private val tags: StateFlow<List<ListModel>> = combine(
|
||||
filter.getAllTags(),
|
||||
filterProperty.map { it.selectedItems },
|
||||
) { all, selected ->
|
||||
all.map { x ->
|
||||
if (x is TagCatalogItem) {
|
||||
val checked = x.tag in selected
|
||||
if (x.isChecked == checked) {
|
||||
x
|
||||
} else {
|
||||
x.copy(isChecked = checked)
|
||||
all.fold(
|
||||
onSuccess = {
|
||||
it.map { tag ->
|
||||
TagCatalogItem(
|
||||
tag = tag,
|
||||
isChecked = tag in selected,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
x
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value)
|
||||
},
|
||||
onFailure = {
|
||||
listOf(it.toErrorState(false))
|
||||
},
|
||||
)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
val content = combine(tags, searchQuery) { raw, query ->
|
||||
raw.filter { x ->
|
||||
@@ -57,15 +60,15 @@ class TagsCatalogViewModel @AssistedInject constructor(
|
||||
|
||||
fun handleTagClick(tag: MangaTag, isChecked: Boolean) {
|
||||
if (isExcluded) {
|
||||
filter.setTagExcluded(tag, !isChecked)
|
||||
filter.toggleTagExclude(tag, !isChecked)
|
||||
} else {
|
||||
filter.setTag(tag, !isChecked)
|
||||
filter.toggleTag(tag, !isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel
|
||||
fun create(filter: FilterCoordinator, isExcludeTag: Boolean): TagsCatalogViewModel
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.ifZero
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
|
||||
fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState(
|
||||
exception = this,
|
||||
|
||||
@@ -28,7 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
@@ -105,11 +105,11 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val tag = data as? MangaTag ?: return
|
||||
val filter = (activity as? FilterOwner)?.filter
|
||||
val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator
|
||||
if (filter == null) {
|
||||
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
|
||||
} else {
|
||||
filter.setTag(tag, true)
|
||||
filter.toggleTag(tag, true)
|
||||
closeSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,18 +25,16 @@ import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
|
||||
import org.koitharu.kotatsu.local.domain.MangaLock
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -53,12 +51,15 @@ class LocalMangaRepository @Inject constructor(
|
||||
override val source = LocalMangaSource
|
||||
private val localMappingCache = LocalMangaMappingCache()
|
||||
|
||||
override val isMultipleTagsSupported: Boolean = true
|
||||
override val isTagsExclusionSupported: Boolean = true
|
||||
override val isSearchSupported: Boolean = true
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities(
|
||||
isMultipleTagsSupported = true,
|
||||
isTagsExclusionSupported = true,
|
||||
isSearchSupported = true,
|
||||
isSearchWithFiltersSupported = true,
|
||||
)
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
|
||||
override val states = emptySet<MangaState>()
|
||||
override val contentRatings = emptySet<ContentRating>()
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = settings.localListOrder
|
||||
@@ -66,7 +67,9 @@ class LocalMangaRepository @Inject constructor(
|
||||
settings.localListOrder = value
|
||||
}
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
override suspend fun getFilterOptions() = MangaListFilterOptions()
|
||||
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
|
||||
if (offset > 0) {
|
||||
return emptyList()
|
||||
}
|
||||
@@ -74,30 +77,25 @@ class LocalMangaRepository @Inject constructor(
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
list.removeIf { it.manga.isNsfw }
|
||||
}
|
||||
when (filter) {
|
||||
is MangaListFilter.Search -> {
|
||||
list.retainAll { x -> x.isMatchesQuery(filter.query) }
|
||||
if (filter != null) {
|
||||
val query = filter.query
|
||||
if (!query.isNullOrEmpty()) {
|
||||
list.retainAll { x -> x.isMatchesQuery(query) }
|
||||
}
|
||||
|
||||
is MangaListFilter.Advanced -> {
|
||||
if (filter.tags.isNotEmpty()) {
|
||||
list.retainAll { x -> x.containsTags(filter.tags) }
|
||||
}
|
||||
if (filter.tagsExclude.isNotEmpty()) {
|
||||
list.removeAll { x -> x.containsAnyTag(filter.tags) }
|
||||
}
|
||||
when (filter.sortOrder) {
|
||||
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
|
||||
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.UPDATED,
|
||||
-> list.sortByDescending { it.createdAt }
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
if (filter.tags.isNotEmpty()) {
|
||||
list.retainAll { x -> x.containsTags(filter.tags) }
|
||||
}
|
||||
if (filter.tagsExclude.isNotEmpty()) {
|
||||
list.removeAll { x -> x.containsAnyTag(filter.tags) }
|
||||
}
|
||||
}
|
||||
when (order) {
|
||||
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
|
||||
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.UPDATED -> list.sortByDescending { it.createdAt }
|
||||
|
||||
null -> Unit
|
||||
else -> Unit
|
||||
}
|
||||
return list.unwrap()
|
||||
}
|
||||
@@ -173,10 +171,6 @@ class LocalMangaRepository @Inject constructor(
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage) = page.url
|
||||
|
||||
override suspend fun getTags() = emptySet<MangaTag>()
|
||||
|
||||
override suspend fun getLocales() = emptySet<Locale>()
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
|
||||
|
||||
suspend fun getOutputDir(manga: Manga): File? {
|
||||
|
||||
@@ -27,7 +27,7 @@ class DeleteLocalMangaUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
suspend operator fun invoke(ids: Set<Long>) {
|
||||
val list = localMangaRepository.getList(0, null)
|
||||
val list = localMangaRepository.getList(0, null, null)
|
||||
var removed = 0
|
||||
for (manga in list) {
|
||||
if (manga.id in ids) {
|
||||
|
||||
@@ -38,7 +38,7 @@ class DeleteReadChaptersUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
suspend operator fun invoke(): Int {
|
||||
val list = localMangaRepository.getList(0, null)
|
||||
val list = localMangaRepository.getList(0, null, null)
|
||||
if (list.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -23,15 +23,14 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
|
||||
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||
|
||||
class LocalListFragment : MangaListFragment(), FilterOwner {
|
||||
class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
|
||||
private val permissionRequestLauncher = registerForActivityResult(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -56,8 +55,8 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
|
||||
|
||||
override val viewModel by viewModels<LocalListViewModel>()
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = viewModel
|
||||
override val filterCoordinator: FilterCoordinator
|
||||
get() = viewModel.filterCoordinator
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.explore.domain.ExploreRepository
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.FilterHeaderProducer
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -32,7 +33,7 @@ import javax.inject.Inject
|
||||
class LocalListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
filter: FilterCoordinator,
|
||||
filterCoordinator: FilterCoordinator,
|
||||
private val settings: AppSettings,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
mangaListMapper: MangaListMapper,
|
||||
@@ -40,11 +41,12 @@ class LocalListViewModel @Inject constructor(
|
||||
exploreRepository: ExploreRepository,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
private val localStorageManager: LocalStorageManager,
|
||||
filterHeaderProducer: FilterHeaderProducer,
|
||||
sourcesRepository: MangaSourcesRepository,
|
||||
) : RemoteListViewModel(
|
||||
savedStateHandle,
|
||||
mangaRepositoryFactory,
|
||||
filter,
|
||||
filterCoordinator,
|
||||
settings,
|
||||
mangaListMapper,
|
||||
downloadScheduler,
|
||||
@@ -58,7 +60,7 @@ class LocalListViewModel @Inject constructor(
|
||||
launchJob(Dispatchers.Default) {
|
||||
localStorageChanges
|
||||
.collect {
|
||||
loadList(filter.snapshot(), append = false).join()
|
||||
loadList(filterCoordinator.snapshot(), append = false).join()
|
||||
}
|
||||
}
|
||||
settings.subscribe(this)
|
||||
|
||||
@@ -62,7 +62,6 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.assertNotNull
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
@@ -452,9 +451,9 @@ class ReaderViewModel @Inject constructor(
|
||||
|
||||
@WorkerThread
|
||||
private fun notifyStateChanged() {
|
||||
val state = getCurrentState().assertNotNull("state") ?: return
|
||||
val chapter = chaptersLoader.peekChapter(state.chapterId).assertNotNull("chapter") ?: return
|
||||
val m = mangaDetails.value.assertNotNull("manga") ?: return
|
||||
val state = getCurrentState() ?: return
|
||||
val chapter = chaptersLoader.peekChapter(state.chapterId) ?: return
|
||||
val m = mangaDetails.value ?: return
|
||||
val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1
|
||||
val newState = ReaderUiState(
|
||||
mangaName = m.toManga().title,
|
||||
|
||||
@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.ifZero
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
|
||||
@@ -11,8 +11,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.ifZero
|
||||
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
|
||||
@@ -25,8 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
@@ -35,12 +34,12 @@ import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RemoteListFragment : MangaListFragment(), FilterOwner {
|
||||
class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
|
||||
override val viewModel by viewModels<RemoteListViewModel>()
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = viewModel
|
||||
override val filterCoordinator: FilterCoordinator
|
||||
get() = viewModel.filterCoordinator
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
@@ -49,7 +48,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
|
||||
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) {
|
||||
startActivity(DetailsActivity.newIntent(binding.root.context, it))
|
||||
}
|
||||
viewModel.header.distinctUntilChangedBy { it.isFilterApplied }
|
||||
filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
|
||||
.drop(1)
|
||||
.observe(viewLifecycleOwner) {
|
||||
activity?.invalidateMenu()
|
||||
@@ -130,7 +129,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_search)?.isVisible = viewModel.isSearchAvailable
|
||||
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value
|
||||
menu.findItem(R.id.action_filter_reset)?.isVisible = viewModel.header.value.isFilterApplied
|
||||
menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
|
||||
@@ -31,7 +31,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.explore.domain.ExploreRepository
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
@@ -52,13 +51,13 @@ private const val FILTER_MIN_INTERVAL = 250L
|
||||
open class RemoteListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val filter: FilterCoordinator,
|
||||
override val filterCoordinator: FilterCoordinator,
|
||||
settings: AppSettings,
|
||||
mangaListMapper: MangaListMapper,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
private val exploreRepository: ExploreRepository,
|
||||
sourcesRepository: MangaSourcesRepository,
|
||||
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter {
|
||||
) : MangaListViewModel(settings, downloadScheduler), FilterCoordinator.Owner {
|
||||
|
||||
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
|
||||
val isRandomLoading = MutableStateFlow(false)
|
||||
@@ -72,7 +71,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
private var randomJob: Job? = null
|
||||
|
||||
val isSearchAvailable: Boolean
|
||||
get() = repository.isSearchSupported
|
||||
get() = repository.filterCapabilities.isSearchSupported
|
||||
|
||||
val browserUrl: String?
|
||||
get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" }
|
||||
@@ -93,7 +92,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
list == null -> add(LoadingState)
|
||||
list.isEmpty() -> add(createEmptyState(canResetFilter = header.value.isFilterApplied))
|
||||
list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied))
|
||||
else -> {
|
||||
mangaListMapper.toListModelList(this, list, mode)
|
||||
when {
|
||||
@@ -107,7 +106,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
|
||||
|
||||
init {
|
||||
filter.observeState()
|
||||
filterCoordinator.observe()
|
||||
.debounce(FILTER_MIN_INTERVAL)
|
||||
.onEach { filterState ->
|
||||
loadingJob?.cancelAndJoin()
|
||||
@@ -123,26 +122,26 @@ open class RemoteListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
loadList(filter.snapshot(), append = false)
|
||||
loadList(filterCoordinator.snapshot(), append = false)
|
||||
}
|
||||
|
||||
override fun onRetry() {
|
||||
loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty())
|
||||
loadList(filterCoordinator.snapshot(), append = !mangaList.value.isNullOrEmpty())
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
if (hasNextPage.value && listError.value == null) {
|
||||
loadList(filter.snapshot(), append = true)
|
||||
loadList(filterCoordinator.snapshot(), append = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilter() = filter.reset()
|
||||
fun resetFilter() = filterCoordinator.reset()
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) {
|
||||
applyFilter(tags)
|
||||
filterCoordinator.set(MangaListFilter(tags = tags))
|
||||
}
|
||||
|
||||
protected fun loadList(filterState: MangaListFilter.Advanced, append: Boolean): Job {
|
||||
protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job {
|
||||
loadingJob?.let {
|
||||
if (it.isActive) return it
|
||||
}
|
||||
@@ -151,7 +150,8 @@ open class RemoteListViewModel @Inject constructor(
|
||||
listError.value = null
|
||||
val list = repository.getList(
|
||||
offset = if (append) mangaList.value.sizeOrZero() else 0,
|
||||
filter = filterState,
|
||||
order = filterState.sortOrder,
|
||||
filter = filterState.listFilter,
|
||||
)
|
||||
val prevList = mangaList.value.orEmpty()
|
||||
if (!append) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
|
||||
|
||||
@@ -125,6 +125,10 @@ class MangaSearchRepository @Inject constructor(
|
||||
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
|
||||
}
|
||||
|
||||
suspend fun getTopTags(source: MangaSource, limit: Int): List<MangaTag> {
|
||||
return db.getTagsDao().findPopularTags(source.name, limit).toMangaTagsList()
|
||||
}
|
||||
|
||||
suspend fun getSourcesSuggestion(limit: Int): List<MangaSource> = sourcesRepository.getTopSources(limit)
|
||||
|
||||
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {
|
||||
|
||||
@@ -36,14 +36,14 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivityMangaListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.list.ui.preview.PreviewFragment
|
||||
import org.koitharu.kotatsu.local.ui.LocalListFragment
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
@@ -53,15 +53,15 @@ import com.google.android.material.R as materialR
|
||||
@AndroidEntryPoint
|
||||
class MangaListActivity :
|
||||
BaseActivity<ActivityMangaListBinding>(),
|
||||
AppBarOwner, View.OnClickListener, FilterOwner, AppBarLayout.OnOffsetChangedListener {
|
||||
AppBarOwner, View.OnClickListener, FilterCoordinator.Owner, AppBarLayout.OnOffsetChangedListener {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override val filter: MangaFilter
|
||||
override val filterCoordinator: FilterCoordinator
|
||||
get() = checkNotNull(findFilterOwner()) {
|
||||
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
|
||||
}.filter
|
||||
"Cannot find FilterCoordinator.Owner fragment in ${supportFragmentManager.fragments}"
|
||||
}.filterCoordinator
|
||||
|
||||
private var source: MangaSource? = null
|
||||
|
||||
@@ -122,7 +122,7 @@ class MangaListActivity :
|
||||
private fun initList(source: MangaSource, tags: Set<MangaTag>?) {
|
||||
val fm = supportFragmentManager
|
||||
val existingFragment = fm.findFragmentById(R.id.container)
|
||||
if (existingFragment is FilterOwner) {
|
||||
if (existingFragment is FilterCoordinator.Owner) {
|
||||
initFilter(existingFragment)
|
||||
} else {
|
||||
fm.commit {
|
||||
@@ -141,7 +141,7 @@ class MangaListActivity :
|
||||
}
|
||||
}
|
||||
|
||||
private fun initFilter(filterOwner: FilterOwner) {
|
||||
private fun initFilter(filterOwner: FilterCoordinator.Owner) {
|
||||
if (viewBinding.containerSide != null) {
|
||||
if (supportFragmentManager.findFragmentById(R.id.container_side) == null) {
|
||||
setSideFragment(FilterSheetFragment::class.java, null)
|
||||
@@ -154,18 +154,18 @@ class MangaListActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = filterOwner.filter
|
||||
val filter = filterOwner.filterCoordinator
|
||||
val chipSort = viewBinding.buttonOrder
|
||||
if (chipSort != null) {
|
||||
val filterBadge = ViewBadge(chipSort, this)
|
||||
filterBadge.setMaxCharacterCount(0)
|
||||
filter.header.observe(this) {
|
||||
chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0)
|
||||
filterBadge.counter = if (it.isFilterApplied) 1 else 0
|
||||
filter.observe().observe(this) { snapshot ->
|
||||
chipSort.setTextAndVisible(snapshot.sortOrder.titleRes)
|
||||
filterBadge.counter = if (snapshot.listFilter.isEmpty()) 0 else 1
|
||||
}
|
||||
} else {
|
||||
filter.header.map {
|
||||
it.textSummary
|
||||
filter.observe().map {
|
||||
it.listFilter.tags.joinToString { tag -> tag.title }
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.observe(this) {
|
||||
supportActionBar?.subtitle = it
|
||||
@@ -173,8 +173,8 @@ class MangaListActivity :
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFilterOwner(): FilterOwner? {
|
||||
return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner
|
||||
private fun findFilterOwner(): FilterCoordinator.Owner? {
|
||||
return supportFragmentManager.findFragmentById(R.id.container) as? FilterCoordinator.Owner
|
||||
}
|
||||
|
||||
private fun setSideFragment(cls: Class<out Fragment>, args: Bundle?) = if (viewBinding.containerSide != null) {
|
||||
@@ -188,12 +188,12 @@ class MangaListActivity :
|
||||
}
|
||||
|
||||
private class ApplyFilterRunnable(
|
||||
private val filterOwner: FilterOwner,
|
||||
private val filterOwner: FilterCoordinator.Owner,
|
||||
private val tags: Set<MangaTag>,
|
||||
) : Runnable {
|
||||
|
||||
override fun run() {
|
||||
filterOwner.filter.applyFilter(tags)
|
||||
filterOwner.filterCoordinator.set(MangaListFilter(tags = tags))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class SearchViewModel @Inject constructor(
|
||||
) : MangaListViewModel(settings, downloadScheduler) {
|
||||
|
||||
private val query = savedStateHandle.require<String>(SearchFragment.ARG_QUERY)
|
||||
private val repository = repositoryFactory.create(MangaSource(savedStateHandle.get(SearchFragment.ARG_SOURCE)))
|
||||
private val repository = repositoryFactory.create(MangaSource(savedStateHandle[SearchFragment.ARG_SOURCE]))
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val hasNextPage = MutableStateFlow(false)
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
@@ -105,7 +105,8 @@ class SearchViewModel @Inject constructor(
|
||||
listError.value = null
|
||||
val list = repository.getList(
|
||||
offset = if (append) mangaList.value.sizeOrZero() else 0,
|
||||
filter = MangaListFilter.Search(query),
|
||||
order = null,
|
||||
filter = MangaListFilter(query = query),
|
||||
)
|
||||
val prevList = mangaList.value.orEmpty()
|
||||
if (!append) {
|
||||
|
||||
@@ -116,14 +116,14 @@ class MultiSearchViewModel @Inject constructor(
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
sources.mapNotNull { source ->
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.isSearchSupported) {
|
||||
if (!repository.filterCapabilities.isSearchSupported) {
|
||||
null
|
||||
} else {
|
||||
launch {
|
||||
val item = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
mangaListMapper.toListModelList(
|
||||
manga = repository.getList(offset = 0, filter = MangaListFilter.Search(q)),
|
||||
manga = repository.getList(offset = 0, null, MangaListFilter(query = q)),
|
||||
mode = ListMode.GRID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,15 +250,14 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
val availableOrders = repository.sortOrders
|
||||
val order = preferredSortOrders.first { it in availableOrders }
|
||||
val availableTags = repository.getTags()
|
||||
val availableTags = repository.getFilterOptions().availableTags
|
||||
val tag = tags.firstNotNullOfOrNull { title ->
|
||||
availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) }
|
||||
}
|
||||
val list = repository.getList(
|
||||
offset = 0,
|
||||
filter = MangaListFilter.Advanced.Builder(order)
|
||||
.tags(setOfNotNull(tag))
|
||||
.build(),
|
||||
order = order,
|
||||
filter = MangaListFilter(tags = setOfNotNull(tag))
|
||||
).asArrayList()
|
||||
if (appSettings.isSuggestionsExcludeNsfw) {
|
||||
list.removeAll { it.isNsfw }
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<Spinner
|
||||
android:id="@+id/spinner_order"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_height="@dimen/spinner_height"
|
||||
android:minHeight="?listPreferredItemHeightSmall"
|
||||
android:paddingHorizontal="8dp" />
|
||||
|
||||
@@ -62,9 +62,11 @@
|
||||
android:layout_marginTop="12dp"
|
||||
android:baselineAligned="false"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone"
|
||||
android:weightSum="2"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
app:singleSelection="true"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_order_asc"
|
||||
@@ -109,7 +111,38 @@
|
||||
<Spinner
|
||||
android:id="@+id/spinner_locale"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_height="@dimen/spinner_height"
|
||||
android:minHeight="?listPreferredItemHeightSmall"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:popupBackground="@drawable/m3_spinner_popup_background" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_original_locale_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:singleLine="true"
|
||||
android:text="@string/original_language"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_original_locale"
|
||||
style="?materialCardViewOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_original_locale"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/spinner_height"
|
||||
android:minHeight="?listPreferredItemHeightSmall"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:popupBackground="@drawable/m3_spinner_popup_background" />
|
||||
@@ -211,6 +244,28 @@
|
||||
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_year"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:singleLine="true"
|
||||
android:text="@string/year"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/slider_year"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:stepSize="1"
|
||||
android:visibility="gone"
|
||||
app:labelBehavior="visible"
|
||||
app:tickVisible="true"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<dimen name="side_card_offset">8dp</dimen>
|
||||
<dimen name="webtoon_pages_gap">24dp</dimen>
|
||||
<dimen name="details_bs_peek_height">92dp</dimen>
|
||||
<dimen name="spinner_height">56dp</dimen>
|
||||
|
||||
<dimen name="search_suggestions_manga_height">142dp</dimen>
|
||||
<dimen name="search_suggestions_manga_spacing">6dp</dimen>
|
||||
|
||||
@@ -707,4 +707,16 @@
|
||||
<string name="no_fix_required">No fix required for \"%s\"</string>
|
||||
<string name="no_alternatives_found">No alternatives found for \"%s\"</string>
|
||||
<string name="manga_fix_prompt">This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background</string>
|
||||
<string name="content_type_novel">Novel</string>
|
||||
<string name="content_type_manhua">Manhua</string>
|
||||
<string name="content_type_manhwa">Manhwa</string>
|
||||
<string name="recently_added">Recently added</string>
|
||||
<string name="added_long_ago">Added long ago</string>
|
||||
<string name="popular_in_hour">Popular this hour</string>
|
||||
<string name="popular_today">Popular today</string>
|
||||
<string name="popular_in_week">Popular this week</string>
|
||||
<string name="popular_in_month">Popular this month</string>
|
||||
<string name="popular_in_year">Popular this year</string>
|
||||
<string name="original_language">Original language</string>
|
||||
<string name="year">Year</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user