Feat: Add Saved Filters Feature

devel
MuhamadSyabitHidayattulloh 7 months ago committed by Koitharu
parent a66283d035
commit 5fb8ff53f9

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

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

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

@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.setValuesRounded import org.koitharu.kotatsu.core.util.ext.setValuesRounded
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterCoordinator 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.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType 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.model.YEAR_UNKNOWN
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.parsers.util.toIntUp
import java.util.Locale import java.util.Locale
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import android.widget.EditText
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(), class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener, AdapterView.OnItemSelectedListener,
@ -46,6 +49,69 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
return SheetFilterBinding.inflate(inflater, container, false) 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?) { override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null) { if (dialog == null) {
@ -89,6 +155,38 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.layoutGenresExclude.setOnMoreButtonClickListener { binding.layoutGenresExclude.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = true) 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 { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {

@ -13,6 +13,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:title="@string/filter" /> app:title="@string/filter" />
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
android:id="@+id/scrollView" android:id="@+id/scrollView"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -29,6 +31,23 @@
android:paddingHorizontal="@dimen/margin_small" android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_normal"> 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 <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_order" android:id="@+id/layout_order"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -254,4 +273,15 @@
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </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> </LinearLayout>

@ -46,6 +46,7 @@
<string name="by_rating">Rating</string> <string name="by_rating">Rating</string>
<string name="sort_order">Sorting order</string> <string name="sort_order">Sorting order</string>
<string name="filter">Filter</string> <string name="filter">Filter</string>
<string name="saved_filters">Saved filters</string>
<string name="theme">Theme</string> <string name="theme">Theme</string>
<string name="light">Light</string> <string name="light">Light</string>
<string name="dark">Dark</string> <string name="dark">Dark</string>
@ -208,6 +209,7 @@
<string name="enabled">Enabled</string> <string name="enabled">Enabled</string>
<string name="disabled">Disabled</string> <string name="disabled">Disabled</string>
<string name="reset_filter">Reset filter</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="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="never">Never</string>
<string name="only_using_wifi">Only on Wi-Fi</string> <string name="only_using_wifi">Only on Wi-Fi</string>

Loading…
Cancel
Save