Excluded tags and content rating in filter

remotes/Isira-Seneviratne/devel
Koitharu 2 years ago
parent 3f2e32dcc2
commit 2f2a5b868d
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -82,7 +82,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:ea095084cc') { implementation('com.github.KotatsuApp:kotatsu-parsers:b274b51699') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }

@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
private val context: Context, private val context: Context,
@ -58,6 +59,10 @@ class CaptchaNotifier(
manager.notify(TAG, exception.source.hashCode(), notification) manager.notify(TAG, exception.source.hashCode(), notification)
} }
fun dismiss(source: MangaSource) {
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
}
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
val e = result.throwable val e = result.throwable

@ -31,6 +31,16 @@ abstract class TagsDao {
) )
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity> abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) ASC
LIMIT :limit""",
)
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
@Query( @Query(
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id

@ -7,6 +7,7 @@ import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -56,6 +57,14 @@ val MangaState.iconResId: Int
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
} }
@get:StringRes
val ContentRating.titleResId: Int
get() = when (this) {
ContentRating.SAFE -> R.string.rating_safe
ContentRating.SUGGESTIVE -> R.string.rating_suggestive
ContentRating.ADULT -> R.string.rating_adult
}
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id) return chapters?.findById(id)
} }

@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -28,10 +29,16 @@ interface MangaRepository {
val states: Set<MangaState> val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean val isMultipleTagsSupported: Boolean
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga

@ -21,6 +21,7 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@ -49,6 +50,9 @@ class RemoteMangaRepository(
override val states: Set<MangaState> override val states: Set<MangaState>
get() = parser.availableStates get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first() get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) { set(value) {
@ -58,6 +62,12 @@ class RemoteMangaRepository(
override val isMultipleTagsSupported: Boolean override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String var domain: String
get() = parser.domain get() = parser.domain
set(value) { set(value) {

@ -76,12 +76,9 @@ class ExploreRepository @Inject constructor(
} }
val list = repository.getList( val list = repository.getList(
offset = 0, offset = 0,
filter = MangaListFilter.Advanced( filter = MangaListFilter.Advanced.Builder(order)
sortOrder = order, .tags(setOfNotNull(tag))
tags = setOfNotNull(tag), .build(),
locale = null,
states = emptySet(),
),
).asArrayList() ).asArrayList()
if (settings.isSuggestionsExcludeNsfw) { if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }

@ -41,6 +41,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@ -67,11 +68,28 @@ class FilterCoordinator @Inject constructor(
private val coroutineScope = lifecycle.lifecycleScope private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val currentState = MutableStateFlow( private val currentState = MutableStateFlow(
MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()), MangaListFilter.Advanced(
sortOrder = repository.defaultSortOrder,
tags = emptySet(),
tagsExclude = emptySet(),
locale = null,
states = emptySet(),
contentRating = emptySet(),
),
) )
private val localTags = SuspendLazy { private val localTags = SuspendLazy {
dataRepository.findTags(repository.source) 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 availableTagsDeferred = loadTagsAsync()
private var availableLocalesDeferred = loadLocalesAsync() private var availableLocalesDeferred = loadLocalesAsync()
private var allTagsLoadJob: Job? = null private var allTagsLoadJob: Job? = null
@ -96,6 +114,22 @@ class FilterCoordinator @Inject constructor(
) )
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (repository.isTagsExclusionSupported) {
combine(
currentState.distinctUntilChangedBy { it.tagsExclude },
getBottomTagsAsFlow(4),
) { state, tags ->
FilterProperty(
availableItems = tags.items.asArrayList(),
selectedItems = state.tagsExclude,
isLoading = tags.isLoading,
error = tags.error,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
} else {
MutableStateFlow(emptyProperty())
}
override val filterSortOrder: StateFlow<FilterProperty<SortOrder>> = combine( override val filterSortOrder: StateFlow<FilterProperty<SortOrder>> = combine(
currentState.distinctUntilChangedBy { it.sortOrder }, currentState.distinctUntilChangedBy { it.sortOrder },
flowOf(repository.sortOrders), flowOf(repository.sortOrders),
@ -120,6 +154,18 @@ class FilterCoordinator @Inject constructor(
) )
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterContentRating: StateFlow<FilterProperty<ContentRating>> = combine(
currentState.distinctUntilChangedBy { it.contentRating },
flowOf(repository.contentRatings),
) { rating, ratings ->
FilterProperty(
availableItems = ratings.sortedBy { it.ordinal },
selectedItems = rating.contentRating,
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine( override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine(
currentState.distinctUntilChangedBy { it.locale }, currentState.distinctUntilChangedBy { it.locale },
getLocalesAsFlow(), getLocalesAsFlow(),
@ -187,7 +233,32 @@ class FilterCoordinator @Inject constructor(
emptySet() emptySet()
} }
} }
oldValue.copy(tags = newTags) oldValue.copy(
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
)
}
}
override fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) {
if (addOrRemove) {
oldValue.tagsExclude + value
} else {
oldValue.tagsExclude - value
}
} else {
if (addOrRemove) {
setOf(value)
} else {
emptySet()
}
}
oldValue.copy(
tagsExclude = newTags,
tags = oldValue.tags - newTags
)
} }
} }
@ -202,6 +273,17 @@ class FilterCoordinator @Inject constructor(
} }
} }
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) { override fun onListHeaderClick(item: ListHeader, view: View) {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy( oldValue.copy(
@ -224,13 +306,16 @@ class FilterCoordinator @Inject constructor(
fun setTags(tags: Set<MangaTag>) { fun setTags(tags: Set<MangaTag>) {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy(tags = tags) oldValue.copy(
tags = tags,
tagsExclude = oldValue.tagsExclude - tags
)
} }
} }
fun reset() { fun reset() {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy(oldValue.sortOrder, emptySet(), null, emptySet()) MangaListFilter.Advanced.Builder(oldValue.sortOrder).build()
} }
} }
@ -248,17 +333,6 @@ class FilterCoordinator @Inject constructor(
) )
} }
private fun getTagsAsFlow() = 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))
}
}
private fun getLocalesAsFlow(): Flow<PendingData<Locale>> = flow { private fun getLocalesAsFlow(): Flow<PendingData<Locale>> = flow {
emit(PendingData(emptySet(), isLoading = true, error = null)) emit(PendingData(emptySet(), isLoading = true, error = null))
tryLoadLocales() tryLoadLocales()
@ -277,7 +351,18 @@ class FilterCoordinator @Inject constructor(
searchRepository.getTagsSuggestion(it).take(limit) searchRepository.getTagsSuggestion(it).take(limit)
} }
}, },
getTagsAsFlow(), 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 -> ) { suggested, all ->
val res = suggested.toMutableList() val res = suggested.toMutableList()
if (res.size < limit) { if (res.size < limit) {
@ -411,6 +496,8 @@ class FilterCoordinator @Inject constructor(
private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null) private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null)
private fun <T> emptyProperty() = FilterProperty<T>(emptyList(), emptySet(), false, null)
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> { private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
private val collator = lc?.let { Collator.getInstance(Locale(it)) } private val collator = lc?.let { Collator.getInstance(Locale(it)) }

@ -37,7 +37,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag val tag = data as? MangaTag
if (tag == null) { if (tag == null) {
TagsCatalogSheet.show(parentFragmentManager) TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false)
} else { } else {
filter.setTag(tag, chip.isChecked) filter.setTag(tag, chip.isChecked)
} }

@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.list.ui.model.ListModel 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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -15,10 +16,14 @@ interface MangaFilter : OnFilterChangedListener {
val filterTags: StateFlow<FilterProperty<MangaTag>> val filterTags: StateFlow<FilterProperty<MangaTag>>
val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>>
val filterSortOrder: StateFlow<FilterProperty<SortOrder>> val filterSortOrder: StateFlow<FilterProperty<SortOrder>>
val filterState: StateFlow<FilterProperty<MangaState>> val filterState: StateFlow<FilterProperty<MangaState>>
val filterContentRating: StateFlow<FilterProperty<ContentRating>>
val filterLocale: StateFlow<FilterProperty<Locale?>> val filterLocale: StateFlow<FilterProperty<Locale?>>
val header: StateFlow<FilterHeaderModel> val header: StateFlow<FilterHeaderModel>

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener 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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -14,5 +15,9 @@ interface OnFilterChangedListener : ListHeaderClickListener {
fun setTag(value: MangaTag, addOrRemove: Boolean) fun setTag(value: MangaTag, addOrRemove: Boolean)
fun setTagExcluded(value: MangaTag, addOrRemove: Boolean)
fun setState(value: MangaState, addOrRemove: Boolean) fun setState(value: MangaState, addOrRemove: Boolean)
fun setContentRating(value: ContentRating, addOrRemove: Boolean)
} }

@ -18,12 +18,14 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -31,8 +33,9 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class FilterSheetFragment : class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
BaseAdaptiveSheet<SheetFilterBinding>(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener { AdapterView.OnItemSelectedListener,
ChipsView.OnChipClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false) return SheetFilterBinding.inflate(inflater, container, false)
@ -50,12 +53,16 @@ class FilterSheetFragment :
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged) filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged) filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged) filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
binding.spinnerLocale.onItemSelectedListener = this binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this binding.chipsState.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
} }
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
@ -72,8 +79,14 @@ class FilterSheetFragment :
val filter = requireFilter() val filter = requireFilter()
when (data) { when (data) {
is MangaState -> filter.setState(data, chip.isChecked) is MangaState -> filter.setState(data, chip.isChecked)
is MangaTag -> filter.setTag(data, chip.isChecked) is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
null -> TagsCatalogSheet.show(childFragmentManager) filter.setTagExcluded(data, chip.isChecked)
} else {
filter.setTag(data, chip.isChecked)
}
is ContentRating -> filter.setContentRating(data, chip.isChecked)
null -> TagsCatalogSheet.show(childFragmentManager, chip.parentView?.id == R.id.chips_genresExclude)
} }
} }
@ -166,6 +179,51 @@ class FilterSheetFragment :
b.chipsGenres.setChips(chips) b.chipsGenres.setChips(chips)
} }
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.textViewGenresExcludeTitle.isGone = value.isEmpty()
b.chipsGenresExclude.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
value.selectedItems.mapTo(chips) { tag ->
ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = true,
data = tag,
)
}
value.availableItems.mapNotNullTo(chips) { tag ->
if (tag !in value.selectedItems) {
ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = false,
data = tag,
)
} else {
null
}
}
chips.add(
ChipsView.ChipModel(
tint = 0,
title = getString(R.string.more),
icon = materialR.drawable.abc_ic_menu_overflow_material,
isCheckable = false,
isChecked = false,
data = null,
),
)
b.chipsGenresExclude.setChips(chips)
}
private fun onStateChanged(value: FilterProperty<MangaState>) { private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.textViewStateTitle.isGone = value.isEmpty() b.textViewStateTitle.isGone = value.isEmpty()
@ -186,6 +244,26 @@ class FilterSheetFragment :
b.chipsState.setChips(chips) b.chipsState.setChips(chips)
} }
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
val b = viewBinding ?: return
b.textViewContentRatingTitle.isGone = value.isEmpty()
b.chipsContentRating.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { contentRating ->
ChipsView.ChipModel(
tint = 0,
title = getString(contentRating.titleResId),
icon = 0,
isCheckable = true,
isChecked = contentRating in value.selectedItems,
data = contentRating,
)
}
b.chipsContentRating.setChips(chips)
}
private fun requireFilter() = (requireActivity() as FilterOwner).filter private fun requireFilter() = (requireActivity() as FilterOwner).filter
companion object { companion object {

@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetTagsBinding import org.koitharu.kotatsu.databinding.SheetTagsBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@ -30,7 +31,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
private val viewModel by viewModels<TagsCatalogViewModel>( private val viewModel by viewModels<TagsCatalogViewModel>(
extrasProducer = { extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory -> defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
factory.create((requireActivity() as FilterOwner).filter) factory.create(
filter = (requireActivity() as FilterOwner).filter,
isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE),
)
} }
}, },
) )
@ -54,8 +58,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
} }
override fun onItemClick(item: TagCatalogItem, view: View) { override fun onItemClick(item: TagCatalogItem, view: View) {
val filter = (requireActivity() as FilterOwner).filter viewModel.handleTagClick(item.tag, item.isChecked)
filter.setTag(item.tag, !item.isChecked)
} }
override fun onFocusChange(v: View?, hasFocus: Boolean) { override fun onFocusChange(v: View?, hasFocus: Boolean) {
@ -90,7 +93,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
companion object { companion object {
private const val TAG = "TagsCatalogSheet" private const val TAG = "TagsCatalogSheet"
private const val ARG_EXCLUDE = "exclude"
fun show(fm: FragmentManager) = TagsCatalogSheet().showDistinct(fm, TAG) fun show(fm: FragmentManager, isExcludeTag: Boolean) = TagsCatalogSheet().withArgs(1) {
putBoolean(ARG_EXCLUDE, isExcludeTag)
}.showDistinct(fm, TAG)
} }
} }

@ -8,29 +8,32 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.MangaTag
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
class TagsCatalogViewModel @AssistedInject constructor( class TagsCatalogViewModel @AssistedInject constructor(
@Assisted filter: MangaFilter, @Assisted private val filter: MangaFilter,
mangaRepositoryFactory: MangaRepository.Factory, @Assisted private val isExcluded: Boolean,
dataRepository: MangaDataRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val searchQuery = MutableStateFlow("") val searchQuery = MutableStateFlow("")
private val filterProperty: StateFlow<FilterProperty<MangaTag>>
get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags
private val tags = combine( private val tags = combine(
filter.allTags, filter.allTags,
filter.filterTags.map { it.selectedItems }, filterProperty.map { it.selectedItems },
) { all, selected -> ) { all, selected ->
all.map { x -> all.map { x ->
if (x is TagCatalogItem) { if (x is TagCatalogItem) {
@ -52,9 +55,17 @@ class TagsCatalogViewModel @AssistedInject constructor(
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
fun handleTagClick(tag: MangaTag, isChecked: Boolean) {
if (isExcluded) {
filter.setTagExcluded(tag, !isChecked)
} else {
filter.setTag(tag, !isChecked)
}
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(filter: MangaFilter): TagsCatalogViewModel fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel
} }
} }

@ -24,6 +24,7 @@ import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -52,8 +53,11 @@ class LocalMangaRepository @Inject constructor(
private val locks = CompositeMutex2<Long>() private val locks = CompositeMutex2<Long>()
override val isMultipleTagsSupported: Boolean = true override val isMultipleTagsSupported: Boolean = true
override val isTagsExclusionSupported: Boolean = true
override val isSearchSupported: Boolean = true
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
override val states = emptySet<MangaState>() override val states = emptySet<MangaState>()
override val contentRatings = emptySet<ContentRating>()
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = settings.localListOrder get() = settings.localListOrder
@ -75,6 +79,9 @@ class LocalMangaRepository @Inject constructor(
if (filter.tags.isNotEmpty()) { if (filter.tags.isNotEmpty()) {
list.retainAll { x -> x.containsTags(filter.tags) } list.retainAll { x -> x.containsTags(filter.tags) }
} }
if (filter.tagsExclude.isNotEmpty()) {
list.removeAll { x -> x.containsAnyTag(filter.tags) }
}
when (filter.sortOrder) { when (filter.sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating } SortOrder.RATING -> list.sortByDescending { it.manga.rating }

@ -30,6 +30,12 @@ data class LocalManga(
return manga.tags.containsAll(tags) return manga.tags.containsAll(tags)
} }
fun containsAnyTag(tags: Set<MangaTag>): Boolean {
return tags.any { tag ->
manga.tags.contains(tag)
}
}
override fun toString(): String { override fun toString(): String {
return "LocalManga(${file.path}: ${manga.title})" return "LocalManga(${file.path}: ${manga.title})"
} }

@ -111,6 +111,10 @@ class MangaSearchRepository @Inject constructor(
} }
} }
suspend fun getRareTags(source: MangaSource, limit: Int): List<MangaTag> {
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
}
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> { fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {
if (query.length < 3) { if (query.length < 3) {
return emptyList() return emptyList()

@ -211,12 +211,9 @@ class SuggestionsWorker @AssistedInject constructor(
} }
val list = repository.getList( val list = repository.getList(
offset = 0, offset = 0,
filter = MangaListFilter.Advanced( filter = MangaListFilter.Advanced.Builder(order)
sortOrder = order, .tags(setOfNotNull(tag))
tags = setOfNotNull(tag), .build(),
locale = null,
states = setOf(),
),
).asArrayList() ).asArrayList()
if (appSettings.isSuggestionsExcludeNsfw) { if (appSettings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }

@ -126,6 +126,28 @@
tools:text="@string/error_multiple_genres_not_supported" tools:text="@string/error_multiple_genres_not_supported"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView
android:id="@+id/textView_genresExclude_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:singleLine="true"
android:text="@string/genres_exclude"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_genresExclude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:paddingHorizontal="@dimen/margin_normal"
android:visibility="gone"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/textView_state_title" android:id="@+id/textView_state_title"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -148,6 +170,28 @@
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView
android:id="@+id/textView_contentRating_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:singleLine="true"
android:text="@string/content_rating"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_contentRating"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:paddingHorizontal="@dimen/margin_normal"
android:visibility="gone"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
tools:visibility="visible" />
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</LinearLayout> </LinearLayout>

@ -550,4 +550,9 @@
<string name="backup_date_">Backup date: %s</string> <string name="backup_date_">Backup date: %s</string>
<string name="state_upcoming">Upcoming</string> <string name="state_upcoming">Upcoming</string>
<string name="by_name_reverse">Name reversed</string> <string name="by_name_reverse">Name reversed</string>
<string name="content_rating">Content rating</string>
<string name="genres_exclude">Exclude genres</string>
<string name="rating_safe">Safe</string>
<string name="rating_suggestive">Suggestive</string>
<string name="rating_adult">Adult</string>
</resources> </resources>

Loading…
Cancel
Save