diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index 4b4c33540..8d7ea96f0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -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 + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt new file mode 100644 index 000000000..eee0451da --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt @@ -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>>(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> = MutableStateFlow(state.value[source].orEmpty()).also { out -> + scope.launch { + state.collect { all -> out.value = all[source].orEmpty() } + } + } + + fun list(source: String): List = 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) { + 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>() + 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) -> Set, + ): 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 = buildSet { + for (i in 0 until length()) { + val v = optString(i) + if (!v.isNullOrEmpty()) add(v) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index c42cbc52e..791265a88 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -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(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 = currentListFilter.map { it.query } - .stateIn(coroutineScope, SharingStarted.Eagerly, null) + val mangaSource: MangaSource + get() = repository.source - val sortOrder: StateFlow> = 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> = 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 = currentListFilter.map { it.query } + .stateIn(coroutineScope, SharingStarted.Eagerly, null) - val tagsExcluded: StateFlow> = 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> = currentSortOrder.map { selected -> + FilterProperty( + availableItems = availableSortOrders.sortedByOrdinal(), + selectedItem = selected, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) - val states: StateFlow> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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 = combine(currentSortOrder, currentListFilter, ::Snapshot) + val savedPresets: StateFlow> = + savedFiltersRepository.observe(repository.source.unwrap().name) + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - fun setSortOrder(newSortOrder: SortOrder) { - currentSortOrder.value = newSortOrder - repository.defaultSortOrder = newSortOrder - } + val selectedPresetId: StateFlow = 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 = 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) -> Set = { 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>> = 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>> = combine( - flow { emit(searchRepository.getTopTags(repository.source, limit)) }, - filterOptions.asFlow(), - ) { suggested, options -> - val all = options.getOrNull()?.availableTags.orEmpty() - val result = ArrayList(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>> = combine( - flow { emit(searchRepository.getRareTags(repository.source, limit)) }, - filterOptions.asFlow(), - ) { suggested, options -> - val all = options.getOrNull()?.availableTags.orEmpty() - val result = ArrayList(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>> = 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>> = combine( + flow { emit(searchRepository.getTopTags(repository.source, limit)) }, + filterOptions.asFlow(), + ) { suggested, options -> + val all = options.getOrNull()?.availableTags.orEmpty() + val result = ArrayList(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>> = combine( + flow { emit(searchRepository.getRareTags(repository.source, limit)) }, + filterOptions.asFlow(), + ) { suggested, options -> + val all = options.getOrNull()?.availableTags.orEmpty() + val result = ArrayList(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 List.addFirstDistinct(other: Collection): List { val result = ArrayDeque(this.size + other.size) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index 1a82afe3a..7f7849a4d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -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(), AdapterView.OnItemSelectedListener, @@ -46,6 +49,69 @@ class FilterSheetFragment : BaseAdaptiveSheet(), return SheetFilterBinding.inflate(inflater, container, false) } + private fun onSavedPresetsChanged(list: List, 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(), 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 { diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml index 75f3817c3..a151fd9eb 100644 --- a/app/src/main/res/layout/sheet_filter.xml +++ b/app/src/main/res/layout/sheet_filter.xml @@ -13,6 +13,8 @@ android:layout_height="wrap_content" app:title="@string/filter" /> + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c58709c79..6b63231d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,6 +46,7 @@ Rating Sorting order Filter + Saved filters Theme Light Dark @@ -208,6 +209,7 @@ Enabled Disabled Reset filter + Enter name Select languages which you want to read manga. You can change it later in settings. Never Only on Wi-Fi