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

@ -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.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 currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() }
val capabilities = repository.filterCapabilities
val mangaSource: MangaSource
get() = repository.source
val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty()
val query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty(
availableItems = availableSortOrders.sortedByOrdinal(),
selectedItem = selected,
)
}.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 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 states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.states },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableStates.sortedByOrdinal(),
selectedItems = selected.states,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.contentRating },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentRating.sortedByOrdinal(),
selectedItems = selected.contentRating,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.types },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentTypes.sortedByOrdinal(),
selectedItems = selected.types,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.demographics },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableDemographics.sortedByOrdinal(),
selectedItems = selected.demographics,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val locale: StateFlow<FilterProperty<Locale?>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.locale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.locale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.originalLocale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.originalLocale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.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)
}
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 reset() {
currentListFilter.value = MangaListFilter.EMPTY
}
fun snapshot() = Snapshot(
sortOrder = currentSortOrder.value,
listFilter = currentListFilter.value,
)
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
repository.defaultSortOrder = newSortOrder
}
fun set(value: MangaListFilter) {
currentListFilter.value = value
}
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 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 setLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
locale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setAuthor(value: String?) {
currentListFilter.update { oldValue ->
oldValue.copy(
author = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setOriginalLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
originalLocale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setYear(value: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
year = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setYearRange(valueFrom: Int, valueTo: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
yearFrom = valueFrom,
yearTo = valueTo,
query = oldValue.takeQueryIfSupported(),
)
}
}
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 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 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 currentPresetId = MutableStateFlow<Long?>(null)
private var lastAppliedPayload: JSONObject? = null
private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() }
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 capabilities = repository.filterCapabilities
val mangaSource: MangaSource
get() = repository.source
val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty()
val query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty(
availableItems = availableSortOrders.sortedByOrdinal(),
selectedItem = selected,
)
}.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 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 states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.states },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableStates.sortedByOrdinal(),
selectedItems = selected.states,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.contentRating },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentRating.sortedByOrdinal(),
selectedItems = selected.contentRating,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.types },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentTypes.sortedByOrdinal(),
selectedItems = selected.types,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.demographics },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableDemographics.sortedByOrdinal(),
selectedItems = selected.demographics,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val locale: StateFlow<FilterProperty<Locale?>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.locale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.locale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.originalLocale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.originalLocale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.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)
}
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 savedPresets: StateFlow<List<SavedFiltersRepository.Preset>> =
savedFiltersRepository.observe(repository.source.unwrap().name)
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
val selectedPresetId: StateFlow<Long?> = currentPresetId
fun reset() {
currentListFilter.value = MangaListFilter.EMPTY
}
fun snapshot() = Snapshot(
sortOrder = currentSortOrder.value,
listFilter = currentListFilter.value,
)
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
repository.defaultSortOrder = newSortOrder
}
fun set(value: MangaListFilter) {
currentListFilter.value = value
}
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 saveCurrentPreset(name: String) {
val preset = savedFiltersRepository.save(repository.source.unwrap().name, name, currentListFilter.value)
currentPresetId.value = preset.id
lastAppliedPayload = preset.payload
}
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 renamePreset(id: Long, newName: String) {
savedFiltersRepository.rename(repository.source.unwrap().name, id, newName)
}
fun deletePreset(id: Long) {
savedFiltersRepository.delete(repository.source.unwrap().name, id)
if (currentPresetId.value == id) {
currentPresetId.value = null
lastAppliedPayload = null
}
}
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 setLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
locale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setAuthor(value: String?) {
currentListFilter.update { oldValue ->
oldValue.copy(
author = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setOriginalLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
originalLocale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setYear(value: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
year = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setYearRange(valueFrom: Int, valueTo: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
yearFrom = valueFrom,
yearTo = valueTo,
query = oldValue.takeQueryIfSupported(),
)
}
}
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 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)

@ -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 {

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

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

Loading…
Cancel
Save