diff --git a/app/build.gradle b/app/build.gradle index 649b199a8..403e8e5ae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -82,7 +82,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:ea095084cc') { + implementation('com.github.KotatsuApp:kotatsu-parsers:b274b51699') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt index 11a2a47c7..2a61414e3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaSource class CaptchaNotifier( private val context: Context, @@ -58,6 +59,10 @@ class CaptchaNotifier( 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) { super.onError(request, result) val e = result.throwable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt index beded29b3..baf63c241 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt @@ -31,6 +31,16 @@ abstract class TagsDao { ) abstract suspend fun findPopularTags(source: String, limit: Int): List + @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 + @Query( """SELECT tags.* FROM tags LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index a7edb01d3..d2b579b10 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -7,6 +7,7 @@ import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.iterator 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.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource @@ -56,6 +57,14 @@ val MangaState.iconResId: Int 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? { return chapters?.findById(id) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt index 4b5e3c906..8971392d8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor 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 @@ -28,10 +29,16 @@ interface MangaRepository { val states: Set + val contentRatings: Set + var defaultSortOrder: SortOrder val isMultipleTagsSupported: Boolean + val isTagsExclusionSupported: Boolean + + val isSearchSupported: Boolean + suspend fun getList(offset: Int, filter: MangaListFilter?): List suspend fun getDetails(manga: Manga): Manga diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index a3bdb3a5e..217d38572 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -21,6 +21,7 @@ import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.config.ConfigKey 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.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -49,6 +50,9 @@ class RemoteMangaRepository( override val states: Set get() = parser.availableStates + override val contentRatings: Set + get() = parser.availableContentRating + override var defaultSortOrder: SortOrder get() = getConfig().defaultSortOrder ?: sortOrders.first() set(value) { @@ -58,6 +62,12 @@ class RemoteMangaRepository( 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) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index ce30bcd85..9a473aac6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -76,12 +76,9 @@ class ExploreRepository @Inject constructor( } val list = repository.getList( offset = 0, - filter = MangaListFilter.Advanced( - sortOrder = order, - tags = setOfNotNull(tag), - locale = null, - states = emptySet(), - ), + filter = MangaListFilter.Advanced.Builder(order) + .tags(setOfNotNull(tag)) + .build(), ).asArrayList() if (settings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index dec244837..ae467b8af 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -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.LoadingState 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.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag @@ -67,11 +68,28 @@ class FilterCoordinator @Inject constructor( private val coroutineScope = lifecycle.lifecycleScope private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) 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 { 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 @@ -96,6 +114,22 @@ class FilterCoordinator @Inject constructor( ) }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + override val filterTagsExcluded: StateFlow> = 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> = combine( currentState.distinctUntilChangedBy { it.sortOrder }, flowOf(repository.sortOrders), @@ -120,6 +154,18 @@ class FilterCoordinator @Inject constructor( ) }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + override val filterContentRating: StateFlow> = 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> = combine( currentState.distinctUntilChangedBy { it.locale }, getLocalesAsFlow(), @@ -187,7 +233,32 @@ class FilterCoordinator @Inject constructor( 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) { currentState.update { oldValue -> oldValue.copy( @@ -224,13 +306,16 @@ class FilterCoordinator @Inject constructor( fun setTags(tags: Set) { currentState.update { oldValue -> - oldValue.copy(tags = tags) + oldValue.copy( + tags = tags, + tagsExclude = oldValue.tagsExclude - tags + ) } } fun reset() { 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> = flow { emit(PendingData(emptySet(), isLoading = true, error = null)) tryLoadLocales() @@ -277,7 +351,18 @@ class FilterCoordinator @Inject constructor( 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> = combine( + flow { emit(searchRepository.getRareTags(repository.source, limit)) }, + tagsFlow, ) { suggested, all -> val res = suggested.toMutableList() if (res.size < limit) { @@ -411,6 +496,8 @@ class FilterCoordinator @Inject constructor( private fun loadingProperty() = FilterProperty(emptyList(), emptySet(), true, null) + private fun emptyProperty() = FilterProperty(emptyList(), emptySet(), false, null) + private class TagTitleComparator(lc: String?) : Comparator { private val collator = lc?.let { Collator.getInstance(Locale(it)) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index 18bbc62c4..546c3892a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -37,7 +37,7 @@ class FilterHeaderFragment : BaseFragment(), ChipsV override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag if (tag == null) { - TagsCatalogSheet.show(parentFragmentManager) + TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) } else { filter.setTag(tag, chip.isChecked) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt index f5f54f682..26fcf6fab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.StateFlow 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 org.koitharu.kotatsu.parsers.model.SortOrder @@ -15,10 +16,14 @@ interface MangaFilter : OnFilterChangedListener { val filterTags: StateFlow> + val filterTagsExcluded: StateFlow> + val filterSortOrder: StateFlow> val filterState: StateFlow> + val filterContentRating: StateFlow> + val filterLocale: StateFlow> val header: StateFlow diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt index 136c60f05..785f32ec6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt @@ -1,6 +1,7 @@ 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 @@ -14,5 +15,9 @@ interface OnFilterChangedListener : ListHeaderClickListener { fun setTag(value: MangaTag, addOrRemove: Boolean) + fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) + fun setState(value: MangaState, addOrRemove: Boolean) + + fun setContentRating(value: ContentRating, addOrRemove: Boolean) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index 28e13931a..ac9bb8b27 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -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.util.ext.getDisplayMessage 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.textAndVisible import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.filter.ui.FilterOwner 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 @@ -31,8 +33,9 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase import java.util.Locale import com.google.android.material.R as materialR -class FilterSheetFragment : - BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener { +class FilterSheetFragment : BaseAdaptiveSheet(), + AdapterView.OnItemSelectedListener, + ChipsView.OnChipClickListener { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) @@ -50,12 +53,16 @@ class FilterSheetFragment : filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) 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) binding.spinnerLocale.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this binding.chipsState.onChipClickListener = this + binding.chipsContentRating.onChipClickListener = this binding.chipsGenres.onChipClickListener = this + binding.chipsGenresExclude.onChipClickListener = this } override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { @@ -72,8 +79,14 @@ class FilterSheetFragment : val filter = requireFilter() when (data) { is MangaState -> filter.setState(data, chip.isChecked) - is MangaTag -> filter.setTag(data, chip.isChecked) - null -> TagsCatalogSheet.show(childFragmentManager) + is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { + 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) } + private fun onTagsExcludedChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewGenresExcludeTitle.isGone = value.isEmpty() + b.chipsGenresExclude.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + val chips = ArrayList(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) { val b = viewBinding ?: return b.textViewStateTitle.isGone = value.isEmpty() @@ -186,6 +244,26 @@ class FilterSheetFragment : b.chipsState.setChips(chips) } + private fun onContentRatingChanged(value: FilterProperty) { + 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 companion object { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt index da03be958..dca92f60c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt @@ -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.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.model.TagCatalogItem @@ -30,7 +31,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL private val viewModel by viewModels( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { 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(), OnListItemClickL } override fun onItemClick(item: TagCatalogItem, view: View) { - val filter = (requireActivity() as FilterOwner).filter - filter.setTag(item.tag, !item.isChecked) + viewModel.handleTagClick(item.tag, item.isChecked) } override fun onFocusChange(v: View?, hasFocus: Boolean) { @@ -90,7 +93,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL companion object { 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) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt index 01dbf3741..092c6950c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt @@ -8,29 +8,32 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn 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.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.parsers.model.MangaTag @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) class TagsCatalogViewModel @AssistedInject constructor( - @Assisted filter: MangaFilter, - mangaRepositoryFactory: MangaRepository.Factory, - dataRepository: MangaDataRepository, + @Assisted private val filter: MangaFilter, + @Assisted private val isExcluded: Boolean, ) : BaseViewModel() { val searchQuery = MutableStateFlow("") + private val filterProperty: StateFlow> + get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags + private val tags = combine( filter.allTags, - filter.filterTags.map { it.selectedItems }, + filterProperty.map { it.selectedItems }, ) { all, selected -> all.map { x -> if (x is TagCatalogItem) { @@ -52,9 +55,17 @@ class TagsCatalogViewModel @AssistedInject constructor( } }.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 interface Factory { - fun create(filter: MangaFilter): TagsCatalogViewModel + fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index e7e32de3f..9bf2ff6c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -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.LocalMangaUtil 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 @@ -52,8 +53,11 @@ class LocalMangaRepository @Inject constructor( private val locks = CompositeMutex2() override val isMultipleTagsSupported: Boolean = true + override val isTagsExclusionSupported: Boolean = true + override val isSearchSupported: Boolean = true override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) override val states = emptySet() + override val contentRatings = emptySet() override var defaultSortOrder: SortOrder get() = settings.localListOrder @@ -75,6 +79,9 @@ class LocalMangaRepository @Inject constructor( 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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt index 0714be953..1f23d304f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt @@ -30,6 +30,12 @@ data class LocalManga( return manga.tags.containsAll(tags) } + fun containsAnyTag(tags: Set): Boolean { + return tags.any { tag -> + manga.tags.contains(tag) + } + } + override fun toString(): String { return "LocalManga(${file.path}: ${manga.title})" } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index 48a79dbb2..e2b474ee4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -111,6 +111,10 @@ class MangaSearchRepository @Inject constructor( } } + suspend fun getRareTags(source: MangaSource, limit: Int): List { + return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList() + } + fun getSourcesSuggestion(query: String, limit: Int): List { if (query.length < 3) { return emptyList() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 166050e20..50fbd8398 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -211,12 +211,9 @@ class SuggestionsWorker @AssistedInject constructor( } val list = repository.getList( offset = 0, - filter = MangaListFilter.Advanced( - sortOrder = order, - tags = setOfNotNull(tag), - locale = null, - states = setOf(), - ), + filter = MangaListFilter.Advanced.Builder(order) + .tags(setOfNotNull(tag)) + .build(), ).asArrayList() if (appSettings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw } diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml index 369a77766..44506b22f 100644 --- a/app/src/main/res/layout/sheet_filter.xml +++ b/app/src/main/res/layout/sheet_filter.xml @@ -126,6 +126,28 @@ tools:text="@string/error_multiple_genres_not_supported" tools:visibility="visible" /> + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e77f2c4da..e193d6036 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -550,4 +550,9 @@ Backup date: %s Upcoming Name reversed + Content rating + Exclude genres + Safe + Suggestive + Adult