Feat: Add Saved Filters Feature

This commit is contained in:
MuhamadSyabitHidayattulloh
2025-10-16 14:25:28 +07:00
committed by Koitharu
parent a66283d035
commit 5fb8ff53f9
6 changed files with 723 additions and 371 deletions

View File

@@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
val data = it.tag
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
}
private val chipOnLongClickListener = OnLongClickListener {
val chip = it as Chip
val data = it.tag
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
}
private val chipStyle: Int
private val iconsVisible: Boolean
var onChipClickListener: OnChipClickListener? = null
@@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
}
var onChipCloseClickListener: OnChipCloseClickListener? = null
var onChipLongClickListener: OnChipLongClickListener? = null
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
@@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
setOnCloseIconClickListener(chipOnCloseListener)
setEnsureMinTouchTargetSize(false)
setOnClickListener(chipOnClickListener)
setOnLongClickListener(chipOnLongClickListener)
isElegantTextHeight = false
}
@@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
fun onChipCloseClick(chip: Chip, data: Any?)
}
fun interface OnChipLongClickListener {
fun onChipLongClick(chip: Chip, data: Any?): Boolean
}
}

View File

@@ -0,0 +1,152 @@
package org.koitharu.kotatsu.filter.data
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
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.MangaTag
import org.koitharu.kotatsu.parsers.model.MangaState
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import android.content.Context
@Singleton
class SavedFiltersRepository @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val scope = CoroutineScope(Dispatchers.Default)
private val keyRoot = "saved_filters_v1"
private val state = MutableStateFlow<Map<String, List<Preset>>>(emptyMap())
init {
scope.launch { loadAll() }
}
data class Preset(
val id: Long,
val name: String,
val source: String,
val payload: JSONObject,
)
fun observe(source: String): StateFlow<List<Preset>> = MutableStateFlow(state.value[source].orEmpty()).also { out ->
scope.launch {
state.collect { all -> out.value = all[source].orEmpty() }
}
}
fun list(source: String): List<Preset> = state.value[source].orEmpty()
fun save(source: String, name: String, filter: MangaListFilter): Preset {
val nowId = System.currentTimeMillis()
val preset = Preset(
id = nowId,
name = name,
source = source,
payload = serializeFilter(filter),
)
val list = list(source) + preset
persist(source, list)
return preset
}
fun rename(source: String, id: Long, newName: String) {
val list = list(source).map { if (it.id == id) it.copy(name = newName) else it }
persist(source, list)
}
fun delete(source: String, id: Long) {
val list = list(source).filterNot { it.id == id }
persist(source, list)
}
private fun persist(source: String, list: List<Preset>) {
val root = JSONObject(prefs.getString(keyRoot, "{}"))
root.put(source, JSONArray(list.map { presetToJson(it) }))
prefs.edit { putString(keyRoot, root.toString()) }
state.value = state.value.toMutableMap().also { it[source] = list }
}
private fun loadAll() {
val root = JSONObject(prefs.getString(keyRoot, "{}"))
val map = mutableMapOf<String, List<Preset>>()
for (key in root.keys()) {
val arr = root.optJSONArray(key) ?: continue
map[key] = (0 until arr.length()).mapNotNull { i -> jsonToPreset(arr.optJSONObject(i), key) }
}
state.value = map
}
private fun presetToJson(p: Preset): JSONObject = JSONObject().apply {
put("id", p.id)
put("name", p.name)
put("payload", p.payload)
}
private fun jsonToPreset(obj: JSONObject?, source: String): Preset? {
obj ?: return null
val id = obj.optLong("id", 0L)
val name = obj.optString("name", null) ?: return null
val payload = obj.optJSONObject("payload") ?: return null
return Preset(id, name, source, payload)
}
fun serializeFilter(f: MangaListFilter): JSONObject = JSONObject().apply {
put("query", f.query)
put("author", f.author)
put("locale", f.locale?.toLanguageTag())
put("originalLocale", f.originalLocale?.toLanguageTag())
put("states", JSONArray(f.states.map { it.name }))
put("contentRating", JSONArray(f.contentRating.map { it.name }))
put("types", JSONArray(f.types.map { it.name }))
put("demographics", JSONArray(f.demographics.map { it.name }))
put("tags", JSONArray(f.tags.map { it.key }))
put("tagsExclude", JSONArray(f.tagsExclude.map { it.key }))
put("year", f.year)
put("yearFrom", f.yearFrom)
put("yearTo", f.yearTo)
}
fun deserializeFilter(
obj: JSONObject,
resolveTags: (Set<String>) -> Set<MangaTag>,
): MangaListFilter {
return MangaListFilter(
query = obj.optString("query").takeIf { it.isNotEmpty() },
author = obj.optString("author").takeIf { it.isNotEmpty() },
locale = obj.optString("locale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
originalLocale = obj.optString("originalLocale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
states = obj.optJSONArray("states")?.toStringSet()?.mapNotNull { runCatching { MangaState.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
contentRating = obj.optJSONArray("contentRating")?.toStringSet()?.mapNotNull { runCatching { ContentRating.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
types = obj.optJSONArray("types")?.toStringSet()?.mapNotNull { runCatching { ContentType.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
demographics = obj.optJSONArray("demographics")?.toStringSet()?.mapNotNull { runCatching { Demographic.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
tags = resolveTags(obj.optJSONArray("tags")?.toStringSet().orEmpty()).toSet(),
tagsExclude = resolveTags(obj.optJSONArray("tagsExclude")?.toStringSet().orEmpty()).toSet(),
year = obj.optInt("year"),
yearFrom = obj.optInt("yearFrom"),
yearTo = obj.optInt("yearTo"),
)
}
}
private fun JSONArray.toStringSet(): Set<String> = buildSet {
for (i in 0 until length()) {
val v = optString(i)
if (!v.isNullOrEmpty()) add(v)
}
}

View File

@@ -15,16 +15,20 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.model.unwrap
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.asFlow
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -42,423 +46,476 @@ import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.json.JSONObject
import java.util.Calendar
import java.util.Locale
import javax.inject.Inject
@ViewModelScoped
class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle,
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
private val searchRepository: MangaSearchRepository,
private val savedFiltersRepository: SavedFiltersRepository,
lifecycle: ViewModelLifecycle,
) {
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val currentPresetId = MutableStateFlow<Long?>(null)
private var lastAppliedPayload: JSONObject? = null
private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() }
val capabilities = repository.filterCapabilities
private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() }
val mangaSource: MangaSource
get() = repository.source
init {
coroutineScope.launch {
currentListFilter.collect { lf ->
val applied = lastAppliedPayload
if (applied != null) {
val cur = savedFiltersRepository.serializeFilter(lf)
if (cur.toString() != applied.toString()) {
currentPresetId.value = null
lastAppliedPayload = null
}
}
}
}
}
val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty()
val capabilities = repository.filterCapabilities
val query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val mangaSource: MangaSource
get() = repository.source
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty(
availableItems = availableSortOrders.sortedByOrdinal(),
selectedItem = selected,
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty()
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 query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
combine(
getBottomTags(TAGS_LIMIT),
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.addFirstDistinct(selected.tagsExclude),
selectedItems = selected.tagsExclude,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty(
availableItems = availableSortOrders.sortedByOrdinal(),
selectedItem = selected,
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
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 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 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 tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
combine(
getBottomTags(TAGS_LIMIT),
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.addFirstDistinct(selected.tagsExclude),
selectedItems = selected.tagsExclude,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val 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 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 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 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 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 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 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, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
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 year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
FilterProperty(
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.year),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
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 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.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
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, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
fun reset() {
currentListFilter.value = MangaListFilter.EMPTY
}
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
FilterProperty(
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.year),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
fun snapshot() = Snapshot(
sortOrder = currentSortOrder.value,
listFilter = currentListFilter.value,
)
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.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
val savedPresets: StateFlow<List<SavedFiltersRepository.Preset>> =
savedFiltersRepository.observe(repository.source.unwrap().name)
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
repository.defaultSortOrder = newSortOrder
}
val selectedPresetId: StateFlow<Long?> = currentPresetId
fun set(value: MangaListFilter) {
currentListFilter.value = value
}
fun reset() {
currentListFilter.value = MangaListFilter.EMPTY
}
fun setAdjusted(value: MangaListFilter) {
var newFilter = value
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
newFilter = newFilter.copy(
query = newFilter.author,
author = null,
)
}
if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) {
newFilter = newFilter.copy(
query = null,
)
}
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
newFilter = MangaListFilter(query = newFilter.query)
}
set(newFilter)
}
fun snapshot() = Snapshot(
sortOrder = currentSortOrder.value,
listFilter = currentListFilter.value,
)
fun setQuery(value: String?) {
val newQuery = value?.trim()?.nullIfEmpty()
currentListFilter.update { oldValue ->
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
oldValue.copy(query = newQuery)
} else {
MangaListFilter(query = newQuery)
}
}
}
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
fun setLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
locale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
repository.defaultSortOrder = newSortOrder
}
fun setAuthor(value: String?) {
currentListFilter.update { oldValue ->
oldValue.copy(
author = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun set(value: MangaListFilter) {
currentListFilter.value = value
}
fun setOriginalLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
originalLocale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setAdjusted(value: MangaListFilter) {
var newFilter = value
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
newFilter = newFilter.copy(
query = newFilter.author,
author = null,
)
}
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
newFilter = MangaListFilter(query = newFilter.query)
}
set(newFilter)
}
fun setYear(value: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
year = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun saveCurrentPreset(name: String) {
val preset = savedFiltersRepository.save(repository.source.unwrap().name, name, currentListFilter.value)
currentPresetId.value = preset.id
lastAppliedPayload = preset.payload
}
fun setYearRange(valueFrom: Int, valueTo: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
yearFrom = valueFrom,
yearTo = valueTo,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun applyPreset(preset: SavedFiltersRepository.Preset) {
coroutineScope.launch {
val available = filterOptions.asFlow().map { it.getOrNull()?.availableTags.orEmpty() }.first()
val byKey: (Set<String>) -> Set<MangaTag> = { keys ->
val all = available.associateBy { it.key }
keys.mapNotNull { all[it] }.toSet()
}
val filter = savedFiltersRepository.deserializeFilter(preset.payload, byKey)
setAdjusted(filter)
currentPresetId.value = preset.id
lastAppliedPayload = preset.payload
}
}
fun toggleState(value: MangaState, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
states = if (isSelected) oldValue.states + value else oldValue.states - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun renamePreset(id: Long, newName: String) {
savedFiltersRepository.rename(repository.source.unwrap().name, id, newName)
}
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun deletePreset(id: Long) {
savedFiltersRepository.delete(repository.source.unwrap().name, id)
if (currentPresetId.value == id) {
currentPresetId.value = null
lastAppliedPayload = null
}
}
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setQuery(value: String?) {
val newQuery = value?.trim()?.nullIfEmpty()
currentListFilter.update { oldValue ->
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
oldValue.copy(query = newQuery)
} else {
MangaListFilter(query = newQuery)
}
}
}
fun toggleContentType(value: ContentType, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
types = if (isSelected) oldValue.types + value else oldValue.types - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
locale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
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 (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setAuthor(value: String?) {
currentListFilter.update { oldValue ->
oldValue.copy(
author = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
} else {
if (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = oldValue.tags - newTagsExclude,
tagsExclude = newTagsExclude,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setOriginalLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
originalLocale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
}
fun setYear(value: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
year = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
private fun MangaListFilter.takeQueryIfSupported() = when {
capabilities.isSearchWithFiltersSupported -> query
query.isNullOrEmpty() -> query
hasNonSearchOptions() -> null
else -> query
}
fun setYearRange(valueFrom: Int, valueTo: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
yearFrom = valueFrom,
yearTo = valueTo,
query = oldValue.takeQueryIfSupported(),
)
}
}
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getTopTags(repository.source, limit)) },
filterOptions.asFlow(),
) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
}
if (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
fun toggleState(value: MangaState, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
states = if (isSelected) oldValue.states + value else oldValue.states - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
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 (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleContentType(value: ContentType, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
types = if (isSelected) oldValue.types + value else oldValue.types - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
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 (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
} else {
if (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = oldValue.tags - newTagsExclude,
tagsExclude = newTagsExclude,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
}
private fun MangaListFilter.takeQueryIfSupported() = when {
capabilities.isSearchWithFiltersSupported -> query
query.isNullOrEmpty() -> query
hasNonSearchOptions() -> null
else -> query
}
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getTopTags(repository.source, limit)) },
filterOptions.asFlow(),
) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
}
if (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
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 (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
val result = ArrayDeque<T>(this.size + other.size)

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.setValuesRounded
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
@@ -37,6 +38,8 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
import org.koitharu.kotatsu.parsers.util.toIntUp
import java.util.Locale
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import android.widget.EditText
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener,
@@ -46,6 +49,69 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
return SheetFilterBinding.inflate(inflater, container, false)
}
private fun onSavedPresetsChanged(list: List<SavedFiltersRepository.Preset>, selectedId: Long?) {
val b = viewBinding ?: return
if (list.isEmpty()) {
b.layoutSavedFilters.isGone = true
b.chipsSavedFilters.setChips(emptyList())
return
}
b.layoutSavedFilters.isGone = false
val chips = list.map { p ->
ChipsView.ChipModel(
title = p.name,
isChecked = p.id == selectedId,
data = p,
)
}
b.chipsSavedFilters.setChips(chips)
}
private fun promptPresetName(onSubmit: (String) -> Unit) {
val ctx = requireContext()
val input = EditText(ctx)
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.enter_name)
.setView(input)
.setPositiveButton(R.string.save) { d, _ ->
val text = input.text?.toString()?.trim()
if (!text.isNullOrEmpty()) onSubmit(text)
d.dismiss()
}
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.show()
}
private fun showPresetOptions(filter: FilterCoordinator, preset: SavedFiltersRepository.Preset) {
val ctx = requireContext()
val items = arrayOf(getString(R.string.edit), getString(R.string.delete))
MaterialAlertDialogBuilder(ctx)
.setItems(items) { d, which ->
when (which) {
0 -> promptRename(filter, preset)
1 -> filter.deletePreset(preset.id)
}
d.dismiss()
}
.show()
}
private fun promptRename(filter: FilterCoordinator, preset: SavedFiltersRepository.Preset) {
val ctx = requireContext()
val input = EditText(ctx)
input.setText(preset.name)
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.edit)
.setView(input)
.setPositiveButton(R.string.save) { d, _ ->
val text = input.text?.toString()?.trim()
if (!text.isNullOrEmpty()) filter.renamePreset(preset.id, text)
d.dismiss()
}
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.show()
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null) {
@@ -89,6 +155,38 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.layoutGenresExclude.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = true)
}
binding.chipsSavedFilters.onChipClickListener = ChipsView.OnChipClickListener { chip, data ->
when (data) {
is SavedFiltersRepository.Preset -> filter.applyPreset(data)
}
}
binding.chipsSavedFilters.onChipLongClickListener = ChipsView.OnChipLongClickListener { chip, data ->
when (data) {
is SavedFiltersRepository.Preset -> {
showPresetOptions(filter, data)
true
}
else -> false
}
}
filter.savedPresets.observe(viewLifecycleOwner) { list ->
val selectedId = filter.selectedPresetId.value
onSavedPresetsChanged(list, selectedId)
}
filter.selectedPresetId.observe(viewLifecycleOwner) { selectedId ->
onSavedPresetsChanged(filter.savedPresets.value, selectedId)
}
filter.observe().observe(viewLifecycleOwner) {
binding.buttonSaveFilter.isEnabled = filter.isFilterApplied
}
binding.buttonSaveFilter.setOnClickListener {
promptPresetName { name ->
filter.saveCurrentPreset(name)
}
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {

View File

@@ -13,6 +13,8 @@
android:layout_height="wrap_content"
app:title="@string/filter" />
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
@@ -29,6 +31,23 @@
android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_normal">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_saved_filters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/saved_filters">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_saved_filters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_order"
android:layout_width="match_parent"
@@ -254,4 +273,15 @@
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/button_save_filter"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginBottom="@dimen/margin_normal"
android:text="@string/save"
android:enabled="false" />
</LinearLayout>

View File

@@ -46,6 +46,7 @@
<string name="by_rating">Rating</string>
<string name="sort_order">Sorting order</string>
<string name="filter">Filter</string>
<string name="saved_filters">Saved filters</string>
<string name="theme">Theme</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
@@ -208,6 +209,7 @@
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="reset_filter">Reset filter</string>
<string name="enter_name">Enter name</string>
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
<string name="never">Never</string>
<string name="only_using_wifi">Only on Wi-Fi</string>