Improve saved filters

devel
Koitharu 6 months ago
parent e35521f16f
commit 1181860e41
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -4,7 +4,7 @@ root = true
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
indent_size = 4 indent_size = 4
indent_style = tab indent_style = space
insert_final_newline = true insert_final_newline = true
max_line_length = 120 max_line_length = 120
tab_width = 4 tab_width = 4

@ -1,9 +1,7 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS"> <option name="OTHER_INDENT_OPTIONS">
<value> <value />
<option name="USE_TAB_CHARACTER" value="true" />
</value>
</option> </option>
<AndroidXmlCodeStyleSettings> <AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS"> <option name="LAYOUT_SETTINGS">
@ -22,40 +20,46 @@
</value> </value>
</option> </option>
</AndroidXmlCodeStyleSettings> </AndroidXmlCodeStyleSettings>
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" /> <option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="CMake">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Groovy">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script"> <codeStyleSettings language="Shell Script">
<indentOptions> <indentOptions>
<option name="USE_TAB_CHARACTER" value="true" /> <option name="USE_TAB_CHARACTER" value="true" />
@ -64,7 +68,6 @@
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">
<indentOptions> <indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" /> <option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions> </indentOptions>
<arrangement> <arrangement>
<rules> <rules>
@ -179,9 +182,6 @@
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" /> <option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" /> <option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" /> <option name="LINE_COMMENT_ADD_SPACE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings> </codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

@ -0,0 +1,20 @@
package org.koitharu.kotatsu.core.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.serialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.koitharu.kotatsu.parsers.model.MangaSource
object MangaSourceSerializer : KSerializer<MangaSource> {
override val descriptor: SerialDescriptor = serialDescriptor<String>()
override fun serialize(
encoder: Encoder,
value: MangaSource
) = encoder.encodeString(value.name)
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
}

@ -2,10 +2,16 @@ package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.inputmethod.EditorInfo
import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.FrameLayout
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.UiContext import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -66,3 +72,27 @@ fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapte
recyclerView.adapter = adapter recyclerView.adapter = adapter
setView(recyclerView) setView(recyclerView)
} }
fun <B : AlertDialog.Builder> B.setEditText(
inputType: Int,
singleLine: Boolean,
): EditText {
val editText = AppCompatEditText(context)
editText.inputType = inputType
if (singleLine) {
editText.setSingleLine()
editText.imeOptions = EditorInfo.IME_ACTION_DONE
}
val layout = FrameLayout(context)
val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding)
lp.setMargins(
horizontalMargin,
context.resources.getDimensionPixelOffset(R.dimen.margin_small),
horizontalMargin,
0,
)
layout.addView(editText, lp)
setView(layout)
return editText
}

@ -0,0 +1,161 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
import kotlinx.serialization.serializer
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.util.Locale
object MangaListFilterSerializer : KSerializer<MangaListFilter> {
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor(MangaListFilter::class.java.name) {
element<String?>("query", isOptional = true)
element(
elementName = "tags",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element(
elementName = "tagsExclude",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element<String?>("locale", isOptional = true)
element<String?>("originalLocale", isOptional = true)
element<Set<MangaState>>("states", isOptional = true)
element<Set<ContentRating>>("contentRating", isOptional = true)
element<Set<ContentType>>("types", isOptional = true)
element<Set<Demographic>>("demographics", isOptional = true)
element<Int>("year", isOptional = true)
element<Int>("yearFrom", isOptional = true)
element<Int>("yearTo", isOptional = true)
element<String?>("author", isOptional = true)
}
override fun serialize(
encoder: Encoder,
value: MangaListFilter
) = encoder.encodeStructure(descriptor) {
encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.query)
encodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer), value.tags)
encodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer), value.tagsExclude)
encodeNullableSerializableElement(descriptor, 3, String.serializer(), value.locale?.toLanguageTag())
encodeNullableSerializableElement(descriptor, 4, String.serializer(), value.originalLocale?.toLanguageTag())
encodeSerializableElement(descriptor, 5, SetSerializer(serializer()), value.states)
encodeSerializableElement(descriptor, 6, SetSerializer(serializer()), value.contentRating)
encodeSerializableElement(descriptor, 7, SetSerializer(serializer()), value.types)
encodeSerializableElement(descriptor, 8, SetSerializer(serializer()), value.demographics)
encodeIntElement(descriptor, 9, value.year)
encodeIntElement(descriptor, 10, value.yearFrom)
encodeIntElement(descriptor, 11, value.yearTo)
encodeNullableSerializableElement(descriptor, 12, String.serializer(), value.author)
}
override fun deserialize(
decoder: Decoder
): MangaListFilter = decoder.decodeStructure(descriptor) {
var query: String? = MangaListFilter.EMPTY.query
var tags: Set<MangaTag> = MangaListFilter.EMPTY.tags
var tagsExclude: Set<MangaTag> = MangaListFilter.EMPTY.tagsExclude
var locale: Locale? = MangaListFilter.EMPTY.locale
var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale
var states: Set<MangaState> = MangaListFilter.EMPTY.states
var contentRating: Set<ContentRating> = MangaListFilter.EMPTY.contentRating
var types: Set<ContentType> = MangaListFilter.EMPTY.types
var demographics: Set<Demographic> = MangaListFilter.EMPTY.demographics
var year: Int = MangaListFilter.EMPTY.year
var yearFrom: Int = MangaListFilter.EMPTY.yearFrom
var yearTo: Int = MangaListFilter.EMPTY.yearTo
var author: String? = MangaListFilter.EMPTY.author
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> query = decodeNullableSerializableElement(descriptor, 0, serializer<String>())
1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer))
2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer))
3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer<String>())?.toLocaleOrNull()
4 -> originalLocale =
decodeNullableSerializableElement(descriptor, 4, serializer<String>())?.toLocaleOrNull()
5 -> states = decodeSerializableElement(descriptor, 5, SetSerializer(serializer()))
6 -> contentRating = decodeSerializableElement(descriptor, 6, SetSerializer(serializer()))
7 -> types = decodeSerializableElement(descriptor, 7, SetSerializer(serializer()))
8 -> demographics = decodeSerializableElement(descriptor, 8, SetSerializer(serializer()))
9 -> year = decodeIntElement(descriptor, 9)
10 -> yearFrom = decodeIntElement(descriptor, 10)
11 -> yearTo = decodeIntElement(descriptor, 11)
12 -> author = decodeNullableSerializableElement(descriptor, 12, serializer<String>())
CompositeDecoder.DECODE_DONE -> break
}
}
MangaListFilter(
query = query,
tags = tags,
tagsExclude = tagsExclude,
locale = locale,
originalLocale = originalLocale,
states = states,
contentRating = contentRating,
types = types,
demographics = demographics,
year = year,
yearFrom = yearFrom,
yearTo = yearTo,
author = author,
)
}
private object MangaTagSerializer : KSerializer<MangaTag> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) {
element<String>("title")
element<String>("key")
element<String>("source")
}
override fun serialize(encoder: Encoder, value: MangaTag) = encoder.encodeStructure(descriptor) {
encodeStringElement(descriptor, 0, value.title)
encodeStringElement(descriptor, 1, value.key)
encodeStringElement(descriptor, 2, value.source.name)
}
override fun deserialize(decoder: Decoder): MangaTag = decoder.decodeStructure(descriptor) {
var title: String? = null
var key: String? = null
var source: String? = null
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> title = decodeStringElement(descriptor, 0)
1 -> key = decodeStringElement(descriptor, 1)
2 -> source = decodeStringElement(descriptor, 2)
CompositeDecoder.DECODE_DONE -> break
}
}
MangaTag(
title = title ?: error("Missing 'title' field"),
key = key ?: error("Missing 'key' field"),
source = MangaSource(source),
)
}
}
}

@ -0,0 +1,30 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import org.koitharu.kotatsu.core.model.MangaSourceSerializer
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Serializable
@JsonIgnoreUnknownKeys
data class PersistableFilter(
@SerialName("name")
val name: String,
@Serializable(with = MangaSourceSerializer::class)
@SerialName("source")
val source: MangaSource,
@Serializable(with = MangaListFilterSerializer::class)
@SerialName("filter")
val filter: MangaListFilter,
) {
val id: Int
get() = filter.hashCode()
companion object {
const val MAX_TITLE_LENGTH = 18
}
}

@ -1,152 +1,99 @@
package org.koitharu.kotatsu.filter.data package org.koitharu.kotatsu.filter.data
import android.content.SharedPreferences import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.flowOn
import org.json.JSONArray import kotlinx.coroutines.flow.map
import org.json.JSONObject import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.parsers.model.ContentRating import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.model.ContentType import kotlinx.serialization.SerializationException
import org.koitharu.kotatsu.parsers.model.Demographic import kotlinx.serialization.json.Json
import org.koitharu.kotatsu.core.util.ext.observeChanges
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
import android.content.Context
@Singleton @Reusable
class SavedFiltersRepository @Inject constructor( class SavedFiltersRepository @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext private val 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 { fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
val nowId = System.currentTimeMillis() .onStart { emit(null) }
val preset = Preset( .map {
id = nowId, getAll(source)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
keys.mapNotNull { key ->
val value = prefs.getString(key, null) ?: return@mapNotNull null
try {
Json.decodeFromString(value)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
}
suspend fun save(
source: MangaSource,
name: String,
filter: MangaListFilter,
): PersistableFilter = withContext(Dispatchers.Default) {
val persistableFilter = PersistableFilter(
name = name, name = name,
source = source, source = source,
payload = serializeFilter(filter), filter = filter,
) )
val list = list(source) + preset persist(source, persistableFilter)
persist(source, list) persistableFilter
return preset
} }
fun rename(source: String, id: Long, newName: String) { suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
val list = list(source).map { if (it.id == id) it.copy(name = newName) else it } val filter = load(source, id) ?: return@withContext
persist(source, list) persist(source, filter.copy(name = newName))
} }
fun delete(source: String, id: Long) { suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
val list = list(source).filterNot { it.id == id } val prefs = getPrefs(source)
persist(source, list) prefs.edit(commit = true) {
remove(FILTER_PREFIX + id)
} }
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() { private fun persist(source: MangaSource, persistableFilter: PersistableFilter) {
val root = JSONObject(prefs.getString(keyRoot, "{}")) val prefs = getPrefs(source)
val map = mutableMapOf<String, List<Preset>>() val json = Json.encodeToString(persistableFilter)
for (key in root.keys()) { prefs.edit(commit = true) {
val arr = root.optJSONArray(key) ?: continue putString(FILTER_PREFIX + persistableFilter.id, json)
map[key] = (0 until arr.length()).mapNotNull { i -> jsonToPreset(arr.optJSONObject(i), key) }
} }
state.value = map
} }
private fun presetToJson(p: Preset): JSONObject = JSONObject().apply { private fun load(source: MangaSource, id: Int): PersistableFilter? {
put("id", p.id) val prefs = getPrefs(source)
put("name", p.name) val json = prefs.getString(FILTER_PREFIX + id, null) ?: return null
put("payload", p.payload) return try {
Json.decodeFromString<PersistableFilter>(json)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
} }
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 { private fun getPrefs(source: MangaSource) = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
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( private companion object {
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 { const val FILTER_PREFIX = "__pf_"
for (i in 0 until length()) {
val v = optString(i)
if (!v.isNullOrEmpty()) add(v)
} }
} }

@ -15,19 +15,18 @@ 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.launch
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.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository 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
@ -46,7 +45,6 @@ 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
@ -66,27 +64,10 @@ class FilterCoordinator @Inject constructor(
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 var lastAppliedPayload: JSONObject? = null
private val availableSortOrders = repository.sortOrders private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() } 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 capabilities = repository.filterCapabilities
val mangaSource: MangaSource val mangaSource: MangaSource
@ -273,11 +254,15 @@ class FilterCoordinator @Inject constructor(
MutableStateFlow(FilterProperty.EMPTY) MutableStateFlow(FilterProperty.EMPTY)
} }
val savedPresets: StateFlow<List<SavedFiltersRepository.Preset>> = val savedFilters: StateFlow<FilterProperty<PersistableFilter>> = combine(
savedFiltersRepository.observe(repository.source.unwrap().name) savedFiltersRepository.observeAll(repository.source),
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) currentListFilter,
) { available, applied ->
val selectedPresetId: StateFlow<Long?> = currentPresetId FilterProperty(
availableItems = available,
selectedItems = setOfNotNull(available.find { it.filter == applied }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY)
fun reset() { fun reset() {
currentListFilter.value = MangaListFilter.EMPTY currentListFilter.value = MangaListFilter.EMPTY
@ -313,36 +298,16 @@ class FilterCoordinator @Inject constructor(
set(newFilter) set(newFilter)
} }
fun saveCurrentPreset(name: String) { fun saveCurrentFilter(name: String) = coroutineScope.launch {
val preset = savedFiltersRepository.save(repository.source.unwrap().name, name, currentListFilter.value) savedFiltersRepository.save(repository.source, 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) { fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
savedFiltersRepository.rename(repository.source.unwrap().name, id, newName) savedFiltersRepository.rename(repository.source, id, newName)
} }
fun deletePreset(id: Long) { fun deleteSavedFilter(id: Int) = coroutineScope.launch {
savedFiltersRepository.delete(repository.source.unwrap().name, id) savedFiltersRepository.delete(repository.source, id)
if (currentPresetId.value == id) {
currentPresetId.value = null
lastAppliedPayload = null
}
} }
fun setQuery(value: String?) { fun setQuery(value: String?) {

@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
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
@ -54,6 +55,12 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
when (data) { when (data) {
is MangaTag -> filter.toggleTag(data, !chip.isChecked) is MangaTag -> filter.toggleTag(data, !chip.isChecked)
is PersistableFilter -> if (chip.isChecked) {
filter.reset()
} else {
filter.setAdjusted(data.filter)
}
is String -> Unit is String -> Unit
null -> router.showTagsCatalogSheet(excludeMode = false) null -> router.showTagsCatalogSheet(excludeMode = false)
} }

@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -21,10 +22,15 @@ class FilterHeaderProducer @Inject constructor(
) { ) {
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> { fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot -> return combine(
filterCoordinator.savedFilters,
filterCoordinator.tags,
filterCoordinator.observe(),
) { saved, tags, snapshot ->
val chipList = createChipsList( val chipList = createChipsList(
source = filterCoordinator.mangaSource, source = filterCoordinator.mangaSource,
capabilities = filterCoordinator.capabilities, capabilities = filterCoordinator.capabilities,
savedFilters = saved,
tagsProperty = tags, tagsProperty = tags,
snapshot = snapshot.listFilter, snapshot = snapshot.listFilter,
limit = 12, limit = 12,
@ -40,11 +46,12 @@ class FilterHeaderProducer @Inject constructor(
private suspend fun createChipsList( private suspend fun createChipsList(
source: MangaSource, source: MangaSource,
capabilities: MangaListFilterCapabilities, capabilities: MangaListFilterCapabilities,
savedFilters: FilterProperty<PersistableFilter>,
tagsProperty: FilterProperty<MangaTag>, tagsProperty: FilterProperty<MangaTag>,
snapshot: MangaListFilter, snapshot: MangaListFilter,
limit: Int, limit: Int,
): List<ChipsView.ChipModel> { ): List<ChipsView.ChipModel> {
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3) val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3)
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
val selectedTags = tagsProperty.selectedItems.toMutableSet() val selectedTags = tagsProperty.selectedItems.toMutableSet()
var tags = if (selectedTags.isEmpty()) { var tags = if (selectedTags.isEmpty()) {
@ -58,6 +65,18 @@ class FilterHeaderProducer @Inject constructor(
if (tags.isEmpty() && selectedTags.isEmpty()) { if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList() return emptyList()
} }
for (saved in savedFilters.availableItems) {
val model = ChipsView.ChipModel(
title = saved.name,
isChecked = saved in savedFilters.selectedItems,
data = saved,
)
if (model.isChecked) {
result.addFirst(model)
} else {
result.addLast(model)
}
}
for (tag in tags) { for (tag in tags) {
val model = ChipsView.ChipModel( val model = ChipsView.ChipModel(
title = tag.title, title = tag.title,

@ -1,20 +1,32 @@
package org.koitharu.kotatsu.filter.ui.sheet package org.koitharu.kotatsu.filter.ui.sheet
import android.os.Bundle import android.os.Bundle
import android.text.InputFilter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.setEditText
import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
@ -26,8 +38,9 @@ import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.setValueRounded 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.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.PersistableFilter.Companion.MAX_TITLE_LENGTH
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
@ -38,80 +51,18 @@ 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,
ChipsView.OnChipClickListener { View.OnClickListener,
ChipsView.OnChipClickListener,
ChipsView.OnChipLongClickListener,
ChipsView.OnChipCloseClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): 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) {
@ -130,6 +81,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged) filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
filter.year.observe(viewLifecycleOwner, this::onYearChanged) filter.year.observe(viewLifecycleOwner, this::onYearChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged) filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
binding.layoutGenres.setTitle( binding.layoutGenres.setTitle(
if (filter.capabilities.isMultipleTagsSupported) { if (filter.capabilities.isMultipleTagsSupported) {
@ -141,12 +93,15 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.spinnerLocale.onItemSelectedListener = this binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOriginalLocale.onItemSelectedListener = this binding.spinnerOriginalLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this
binding.chipsSavedFilters.onChipClickListener = this
binding.chipsState.onChipClickListener = this binding.chipsState.onChipClickListener = this
binding.chipsTypes.onChipClickListener = this binding.chipsTypes.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this binding.chipsContentRating.onChipClickListener = this
binding.chipsDemographics.onChipClickListener = this binding.chipsDemographics.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this
binding.chipsSavedFilters.onChipLongClickListener = this
binding.chipsSavedFilters.onChipCloseClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange) binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange) binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
binding.layoutGenres.setOnMoreButtonClickListener { binding.layoutGenres.setOnMoreButtonClickListener {
@ -155,48 +110,36 @@ 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) { filter.observe().observe(viewLifecycleOwner) {
binding.buttonSaveFilter.isEnabled = filter.isFilterApplied binding.buttonReset.isEnabled = it.listFilter.isNotEmpty()
}
binding.buttonSaveFilter.setOnClickListener {
promptPresetName { name ->
filter.saveCurrentPreset(name)
} }
combine(
filter.observe().map { it.listFilter.isNotEmpty() }.distinctUntilChanged(),
filter.savedFilters.map { it.selectedItems.isEmpty() }.distinctUntilChanged(),
Boolean::and,
).flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner) {
binding.buttonSave.isEnabled = it
} }
binding.buttonSave.setOnClickListener(this)
binding.buttonReset.setOnClickListener(this)
} }
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars() val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding( viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottom = insets.getInsets(typeMask).bottom, bottomMargin = insets.getInsets(typeMask).bottom
) }
return insets.consume(v, typeMask, bottom = true) return insets.consume(v, typeMask, bottom = true)
} }
override fun onClick(v: View) {
when (v.id) {
R.id.button_reset -> FilterCoordinator.require(this).reset()
R.id.button_save -> onSaveFilterClick()
}
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = FilterCoordinator.require(this) val filter = FilterCoordinator.require(this)
when (parent.id) { when (parent.id) {
@ -255,10 +198,30 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
is ContentType -> filter.toggleContentType(data, !chip.isChecked) is ContentType -> filter.toggleContentType(data, !chip.isChecked)
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
is Demographic -> filter.toggleDemographic(data, !chip.isChecked) is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
is PersistableFilter -> filter.setAdjusted(data.filter)
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude) null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
} }
} }
override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
return when (data) {
is PersistableFilter -> {
showSavedFilterMenu(chip, data)
true
}
else -> false
}
}
override fun onChipCloseClick(chip: Chip, data: Any?) {
when (data) {
is PersistableFilter -> {
showSavedFilterMenu(chip, data)
}
}
}
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) { private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutOrder.isGone = value.isEmpty() b.layoutOrder.isGone = value.isEmpty()
@ -451,4 +414,88 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
) )
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
} }
private fun onSavedPresetsChanged(value: FilterProperty<PersistableFilter>) {
val b = viewBinding ?: return
b.layoutSavedFilters.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { f ->
ChipsView.ChipModel(
title = f.name,
isChecked = f in value.selectedItems,
data = f,
isDropdown = true,
)
}
b.chipsSavedFilters.setChips(chips)
}
private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) {
val menu = PopupMenu(context ?: return, anchor)
val filter = FilterCoordinator.require(this)
menu.inflate(R.menu.popup_saved_filter)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_delete -> filter.deleteSavedFilter(preset.id)
R.id.action_rename -> onRenameFilterClick(preset)
}
true
}
menu.show()
}
private fun onSaveFilterClick() {
val filter = FilterCoordinator.require(this)
buildAlertDialog(context ?: return) {
val input = setEditText(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.setHint(R.string.enter_name)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
setTitle(R.string.save_filter)
setPositiveButton(R.string.save) { d, _ ->
val text = input.text?.toString()?.trim()
if (!text.isNullOrEmpty()) {
filter.saveCurrentFilter(text)
} else {
Snackbar.make(
viewBinding?.scrollView ?: return@setPositiveButton,
R.string.invalid_value_message,
Snackbar.LENGTH_SHORT,
).show()
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun onRenameFilterClick(preset: PersistableFilter) {
val filter = FilterCoordinator.require(this)
buildAlertDialog(context ?: return) {
val input = setEditText(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
input.setHint(R.string.enter_name)
input.setText(preset.name)
setTitle(R.string.rename)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (!text.isNullOrEmpty()) {
filter.renameSavedFilter(preset.id, text)
} else {
Snackbar.make(
viewBinding?.scrollView ?: return@setPositiveButton,
R.string.invalid_value_message,
Snackbar.LENGTH_SHORT,
).show()
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
} }

@ -13,14 +13,13 @@
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"
android:layout_height="match_parent" android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false" android:clipToPadding="false"
android:scrollIndicators="top" android:scrollIndicators="top|bottom"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<LinearLayout <LinearLayout
@ -31,23 +30,6 @@
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"
@ -73,6 +55,23 @@
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<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_locale" android:id="@+id/layout_locale"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -274,14 +273,41 @@
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
<com.google.android.material.button.MaterialButton <com.google.android.material.dockedtoolbar.DockedToolbarLayout
android:id="@+id/button_save_filter" android:id="@+id/docked_toolbar"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small" android:fitsSystemWindows="false">
android:layout_marginTop="@dimen/margin_small"
android:layout_marginBottom="@dimen/margin_normal" <LinearLayout
android:id="@+id/layout_bottom"
android:layout_width="match_parent"
android:layout_height="@dimen/m3_comp_toolbar_docked_container_height"
android:gravity="center_vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_reset"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_small"
android:layout_weight="1"
android:enabled="false"
android:text="@string/reset"
tools:enabled="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_save"
style="?materialButtonTonalStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_small"
android:layout_weight="1"
android:enabled="false"
android:text="@string/save" android:text="@string/save"
android:enabled="false" /> tools:enabled="true"
tools:ignore="ButtonStyle" />
</LinearLayout>
</com.google.android.material.dockedtoolbar.DockedToolbarLayout>
</LinearLayout> </LinearLayout>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_rename"
android:title="@string/rename" />
<item
android:id="@+id/action_delete"
android:title="@string/delete" />
</menu>

@ -891,4 +891,6 @@
<string name="chapters_load_failed">Failed to load chapter list</string> <string name="chapters_load_failed">Failed to load chapter list</string>
<string name="telegram_integration">Telegram integration</string> <string name="telegram_integration">Telegram integration</string>
<string name="test_parser">Test manga source</string> <string name="test_parser">Test manga source</string>
<string name="rename">Rename</string>
<string name="save_filter">Save filter</string>
</resources> </resources>

Loading…
Cancel
Save