Improve saved filters

This commit is contained in:
Koitharu
2025-10-20 14:18:08 +03:00
parent e35521f16f
commit 1181860e41
14 changed files with 2257 additions and 1991 deletions

View File

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

View File

@@ -1,9 +1,7 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS">
<value>
<option name="USE_TAB_CHARACTER" value="true" />
</value>
<value />
</option>
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
@@ -22,40 +20,46 @@
</value>
</option>
</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>
<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" />
</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">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
@@ -64,7 +68,6 @@
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
<arrangement>
<rules>
@@ -179,9 +182,6 @@
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

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

View File

@@ -2,10 +2,16 @@ package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
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.EditText
import android.widget.FrameLayout
import androidx.annotation.StringRes
import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -18,51 +24,75 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import com.google.android.material.R as materialR
inline fun buildAlertDialog(
@UiContext context: Context,
isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit,
@UiContext context: Context,
isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder(
context,
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
context,
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
).apply(block).create()
fun <B : AlertDialog.Builder> B.setCheckbox(
@StringRes textResId: Int,
isChecked: Boolean,
onCheckedChangeListener: OnCheckedChangeListener
@StringRes textResId: Int,
isChecked: Boolean,
onCheckedChangeListener: OnCheckedChangeListener
) = apply {
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
binding.checkbox.setText(textResId)
binding.checkbox.isChecked = isChecked
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
setView(binding.root)
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
binding.checkbox.setText(textResId)
binding.checkbox.isChecked = isChecked
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
setView(binding.root)
}
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>,
delegate: AdapterDelegate<List<T>>,
list: List<T>,
delegate: AdapterDelegate<List<T>>,
) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegatesManager.addDelegate(delegate)
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegatesManager.addDelegate(delegate)
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>,
vararg delegates: AdapterDelegate<List<T>>,
list: List<T>,
vararg delegates: AdapterDelegate<List<T>>,
) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegates.forEach { delegatesManager.addDelegate(it) }
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegates.forEach { delegatesManager.addDelegate(it) }
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
val recyclerView = RecyclerView(context)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
recyclerView.adapter = adapter
setView(recyclerView)
val recyclerView = RecyclerView(context)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
recyclerView.adapter = adapter
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
}

View File

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

View File

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

View File

@@ -1,152 +1,99 @@
package org.koitharu.kotatsu.filter.data
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.MangaState
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import android.content.Context
import androidx.core.content.edit
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
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.MangaSource
import javax.inject.Inject
@Singleton
@Reusable
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"
fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
.onStart { emit(null) }
.map {
getAll(source)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
private val state = MutableStateFlow<Map<String, List<Preset>>>(emptyMap())
init {
scope.launch { loadAll() }
}
data class Preset(
val id: Long,
val name: String,
val source: String,
val payload: JSONObject,
)
fun observe(source: String): StateFlow<List<Preset>> = MutableStateFlow(state.value[source].orEmpty()).also { out ->
scope.launch {
state.collect { all -> out.value = all[source].orEmpty() }
suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
keys.mapNotNull { key ->
val value = prefs.getString(key, null) ?: return@mapNotNull null
try {
Json.decodeFromString(value)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
}
fun list(source: String): List<Preset> = state.value[source].orEmpty()
fun save(source: String, name: String, filter: MangaListFilter): Preset {
val nowId = System.currentTimeMillis()
val preset = Preset(
id = nowId,
suspend fun save(
source: MangaSource,
name: String,
filter: MangaListFilter,
): PersistableFilter = withContext(Dispatchers.Default) {
val persistableFilter = PersistableFilter(
name = name,
source = source,
payload = serializeFilter(filter),
filter = filter,
)
val list = list(source) + preset
persist(source, list)
return preset
persist(source, persistableFilter)
persistableFilter
}
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)
suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
val filter = load(source, id) ?: return@withContext
persist(source, filter.copy(name = newName))
}
fun delete(source: String, id: Long) {
val list = list(source).filterNot { it.id == id }
persist(source, list)
}
private fun persist(source: String, list: List<Preset>) {
val root = JSONObject(prefs.getString(keyRoot, "{}"))
root.put(source, JSONArray(list.map { presetToJson(it) }))
prefs.edit { putString(keyRoot, root.toString()) }
state.value = state.value.toMutableMap().also { it[source] = list }
}
private fun loadAll() {
val root = JSONObject(prefs.getString(keyRoot, "{}"))
val map = mutableMapOf<String, List<Preset>>()
for (key in root.keys()) {
val arr = root.optJSONArray(key) ?: continue
map[key] = (0 until arr.length()).mapNotNull { i -> jsonToPreset(arr.optJSONObject(i), key) }
suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(FILTER_PREFIX + id)
}
state.value = map
}
private fun presetToJson(p: Preset): JSONObject = JSONObject().apply {
put("id", p.id)
put("name", p.name)
put("payload", p.payload)
private fun persist(source: MangaSource, persistableFilter: PersistableFilter) {
val prefs = getPrefs(source)
val json = Json.encodeToString(persistableFilter)
prefs.edit(commit = true) {
putString(FILTER_PREFIX + persistableFilter.id, json)
}
}
private fun jsonToPreset(obj: JSONObject?, source: String): Preset? {
obj ?: return null
val id = obj.optLong("id", 0L)
val name = obj.optString("name", null) ?: return null
val payload = obj.optJSONObject("payload") ?: return null
return Preset(id, name, source, payload)
private fun load(source: MangaSource, id: Int): PersistableFilter? {
val prefs = getPrefs(source)
val json = prefs.getString(FILTER_PREFIX + id, null) ?: return null
return try {
Json.decodeFromString<PersistableFilter>(json)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
fun serializeFilter(f: MangaListFilter): JSONObject = JSONObject().apply {
put("query", f.query)
put("author", f.author)
put("locale", f.locale?.toLanguageTag())
put("originalLocale", f.originalLocale?.toLanguageTag())
put("states", JSONArray(f.states.map { it.name }))
put("contentRating", JSONArray(f.contentRating.map { it.name }))
put("types", JSONArray(f.types.map { it.name }))
put("demographics", JSONArray(f.demographics.map { it.name }))
put("tags", JSONArray(f.tags.map { it.key }))
put("tagsExclude", JSONArray(f.tagsExclude.map { it.key }))
put("year", f.year)
put("yearFrom", f.yearFrom)
put("yearTo", f.yearTo)
}
private fun getPrefs(source: MangaSource) = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
fun deserializeFilter(
obj: JSONObject,
resolveTags: (Set<String>) -> Set<MangaTag>,
): MangaListFilter {
return MangaListFilter(
query = obj.optString("query").takeIf { it.isNotEmpty() },
author = obj.optString("author").takeIf { it.isNotEmpty() },
locale = obj.optString("locale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
originalLocale = obj.optString("originalLocale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
states = obj.optJSONArray("states")?.toStringSet()?.mapNotNull { runCatching { MangaState.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
contentRating = obj.optJSONArray("contentRating")?.toStringSet()?.mapNotNull { runCatching { ContentRating.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
types = obj.optJSONArray("types")?.toStringSet()?.mapNotNull { runCatching { ContentType.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
demographics = obj.optJSONArray("demographics")?.toStringSet()?.mapNotNull { runCatching { Demographic.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
tags = resolveTags(obj.optJSONArray("tags")?.toStringSet().orEmpty()).toSet(),
tagsExclude = resolveTags(obj.optJSONArray("tagsExclude")?.toStringSet().orEmpty()).toSet(),
year = obj.optInt("year"),
yearFrom = obj.optInt("yearFrom"),
yearTo = obj.optInt("yearTo"),
)
}
}
private fun JSONArray.toStringSet(): Set<String> = buildSet {
for (i in 0 until length()) {
val v = optString(i)
if (!v.isNullOrEmpty()) add(v)
private companion object {
const val FILTER_PREFIX = "__pf_"
}
}

View File

@@ -15,19 +15,18 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.model.unwrap
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.asFlow
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
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.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.json.JSONObject
import java.util.Calendar
import java.util.Locale
import javax.inject.Inject
@@ -66,27 +64,10 @@ class FilterCoordinator @Inject constructor(
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val currentPresetId = MutableStateFlow<Long?>(null)
private var lastAppliedPayload: JSONObject? = null
private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() }
init {
coroutineScope.launch {
currentListFilter.collect { lf ->
val applied = lastAppliedPayload
if (applied != null) {
val cur = savedFiltersRepository.serializeFilter(lf)
if (cur.toString() != applied.toString()) {
currentPresetId.value = null
lastAppliedPayload = null
}
}
}
}
}
val capabilities = repository.filterCapabilities
val mangaSource: MangaSource
@@ -273,11 +254,15 @@ class FilterCoordinator @Inject constructor(
MutableStateFlow(FilterProperty.EMPTY)
}
val savedPresets: StateFlow<List<SavedFiltersRepository.Preset>> =
savedFiltersRepository.observe(repository.source.unwrap().name)
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
val selectedPresetId: StateFlow<Long?> = currentPresetId
val savedFilters: StateFlow<FilterProperty<PersistableFilter>> = combine(
savedFiltersRepository.observeAll(repository.source),
currentListFilter,
) { available, applied ->
FilterProperty(
availableItems = available,
selectedItems = setOfNotNull(available.find { it.filter == applied }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY)
fun reset() {
currentListFilter.value = MangaListFilter.EMPTY
@@ -313,36 +298,16 @@ class FilterCoordinator @Inject constructor(
set(newFilter)
}
fun saveCurrentPreset(name: String) {
val preset = savedFiltersRepository.save(repository.source.unwrap().name, name, currentListFilter.value)
currentPresetId.value = preset.id
lastAppliedPayload = preset.payload
fun saveCurrentFilter(name: String) = coroutineScope.launch {
savedFiltersRepository.save(repository.source, name, currentListFilter.value)
}
fun applyPreset(preset: SavedFiltersRepository.Preset) {
coroutineScope.launch {
val available = filterOptions.asFlow().map { it.getOrNull()?.availableTags.orEmpty() }.first()
val byKey: (Set<String>) -> Set<MangaTag> = { keys ->
val all = available.associateBy { it.key }
keys.mapNotNull { all[it] }.toSet()
}
val filter = savedFiltersRepository.deserializeFilter(preset.payload, byKey)
setAdjusted(filter)
currentPresetId.value = preset.id
lastAppliedPayload = preset.payload
}
fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
savedFiltersRepository.rename(repository.source, id, newName)
}
fun renamePreset(id: Long, newName: String) {
savedFiltersRepository.rename(repository.source.unwrap().name, id, newName)
}
fun deletePreset(id: Long) {
savedFiltersRepository.delete(repository.source.unwrap().name, id)
if (currentPresetId.value == id) {
currentPresetId.value = null
lastAppliedPayload = null
}
fun deleteSavedFilter(id: Int) = coroutineScope.launch {
savedFiltersRepository.delete(repository.source, id)
}
fun setQuery(value: String?) {
@@ -517,57 +482,57 @@ class FilterCoordinator @Inject constructor(
emit(Result.failure(it))
}
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
val result = ArrayDeque<T>(this.size + other.size)
result.addAll(this)
for (item in other) {
if (item !in result) {
result.addFirst(item)
}
}
return result
}
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
val result = ArrayDeque<T>(this.size + other.size)
result.addAll(this)
for (item in other) {
if (item !in result) {
result.addFirst(item)
}
}
return result
}
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
val result = ArrayDeque<T>(this.size + 1)
result.addAll(this)
if (item !in result) {
result.addFirst(item)
}
return result
}
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
val result = ArrayDeque<T>(this.size + 1)
result.addAll(this)
if (item !in result) {
result.addFirst(item)
}
return result
}
data class Snapshot(
val sortOrder: SortOrder,
val listFilter: MangaListFilter,
)
data class Snapshot(
val sortOrder: SortOrder,
val listFilter: MangaListFilter,
)
interface Owner {
interface Owner {
val filterCoordinator: FilterCoordinator
}
val filterCoordinator: FilterCoordinator
}
companion object {
companion object {
private const val TAGS_LIMIT = 12
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
private const val TAGS_LIMIT = 12
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
fun find(fragment: Fragment): FilterCoordinator? {
(fragment.activity as? Owner)?.let {
return it.filterCoordinator
}
var f = fragment
while (true) {
(f as? Owner)?.let {
return it.filterCoordinator
}
f = f.parentFragment ?: break
}
return null
}
fun find(fragment: Fragment): FilterCoordinator? {
(fragment.activity as? Owner)?.let {
return it.filterCoordinator
}
var f = fragment
while (true) {
(f as? Owner)?.let {
return it.filterCoordinator
}
f = f.parentFragment ?: break
}
return null
}
fun require(fragment: Fragment): FilterCoordinator {
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
}
}
fun require(fragment: Fragment): FilterCoordinator {
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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