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
@ -18,51 +24,75 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
inline fun buildAlertDialog( inline fun buildAlertDialog(
@UiContext context: Context, @UiContext context: Context,
isCentered: Boolean = false, isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit, block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder( ): AlertDialog = MaterialAlertDialogBuilder(
context, context,
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0, if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
).apply(block).create() ).apply(block).create()
fun <B : AlertDialog.Builder> B.setCheckbox( fun <B : AlertDialog.Builder> B.setCheckbox(
@StringRes textResId: Int, @StringRes textResId: Int,
isChecked: Boolean, isChecked: Boolean,
onCheckedChangeListener: OnCheckedChangeListener onCheckedChangeListener: OnCheckedChangeListener
) = apply { ) = apply {
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context)) val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
binding.checkbox.setText(textResId) binding.checkbox.setText(textResId)
binding.checkbox.isChecked = isChecked binding.checkbox.isChecked = isChecked
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener) binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
setView(binding.root) setView(binding.root)
} }
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList( fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>, list: List<T>,
delegate: AdapterDelegate<List<T>>, delegate: AdapterDelegate<List<T>>,
) = apply { ) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>() val delegatesManager = AdapterDelegatesManager<List<T>>()
delegatesManager.addDelegate(delegate) delegatesManager.addDelegate(delegate)
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list }) setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
} }
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList( fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>, list: List<T>,
vararg delegates: AdapterDelegate<List<T>>, vararg delegates: AdapterDelegate<List<T>>,
) = apply { ) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>() val delegatesManager = AdapterDelegatesManager<List<T>>()
delegates.forEach { delegatesManager.addDelegate(it) } delegates.forEach { delegatesManager.addDelegate(it) }
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list }) setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
} }
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply { fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
val recyclerView = RecyclerView(context) val recyclerView = RecyclerView(context)
recyclerView.layoutManager = LinearLayoutManager(context) recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding( recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing), top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
) )
recyclerView.clipToPadding = false recyclerView.clipToPadding = false
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()) fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
.onStart { emit(null) }
init { .map {
scope.launch { loadAll() } getAll(source)
} }.distinctUntilChanged()
.flowOn(Dispatchers.Default)
data class Preset(
val id: Long, suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
val name: String, val prefs = getPrefs(source)
val source: String, val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
val payload: JSONObject, keys.mapNotNull { key ->
) val value = prefs.getString(key, null) ?: return@mapNotNull null
try {
fun observe(source: String): StateFlow<List<Preset>> = MutableStateFlow(state.value[source].orEmpty()).also { out -> Json.decodeFromString(value)
scope.launch { } catch (e: SerializationException) {
state.collect { all -> out.value = all[source].orEmpty() } e.printStackTraceDebug()
null
}
} }
} }
fun list(source: String): List<Preset> = state.value[source].orEmpty() suspend fun save(
source: MangaSource,
fun save(source: String, name: String, filter: MangaListFilter): Preset { name: String,
val nowId = System.currentTimeMillis() filter: MangaListFilter,
val preset = Preset( ): PersistableFilter = withContext(Dispatchers.Default) {
id = nowId, 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) {
val list = list(source).map { if (it.id == id) it.copy(name = newName) else it }
persist(source, list)
} }
fun delete(source: String, id: Long) { suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
val list = list(source).filterNot { it.id == id } val filter = load(source, id) ?: return@withContext
persist(source, list) persist(source, filter.copy(name = newName))
} }
private fun persist(source: String, list: List<Preset>) { suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
val root = JSONObject(prefs.getString(keyRoot, "{}")) val prefs = getPrefs(source)
root.put(source, JSONArray(list.map { presetToJson(it) })) prefs.edit(commit = true) {
prefs.edit { putString(keyRoot, root.toString()) } remove(FILTER_PREFIX + id)
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? { private fun getPrefs(source: MangaSource) = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
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 companion object {
put("query", f.query)
put("author", f.author)
put("locale", f.locale?.toLanguageTag())
put("originalLocale", f.originalLocale?.toLanguageTag())
put("states", JSONArray(f.states.map { it.name }))
put("contentRating", JSONArray(f.contentRating.map { it.name }))
put("types", JSONArray(f.types.map { it.name }))
put("demographics", JSONArray(f.demographics.map { it.name }))
put("tags", JSONArray(f.tags.map { it.key }))
put("tagsExclude", JSONArray(f.tagsExclude.map { it.key }))
put("year", f.year)
put("yearFrom", f.yearFrom)
put("yearTo", f.yearTo)
}
fun deserializeFilter(
obj: JSONObject,
resolveTags: (Set<String>) -> Set<MangaTag>,
): MangaListFilter {
return MangaListFilter(
query = obj.optString("query").takeIf { it.isNotEmpty() },
author = obj.optString("author").takeIf { it.isNotEmpty() },
locale = obj.optString("locale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
originalLocale = obj.optString("originalLocale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
states = obj.optJSONArray("states")?.toStringSet()?.mapNotNull { runCatching { MangaState.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
contentRating = obj.optJSONArray("contentRating")?.toStringSet()?.mapNotNull { runCatching { ContentRating.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
types = obj.optJSONArray("types")?.toStringSet()?.mapNotNull { runCatching { ContentType.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
demographics = obj.optJSONArray("demographics")?.toStringSet()?.mapNotNull { runCatching { Demographic.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
tags = resolveTags(obj.optJSONArray("tags")?.toStringSet().orEmpty()).toSet(),
tagsExclude = resolveTags(obj.optJSONArray("tagsExclude")?.toStringSet().orEmpty()).toSet(),
year = obj.optInt("year"),
yearFrom = obj.optInt("yearFrom"),
yearTo = obj.optInt("yearTo"),
)
}
}
private fun JSONArray.toStringSet(): Set<String> = buildSet { 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) { fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
coroutineScope.launch { savedFiltersRepository.rename(repository.source, id, newName)
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 deleteSavedFilter(id: Int) = coroutineScope.launch {
savedFiltersRepository.rename(repository.source.unwrap().name, id, newName) savedFiltersRepository.delete(repository.source, id)
}
fun deletePreset(id: Long) {
savedFiltersRepository.delete(repository.source.unwrap().name, id)
if (currentPresetId.value == id) {
currentPresetId.value = null
lastAppliedPayload = null
}
} }
fun setQuery(value: String?) { fun setQuery(value: String?) {
@ -517,57 +482,57 @@ class FilterCoordinator @Inject constructor(
emit(Result.failure(it)) emit(Result.failure(it))
} }
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> { private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
val result = ArrayDeque<T>(this.size + other.size) val result = ArrayDeque<T>(this.size + other.size)
result.addAll(this) result.addAll(this)
for (item in other) { for (item in other) {
if (item !in result) { if (item !in result) {
result.addFirst(item) result.addFirst(item)
} }
} }
return result return result
} }
private fun <T> List<T>.addFirstDistinct(item: T): List<T> { private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
val result = ArrayDeque<T>(this.size + 1) val result = ArrayDeque<T>(this.size + 1)
result.addAll(this) result.addAll(this)
if (item !in result) { if (item !in result) {
result.addFirst(item) result.addFirst(item)
} }
return result return result
} }
data class Snapshot( data class Snapshot(
val sortOrder: SortOrder, val sortOrder: SortOrder,
val listFilter: MangaListFilter, val listFilter: MangaListFilter,
) )
interface Owner { interface Owner {
val filterCoordinator: FilterCoordinator val filterCoordinator: FilterCoordinator
} }
companion object { companion object {
private const val TAGS_LIMIT = 12 private const val TAGS_LIMIT = 12
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1 private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
fun find(fragment: Fragment): FilterCoordinator? { fun find(fragment: Fragment): FilterCoordinator? {
(fragment.activity as? Owner)?.let { (fragment.activity as? Owner)?.let {
return it.filterCoordinator return it.filterCoordinator
} }
var f = fragment var f = fragment
while (true) { while (true) {
(f as? Owner)?.let { (f as? Owner)?.let {
return it.filterCoordinator return it.filterCoordinator
} }
f = f.parentFragment ?: break f = f.parentFragment ?: break
} }
return null return null
} }
fun require(fragment: Fragment): FilterCoordinator { fun require(fragment: Fragment): FilterCoordinator {
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found") return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
} }
} }
} }

@ -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
@ -28,69 +29,75 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener, class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener,
ChipsView.OnChipCloseClickListener { ChipsView.OnChipCloseClickListener {
@Inject @Inject
lateinit var filterHeaderProducer: FilterHeaderProducer lateinit var filterHeaderProducer: FilterHeaderProducer
private val filter: FilterCoordinator private val filter: FilterCoordinator
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false) return FragmentFilterHeaderBinding.inflate(inflater, container, false)
} }
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
binding.chipsTags.onChipCloseClickListener = this binding.chipsTags.onChipCloseClickListener = this
filterHeaderProducer.observeHeader(filter) filterHeaderProducer.observeHeader(filter)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, ::onDataChanged) .observe(viewLifecycleOwner, ::onDataChanged)
} }
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
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 String -> Unit is PersistableFilter -> if (chip.isChecked) {
null -> router.showTagsCatalogSheet(excludeMode = false) filter.reset()
} } else {
} filter.setAdjusted(data.filter)
}
override fun onChipCloseClick(chip: Chip, data: Any?) { is String -> Unit
when (data) { null -> router.showTagsCatalogSheet(excludeMode = false)
is String -> if (data == filter.snapshot().listFilter.author) { }
filter.setAuthor(null) }
} else {
filter.setQuery(null)
}
is ContentRating -> filter.toggleContentRating(data, false) override fun onChipCloseClick(chip: Chip, data: Any?) {
is Demographic -> filter.toggleDemographic(data, false) when (data) {
is ContentType -> filter.toggleContentType(data, false) is String -> if (data == filter.snapshot().listFilter.author) {
is MangaState -> filter.toggleState(data, false) filter.setAuthor(null)
is Locale -> filter.setLocale(null) } else {
is Int -> filter.setYear(YEAR_UNKNOWN) filter.setQuery(null)
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN) }
}
}
private fun onDataChanged(header: FilterHeaderModel) { is ContentRating -> filter.toggleContentRating(data, false)
val binding = viewBinding ?: return is Demographic -> filter.toggleDemographic(data, false)
val chips = header.chips is ContentType -> filter.toggleContentType(data, false)
if (chips.isEmpty()) { is MangaState -> filter.toggleState(data, false)
binding.chipsTags.setChips(emptyList()) is Locale -> filter.setLocale(null)
binding.root.isVisible = false is Int -> filter.setYear(YEAR_UNKNOWN)
return is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
} }
binding.chipsTags.setChips(header.chips) }
binding.root.isVisible = true
if (binding.root.context.isAnimationsEnabled) { private fun onDataChanged(header: FilterHeaderModel) {
binding.scrollView.smoothScrollTo(0, 0) val binding = viewBinding ?: return
} else { val chips = header.chips
binding.scrollView.scrollTo(0, 0) if (chips.isEmpty()) {
} binding.chipsTags.setChips(emptyList())
} binding.root.isVisible = false
return
}
binding.chipsTags.setChips(header.chips)
binding.root.isVisible = true
if (binding.root.context.isAnimationsEnabled) {
binding.scrollView.smoothScrollTo(0, 0)
} else {
binding.scrollView.scrollTo(0, 0)
}
}
} }

@ -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
@ -17,143 +18,161 @@ import javax.inject.Inject
import androidx.appcompat.R as appcompatR import androidx.appcompat.R as appcompatR
class FilterHeaderProducer @Inject constructor( class FilterHeaderProducer @Inject constructor(
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
) { ) {
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> { fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot -> return combine(
val chipList = createChipsList( filterCoordinator.savedFilters,
source = filterCoordinator.mangaSource, filterCoordinator.tags,
capabilities = filterCoordinator.capabilities, filterCoordinator.observe(),
tagsProperty = tags, ) { saved, tags, snapshot ->
snapshot = snapshot.listFilter, val chipList = createChipsList(
limit = 12, source = filterCoordinator.mangaSource,
) capabilities = filterCoordinator.capabilities,
FilterHeaderModel( savedFilters = saved,
chips = chipList, tagsProperty = tags,
sortOrder = snapshot.sortOrder, snapshot = snapshot.listFilter,
isFilterApplied = !snapshot.listFilter.isEmpty(), limit = 12,
) )
} FilterHeaderModel(
} chips = chipList,
sortOrder = snapshot.sortOrder,
isFilterApplied = !snapshot.listFilter.isEmpty(),
)
}
}
private suspend fun createChipsList( private suspend fun createChipsList(
source: MangaSource, source: MangaSource,
capabilities: MangaListFilterCapabilities, capabilities: MangaListFilterCapabilities,
tagsProperty: FilterProperty<MangaTag>, savedFilters: FilterProperty<PersistableFilter>,
snapshot: MangaListFilter, tagsProperty: FilterProperty<MangaTag>,
limit: Int, snapshot: MangaListFilter,
): List<ChipsView.ChipModel> { limit: Int,
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3) ): List<ChipsView.ChipModel> {
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3)
val selectedTags = tagsProperty.selectedItems.toMutableSet() if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
var tags = if (selectedTags.isEmpty()) { val selectedTags = tagsProperty.selectedItems.toMutableSet()
searchRepository.getTagsSuggestion("", limit, source) var tags = if (selectedTags.isEmpty()) {
} else { searchRepository.getTagsSuggestion("", limit, source)
searchRepository.getTagsSuggestion(selectedTags).take(limit) } else {
} searchRepository.getTagsSuggestion(selectedTags).take(limit)
if (tags.size < limit) { }
tags = tags + tagsProperty.availableItems.take(limit - tags.size) if (tags.size < limit) {
} tags = tags + tagsProperty.availableItems.take(limit - tags.size)
if (tags.isEmpty() && selectedTags.isEmpty()) { }
return emptyList() if (tags.isEmpty() && selectedTags.isEmpty()) {
} return emptyList()
for (tag in tags) { }
val model = ChipsView.ChipModel( for (saved in savedFilters.availableItems) {
title = tag.title, val model = ChipsView.ChipModel(
isChecked = selectedTags.remove(tag), title = saved.name,
data = tag, isChecked = saved in savedFilters.selectedItems,
) data = saved,
if (model.isChecked) { )
result.addFirst(model) if (model.isChecked) {
} else { result.addFirst(model)
result.addLast(model) } else {
} result.addLast(model)
} }
for (tag in selectedTags) { }
val model = ChipsView.ChipModel( for (tag in tags) {
title = tag.title, val model = ChipsView.ChipModel(
isChecked = true, title = tag.title,
data = tag, isChecked = selectedTags.remove(tag),
) data = tag,
result.addFirst(model) )
} if (model.isChecked) {
} result.addFirst(model)
snapshot.locale?.let { } else {
result.addFirst( result.addLast(model)
ChipsView.ChipModel( }
title = it.getDisplayName(it).toTitleCase(it), }
icon = R.drawable.ic_language, for (tag in selectedTags) {
isCloseable = true, val model = ChipsView.ChipModel(
data = it, title = tag.title,
), isChecked = true,
) data = tag,
} )
snapshot.types.forEach { result.addFirst(model)
result.addFirst( }
ChipsView.ChipModel( }
titleResId = it.titleResId, snapshot.locale?.let {
isCloseable = true, result.addFirst(
data = it, ChipsView.ChipModel(
), title = it.getDisplayName(it).toTitleCase(it),
) icon = R.drawable.ic_language,
} isCloseable = true,
snapshot.demographics.forEach { data = it,
result.addFirst( ),
ChipsView.ChipModel( )
titleResId = it.titleResId, }
isCloseable = true, snapshot.types.forEach {
data = it, result.addFirst(
), ChipsView.ChipModel(
) titleResId = it.titleResId,
} isCloseable = true,
snapshot.contentRating.forEach { data = it,
result.addFirst( ),
ChipsView.ChipModel( )
titleResId = it.titleResId, }
isCloseable = true, snapshot.demographics.forEach {
data = it, result.addFirst(
), ChipsView.ChipModel(
) titleResId = it.titleResId,
} isCloseable = true,
snapshot.states.forEach { data = it,
result.addFirst( ),
ChipsView.ChipModel( )
titleResId = it.titleResId, }
isCloseable = true, snapshot.contentRating.forEach {
data = it, result.addFirst(
), ChipsView.ChipModel(
) titleResId = it.titleResId,
} isCloseable = true,
if (!snapshot.query.isNullOrEmpty()) { data = it,
result.addFirst( ),
ChipsView.ChipModel( )
title = snapshot.query, }
icon = appcompatR.drawable.abc_ic_search_api_material, snapshot.states.forEach {
isCloseable = true, result.addFirst(
data = snapshot.query, ChipsView.ChipModel(
), titleResId = it.titleResId,
) isCloseable = true,
} data = it,
if (!snapshot.author.isNullOrEmpty()) { ),
result.addFirst( )
ChipsView.ChipModel( }
title = snapshot.author, if (!snapshot.query.isNullOrEmpty()) {
icon = R.drawable.ic_user, result.addFirst(
isCloseable = true, ChipsView.ChipModel(
data = snapshot.author, title = snapshot.query,
), icon = appcompatR.drawable.abc_ic_search_api_material,
) isCloseable = true,
} data = snapshot.query,
val hasTags = result.any { it.data is MangaTag } ),
if (hasTags) { )
result.addFirst(moreTagsChip()) }
} if (!snapshot.author.isNullOrEmpty()) {
return result result.addFirst(
} ChipsView.ChipModel(
title = snapshot.author,
icon = R.drawable.ic_user,
isCloseable = true,
data = snapshot.author,
),
)
}
val hasTags = result.any { it.data is MangaTag }
if (hasTags) {
result.addFirst(moreTagsChip())
}
return result
}
private fun moreTagsChip() = ChipsView.ChipModel( private fun moreTagsChip() = ChipsView.ChipModel(
titleResId = R.string.genres, titleResId = R.string.genres,
icon = R.drawable.ic_drawer_menu_open, icon = R.drawable.ic_drawer_menu_open,
) )
} }

@ -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,417 +51,451 @@ 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?) { override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
val b = viewBinding ?: return super.onViewBindingCreated(binding, savedInstanceState)
if (list.isEmpty()) { if (dialog == null) {
b.layoutSavedFilters.isGone = true binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
b.chipsSavedFilters.setChips(emptyList()) binding.scrollView.scrollIndicators = 0
return }
} val filter = FilterCoordinator.require(this)
b.layoutSavedFilters.isGone = false filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
val chips = list.map { p -> filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
ChipsView.ChipModel( filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
title = p.name, filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
isChecked = p.id == selectedId, filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
data = p, filter.states.observe(viewLifecycleOwner, this::onStateChanged)
) filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
} filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
b.chipsSavedFilters.setChips(chips) filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
} filter.year.observe(viewLifecycleOwner, this::onYearChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
private fun promptPresetName(onSubmit: (String) -> Unit) { binding.layoutGenres.setTitle(
val ctx = requireContext() if (filter.capabilities.isMultipleTagsSupported) {
val input = EditText(ctx) R.string.genres
MaterialAlertDialogBuilder(ctx) } else {
.setTitle(R.string.enter_name) R.string.genre
.setView(input) },
.setPositiveButton(R.string.save) { d, _ -> )
val text = input.text?.toString()?.trim() binding.spinnerLocale.onItemSelectedListener = this
if (!text.isNullOrEmpty()) onSubmit(text) binding.spinnerOriginalLocale.onItemSelectedListener = this
d.dismiss() binding.spinnerOrder.onItemSelectedListener = this
} binding.chipsSavedFilters.onChipClickListener = this
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } binding.chipsState.onChipClickListener = this
.show() binding.chipsTypes.onChipClickListener = this
} binding.chipsContentRating.onChipClickListener = this
binding.chipsDemographics.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
binding.chipsSavedFilters.onChipLongClickListener = this
binding.chipsSavedFilters.onChipCloseClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
binding.layoutGenres.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = false)
}
binding.layoutGenresExclude.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = true)
}
filter.observe().observe(viewLifecycleOwner) {
binding.buttonReset.isEnabled = it.listFilter.isNotEmpty()
}
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)
}
private fun showPresetOptions(filter: FilterCoordinator, preset: SavedFiltersRepository.Preset) { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val ctx = requireContext() val typeMask = WindowInsetsCompat.Type.systemBars()
val items = arrayOf(getString(R.string.edit), getString(R.string.delete)) viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
MaterialAlertDialogBuilder(ctx) bottomMargin = insets.getInsets(typeMask).bottom
.setItems(items) { d, which -> }
when (which) { return insets.consume(v, typeMask, bottom = true)
0 -> promptRename(filter, preset) }
1 -> filter.deletePreset(preset.id)
}
d.dismiss()
}
.show()
}
private fun promptRename(filter: FilterCoordinator, preset: SavedFiltersRepository.Preset) { override fun onClick(v: View) {
val ctx = requireContext() when (v.id) {
val input = EditText(ctx) R.id.button_reset -> FilterCoordinator.require(this).reset()
input.setText(preset.name) R.id.button_save -> onSaveFilterClick()
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 onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
super.onViewBindingCreated(binding, savedInstanceState) val filter = FilterCoordinator.require(this)
if (dialog == null) { when (parent.id) {
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom) R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
binding.scrollView.scrollIndicators = 0 R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
} R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
val filter = FilterCoordinator.require(this) }
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) }
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
binding.layoutGenres.setTitle( override fun onNothingSelected(parent: AdapterView<*>?) = Unit
if (filter.capabilities.isMultipleTagsSupported) {
R.string.genres
} else {
R.string.genre
},
)
binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOriginalLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this
binding.chipsTypes.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this
binding.chipsDemographics.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
binding.layoutGenres.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = false)
}
binding.layoutGenresExclude.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = true)
}
binding.chipsSavedFilters.onChipClickListener = ChipsView.OnChipClickListener { chip, data -> private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) {
when (data) { if (!fromUser) {
is SavedFiltersRepository.Preset -> filter.applyPreset(data) return
} }
} val intValue = value.toInt()
binding.chipsSavedFilters.onChipLongClickListener = ChipsView.OnChipLongClickListener { chip, data -> val filter = FilterCoordinator.require(this)
when (data) { when (slider.id) {
is SavedFiltersRepository.Preset -> { R.id.slider_year -> filter.setYear(
showPresetOptions(filter, data) if (intValue <= slider.valueFrom.toIntUp()) {
true YEAR_UNKNOWN
} } else {
else -> false intValue
} },
} )
}
}
filter.savedPresets.observe(viewLifecycleOwner) { list -> private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
val selectedId = filter.selectedPresetId.value if (!fromUser) {
onSavedPresetsChanged(list, selectedId) return
} }
filter.selectedPresetId.observe(viewLifecycleOwner) { selectedId -> val filter = FilterCoordinator.require(this)
onSavedPresetsChanged(filter.savedPresets.value, selectedId) when (slider.id) {
} R.id.slider_yearsRange -> filter.setYearRange(
valueFrom = slider.values.firstOrNull()?.let {
if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt()
} ?: YEAR_UNKNOWN,
valueTo = slider.values.lastOrNull()?.let {
if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt()
} ?: YEAR_UNKNOWN,
)
}
}
filter.observe().observe(viewLifecycleOwner) { override fun onChipClick(chip: Chip, data: Any?) {
binding.buttonSaveFilter.isEnabled = filter.isFilterApplied val filter = FilterCoordinator.require(this)
} when (data) {
binding.buttonSaveFilter.setOnClickListener { is MangaState -> filter.toggleState(data, !chip.isChecked)
promptPresetName { name -> is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
filter.saveCurrentPreset(name) filter.toggleTagExclude(data, !chip.isChecked)
} } else {
} filter.toggleTag(data, !chip.isChecked)
} }
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { is ContentType -> filter.toggleContentType(data, !chip.isChecked)
val typeMask = WindowInsetsCompat.Type.systemBars() is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
viewBinding?.scrollView?.updatePadding( is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
bottom = insets.getInsets(typeMask).bottom, is PersistableFilter -> filter.setAdjusted(data.filter)
) null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
return insets.consume(v, typeMask, bottom = true) }
} }
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
val filter = FilterCoordinator.require(this) return when (data) {
when (parent.id) { is PersistableFilter -> {
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position]) showSavedFilterMenu(chip, data)
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position]) true
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position]) }
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit else -> false
}
}
private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) { override fun onChipCloseClick(chip: Chip, data: Any?) {
if (!fromUser) { when (data) {
return is PersistableFilter -> {
} showSavedFilterMenu(chip, data)
val intValue = value.toInt() }
val filter = FilterCoordinator.require(this) }
when (slider.id) { }
R.id.slider_year -> filter.setYear(
if (intValue <= slider.valueFrom.toIntUp()) {
YEAR_UNKNOWN
} else {
intValue
},
)
}
}
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) { private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
if (!fromUser) { val b = viewBinding ?: return
return b.layoutOrder.isGone = value.isEmpty()
} if (value.isEmpty()) {
val filter = FilterCoordinator.require(this) return
when (slider.id) { }
R.id.slider_yearsRange -> filter.setYearRange( val selected = value.selectedItems.single()
valueFrom = slider.values.firstOrNull()?.let { b.spinnerOrder.adapter = ArrayAdapter(
if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt() b.spinnerOrder.context,
} ?: YEAR_UNKNOWN, android.R.layout.simple_spinner_dropdown_item,
valueTo = slider.values.lastOrNull()?.let { android.R.id.text1,
if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt() value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
} ?: YEAR_UNKNOWN, )
) val selectedIndex = value.availableItems.indexOf(selected)
} if (selectedIndex >= 0) {
} b.spinnerOrder.setSelection(selectedIndex, false)
}
}
override fun onChipClick(chip: Chip, data: Any?) { private fun onLocaleChanged(value: FilterProperty<Locale?>) {
val filter = FilterCoordinator.require(this) val b = viewBinding ?: return
when (data) { b.layoutLocale.isGone = value.isEmpty()
is MangaState -> filter.toggleState(data, !chip.isChecked) if (value.isEmpty()) {
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { return
filter.toggleTagExclude(data, !chip.isChecked) }
} else { val selected = value.selectedItems.singleOrNull()
filter.toggleTag(data, !chip.isChecked) b.spinnerLocale.adapter = ArrayAdapter(
} b.spinnerLocale.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerLocale.setSelection(selectedIndex, false)
}
}
is ContentType -> filter.toggleContentType(data, !chip.isChecked) private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) val b = viewBinding ?: return
is Demographic -> filter.toggleDemographic(data, !chip.isChecked) b.layoutOriginalLocale.isGone = value.isEmpty()
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude) if (value.isEmpty()) {
} return
} }
val selected = value.selectedItems.singleOrNull()
b.spinnerOriginalLocale.adapter = ArrayAdapter(
b.spinnerOriginalLocale.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerOriginalLocale.setSelection(selectedIndex, false)
}
}
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) { private fun onTagsChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutOrder.isGone = value.isEmpty() b.layoutGenres.isGone = value.isEmptyAndSuccess()
if (value.isEmpty()) { b.layoutGenres.setError(value.error?.getDisplayMessage(resources))
return if (value.isEmpty()) {
} return
val selected = value.selectedItems.single() }
b.spinnerOrder.adapter = ArrayAdapter( val chips = value.availableItems.map { tag ->
b.spinnerOrder.context, ChipsView.ChipModel(
android.R.layout.simple_spinner_dropdown_item, title = tag.title,
android.R.id.text1, isChecked = tag in value.selectedItems,
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) }, data = tag,
) )
val selectedIndex = value.availableItems.indexOf(selected) }
if (selectedIndex >= 0) { b.chipsGenres.setChips(chips)
b.spinnerOrder.setSelection(selectedIndex, false) }
}
}
private fun onLocaleChanged(value: FilterProperty<Locale?>) { private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutLocale.isGone = value.isEmpty() b.layoutGenresExclude.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
val selected = value.selectedItems.singleOrNull() val chips = value.availableItems.map { tag ->
b.spinnerLocale.adapter = ArrayAdapter( ChipsView.ChipModel(
b.spinnerLocale.context, title = tag.title,
android.R.layout.simple_spinner_dropdown_item, isChecked = tag in value.selectedItems,
android.R.id.text1, data = tag,
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) }, )
) }
val selectedIndex = value.availableItems.indexOf(selected) b.chipsGenresExclude.setChips(chips)
if (selectedIndex >= 0) { }
b.spinnerLocale.setSelection(selectedIndex, false)
}
}
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) { private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutOriginalLocale.isGone = value.isEmpty() b.layoutState.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
val selected = value.selectedItems.singleOrNull() val chips = value.availableItems.map { state ->
b.spinnerOriginalLocale.adapter = ArrayAdapter( ChipsView.ChipModel(
b.spinnerOriginalLocale.context, title = getString(state.titleResId),
android.R.layout.simple_spinner_dropdown_item, isChecked = state in value.selectedItems,
android.R.id.text1, data = state,
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) }, )
) }
val selectedIndex = value.availableItems.indexOf(selected) b.chipsState.setChips(chips)
if (selectedIndex >= 0) { }
b.spinnerOriginalLocale.setSelection(selectedIndex, false)
}
}
private fun onTagsChanged(value: FilterProperty<MangaTag>) { private fun onContentTypesChanged(value: FilterProperty<ContentType>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutGenres.isGone = value.isEmptyAndSuccess() b.layoutTypes.isGone = value.isEmpty()
b.layoutGenres.setError(value.error?.getDisplayMessage(resources)) if (value.isEmpty()) {
if (value.isEmpty()) { return
return }
} val chips = value.availableItems.map { type ->
val chips = value.availableItems.map { tag -> ChipsView.ChipModel(
ChipsView.ChipModel( title = getString(type.titleResId),
title = tag.title, isChecked = type in value.selectedItems,
isChecked = tag in value.selectedItems, data = type,
data = tag, )
) }
} b.chipsTypes.setChips(chips)
b.chipsGenres.setChips(chips) }
}
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) { private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutGenresExclude.isGone = value.isEmpty() b.layoutContentRating.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
val chips = value.availableItems.map { tag -> val chips = value.availableItems.map { contentRating ->
ChipsView.ChipModel( ChipsView.ChipModel(
title = tag.title, title = getString(contentRating.titleResId),
isChecked = tag in value.selectedItems, isChecked = contentRating in value.selectedItems,
data = tag, data = contentRating,
) )
} }
b.chipsGenresExclude.setChips(chips) b.chipsContentRating.setChips(chips)
} }
private fun onStateChanged(value: FilterProperty<MangaState>) { private fun onDemographicsChanged(value: FilterProperty<Demographic>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutState.isGone = value.isEmpty() b.layoutDemographics.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
val chips = value.availableItems.map { state -> val chips = value.availableItems.map { demographic ->
ChipsView.ChipModel( ChipsView.ChipModel(
title = getString(state.titleResId), title = getString(demographic.titleResId),
isChecked = state in value.selectedItems, isChecked = demographic in value.selectedItems,
data = state, data = demographic,
) )
} }
b.chipsState.setChips(chips) b.chipsDemographics.setChips(chips)
} }
private fun onContentTypesChanged(value: FilterProperty<ContentType>) { private fun onYearChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutTypes.isGone = value.isEmpty() b.layoutYear.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
val chips = value.availableItems.map { type -> val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN
ChipsView.ChipModel( b.layoutYear.setValueText(
title = getString(type.titleResId), if (currentValue == YEAR_UNKNOWN) {
isChecked = type in value.selectedItems, getString(R.string.any)
data = type, } else {
) currentValue.toString()
} },
b.chipsTypes.setChips(chips) )
} b.sliderYear.valueFrom = value.availableItems.first().toFloat()
b.sliderYear.valueTo = value.availableItems.last().toFloat()
b.sliderYear.setValueRounded(currentValue.toFloat())
}
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) { private fun onYearRangeChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutContentRating.isGone = value.isEmpty() b.layoutYearsRange.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
val chips = value.availableItems.map { contentRating -> b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat()
ChipsView.ChipModel( b.sliderYearsRange.valueTo = value.availableItems.last().toFloat()
title = getString(contentRating.titleResId), val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom
isChecked = contentRating in value.selectedItems, val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo
data = contentRating, b.layoutYearsRange.setValueText(
) getString(
} R.string.memory_usage_pattern,
b.chipsContentRating.setChips(chips) currentValueFrom.toInt().toString(),
} currentValueTo.toInt().toString(),
),
)
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
}
private fun onDemographicsChanged(value: FilterProperty<Demographic>) { private fun onSavedPresetsChanged(value: FilterProperty<PersistableFilter>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutDemographics.isGone = value.isEmpty() b.layoutSavedFilters.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
val chips = value.availableItems.map { demographic -> val chips = value.availableItems.map { f ->
ChipsView.ChipModel( ChipsView.ChipModel(
title = getString(demographic.titleResId), title = f.name,
isChecked = demographic in value.selectedItems, isChecked = f in value.selectedItems,
data = demographic, data = f,
) isDropdown = true,
} )
b.chipsDemographics.setChips(chips) }
} b.chipsSavedFilters.setChips(chips)
}
private fun onYearChanged(value: FilterProperty<Int>) { private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) {
val b = viewBinding ?: return val menu = PopupMenu(context ?: return, anchor)
b.layoutYear.isGone = value.isEmpty() val filter = FilterCoordinator.require(this)
if (value.isEmpty()) { menu.inflate(R.menu.popup_saved_filter)
return menu.setOnMenuItemClickListener { menuItem ->
} when (menuItem.itemId) {
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN R.id.action_delete -> filter.deleteSavedFilter(preset.id)
b.layoutYear.setValueText( R.id.action_rename -> onRenameFilterClick(preset)
if (currentValue == YEAR_UNKNOWN) { }
getString(R.string.any) true
} else { }
currentValue.toString() menu.show()
}, }
)
b.sliderYear.valueFrom = value.availableItems.first().toFloat()
b.sliderYear.valueTo = value.availableItems.last().toFloat()
b.sliderYear.setValueRounded(currentValue.toFloat())
}
private fun onYearRangeChanged(value: FilterProperty<Int>) { private fun onSaveFilterClick() {
val b = viewBinding ?: return val filter = FilterCoordinator.require(this)
b.layoutYearsRange.isGone = value.isEmpty() buildAlertDialog(context ?: return) {
if (value.isEmpty()) { val input = setEditText(
return inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
} singleLine = true,
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat() )
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat() input.setHint(R.string.enter_name)
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo setTitle(R.string.save_filter)
b.layoutYearsRange.setValueText( setPositiveButton(R.string.save) { d, _ ->
getString( val text = input.text?.toString()?.trim()
R.string.memory_usage_pattern, if (!text.isNullOrEmpty()) {
currentValueFrom.toInt().toString(), filter.saveCurrentFilter(text)
currentValueTo.toInt().toString(), } else {
), Snackbar.make(
) viewBinding?.scrollView ?: return@setPositiveButton,
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) 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()
}
} }

@ -1,287 +1,313 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar <org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar" android:id="@+id/headerBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:title="@string/filter" /> app:title="@string/filter" />
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
<androidx.core.widget.NestedScrollView android:layout_width="match_parent"
android:id="@+id/scrollView" android:layout_height="0dp"
android:layout_width="match_parent" android:layout_weight="1"
android:layout_height="match_parent" android:clipToPadding="false"
android:clipToPadding="false" android:scrollIndicators="top|bottom"
android:scrollIndicators="top" tools:ignore="UnusedAttribute">
tools:ignore="UnusedAttribute">
<LinearLayout
<LinearLayout android:id="@+id/layout_body"
android:id="@+id/layout_body" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:orientation="vertical"
android:orientation="vertical" 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
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_order"
android:id="@+id/layout_saved_filters" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" app:title="@string/sort_order">
android:layout_marginTop="@dimen/margin_small"
app:title="@string/saved_filters"> <com.google.android.material.card.MaterialCardView
android:id="@+id/card_order"
<org.koitharu.kotatsu.core.ui.widgets.ChipsView style="?materialCardViewOutlinedStyle"
android:id="@+id/chips_saved_filters" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small">
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> <Spinner
android:id="@+id/spinner_order"
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:minHeight="?listPreferredItemHeightSmall"
android:id="@+id/layout_order" android:paddingHorizontal="8dp" />
android:layout_width="match_parent"
android:layout_height="wrap_content" </com.google.android.material.card.MaterialCardView>
app:title="@string/sort_order">
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_order" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
style="?materialCardViewOutlinedStyle" android:id="@+id/layout_saved_filters"
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:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"> app:title="@string/saved_filters">
<Spinner <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/spinner_order" android:id="@+id/chips_saved_filters"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height" android:layout_height="wrap_content"
android:minHeight="?listPreferredItemHeightSmall" android:layout_marginHorizontal="@dimen/margin_small"
android:paddingHorizontal="8dp" /> android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</com.google.android.material.card.MaterialCardView>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</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" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/language">
app:title="@string/language">
<com.google.android.material.card.MaterialCardView
<com.google.android.material.card.MaterialCardView android:id="@+id/card_locale"
android:id="@+id/card_locale" style="?materialCardViewOutlinedStyle"
style="?materialCardViewOutlinedStyle" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small">
android:layout_marginTop="@dimen/margin_small">
<Spinner
<Spinner android:id="@+id/spinner_locale"
android:id="@+id/spinner_locale" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="@dimen/spinner_height"
android:layout_height="@dimen/spinner_height" android:minHeight="?listPreferredItemHeightSmall"
android:minHeight="?listPreferredItemHeightSmall" android:paddingHorizontal="8dp"
android:paddingHorizontal="8dp" android:popupBackground="@drawable/m3_spinner_popup_background" />
android:popupBackground="@drawable/m3_spinner_popup_background" />
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_original_locale"
android:id="@+id/layout_original_locale" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/original_language">
app:title="@string/original_language">
<com.google.android.material.card.MaterialCardView
<com.google.android.material.card.MaterialCardView android:id="@+id/card_original_locale"
android:id="@+id/card_original_locale" style="?materialCardViewOutlinedStyle"
style="?materialCardViewOutlinedStyle" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small">
android:layout_marginTop="@dimen/margin_small">
<Spinner
<Spinner android:id="@+id/spinner_original_locale"
android:id="@+id/spinner_original_locale" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="@dimen/spinner_height"
android:layout_height="@dimen/spinner_height" android:minHeight="?listPreferredItemHeightSmall"
android:minHeight="?listPreferredItemHeightSmall" android:paddingHorizontal="8dp"
android:paddingHorizontal="8dp" android:popupBackground="@drawable/m3_spinner_popup_background" />
android:popupBackground="@drawable/m3_spinner_popup_background" />
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_genres"
android:id="@+id/layout_genres" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:showMoreButton="true"
app:showMoreButton="true" app:title="@string/genres">
app:title="@string/genres">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
<org.koitharu.kotatsu.core.ui.widgets.ChipsView android:id="@+id/chips_genres"
android:id="@+id/chips_genres" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
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
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_genresExclude"
android:id="@+id/layout_genresExclude" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:showMoreButton="true"
app:showMoreButton="true" app:title="@string/genres_exclude">
app:title="@string/genres_exclude">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
<org.koitharu.kotatsu.core.ui.widgets.ChipsView android:id="@+id/chips_genresExclude"
android:id="@+id/chips_genresExclude" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
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
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_types"
android:id="@+id/layout_types" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/type">
app:title="@string/type">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
<org.koitharu.kotatsu.core.ui.widgets.ChipsView android:id="@+id/chips_types"
android:id="@+id/chips_types" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
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
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_state"
android:id="@+id/layout_state" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/state">
app:title="@string/state">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
<org.koitharu.kotatsu.core.ui.widgets.ChipsView android:id="@+id/chips_state"
android:id="@+id/chips_state" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
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
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_contentRating"
android:id="@+id/layout_contentRating" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/content_rating">
app:title="@string/content_rating">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
<org.koitharu.kotatsu.core.ui.widgets.ChipsView android:id="@+id/chips_contentRating"
android:id="@+id/chips_contentRating" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
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
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_demographics"
android:id="@+id/layout_demographics" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/demographics">
app:title="@string/demographics">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
<org.koitharu.kotatsu.core.ui.widgets.ChipsView android:id="@+id/chips_demographics"
android:id="@+id/chips_demographics" 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:layout_marginHorizontal="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
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
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_year"
android:id="@+id/layout_year" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/year">
app:title="@string/year">
<com.google.android.material.slider.Slider
<com.google.android.material.slider.Slider android:id="@+id/slider_year"
android:id="@+id/slider_year" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:stepSize="1"
android:stepSize="1" app:labelBehavior="gone"
app:labelBehavior="gone" app:tickVisible="true"
app:tickVisible="true" tools:value="2020"
tools:value="2020" tools:valueFrom="1900"
tools:valueFrom="1900" tools:valueTo="2090" />
tools:valueTo="2090" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_yearsRange"
android:id="@+id/layout_yearsRange" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/years">
app:title="@string/years">
<com.google.android.material.slider.RangeSlider
<com.google.android.material.slider.RangeSlider android:id="@+id/slider_yearsRange"
android:id="@+id/slider_yearsRange" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal" android:stepSize="1"
android:stepSize="1" app:labelBehavior="gone"
app:labelBehavior="gone" app:tickVisible="true"
app:tickVisible="true" tools:valueFrom="1900"
tools:valueFrom="1900" tools:valueTo="2090" />
tools:valueTo="2090" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</LinearLayout>
</LinearLayout> </androidx.core.widget.NestedScrollView>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.dockedtoolbar.DockedToolbarLayout
<com.google.android.material.button.MaterialButton android:id="@+id/docked_toolbar"
android:id="@+id/button_save_filter" android:layout_width="match_parent"
style="@style/Widget.Material3.Button" android:layout_height="wrap_content"
android:layout_width="match_parent" android:fitsSystemWindows="false">
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small" <LinearLayout
android:layout_marginTop="@dimen/margin_small" android:id="@+id/layout_bottom"
android:layout_marginBottom="@dimen/margin_normal" android:layout_width="match_parent"
android:text="@string/save" android:layout_height="@dimen/m3_comp_toolbar_docked_container_height"
android:enabled="false" /> 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"
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>

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save