diff --git a/.editorconfig b/.editorconfig
index 63c49d65d..8cb8b3907 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -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
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index bda8001d6..c2fceb61c 100755
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -1,9 +1,7 @@
@@ -22,40 +20,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -64,7 +68,6 @@
-
@@ -179,9 +182,6 @@
-
-
-
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSourceSerializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSourceSerializer.kt
new file mode 100644
index 000000000..cae4ed3fc
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSourceSerializer.kt
@@ -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 {
+
+ override val descriptor: SerialDescriptor = serialDescriptor()
+
+ override fun serialize(
+ encoder: Encoder,
+ value: MangaSource
+ ) = encoder.encodeString(value.name)
+
+ override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt
index e18741e76..dd3fe5a88 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt
@@ -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.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.setRecyclerViewList(
- list: List,
- delegate: AdapterDelegate>,
+ list: List,
+ delegate: AdapterDelegate>,
) = apply {
- val delegatesManager = AdapterDelegatesManager>()
- delegatesManager.addDelegate(delegate)
- setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
+ val delegatesManager = AdapterDelegatesManager>()
+ delegatesManager.addDelegate(delegate)
+ setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun B.setRecyclerViewList(
- list: List,
- vararg delegates: AdapterDelegate>,
+ list: List,
+ vararg delegates: AdapterDelegate>,
) = apply {
- val delegatesManager = AdapterDelegatesManager>()
- delegates.forEach { delegatesManager.addDelegate(it) }
- setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
+ val delegatesManager = AdapterDelegatesManager>()
+ delegates.forEach { delegatesManager.addDelegate(it) }
+ setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun 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.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
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/MangaListFilterSerializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/MangaListFilterSerializer.kt
new file mode 100644
index 000000000..d13662ce0
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/MangaListFilterSerializer.kt
@@ -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 {
+
+ override val descriptor: SerialDescriptor =
+ buildClassSerialDescriptor(MangaListFilter::class.java.name) {
+ element("query", isOptional = true)
+ element(
+ elementName = "tags",
+ descriptor = SetSerializer(MangaTagSerializer).descriptor,
+ isOptional = true,
+ )
+ element(
+ elementName = "tagsExclude",
+ descriptor = SetSerializer(MangaTagSerializer).descriptor,
+ isOptional = true,
+ )
+ element("locale", isOptional = true)
+ element("originalLocale", isOptional = true)
+ element>("states", isOptional = true)
+ element>("contentRating", isOptional = true)
+ element>("types", isOptional = true)
+ element>("demographics", isOptional = true)
+ element("year", isOptional = true)
+ element("yearFrom", isOptional = true)
+ element("yearTo", isOptional = true)
+ element("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 = MangaListFilter.EMPTY.tags
+ var tagsExclude: Set = MangaListFilter.EMPTY.tagsExclude
+ var locale: Locale? = MangaListFilter.EMPTY.locale
+ var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale
+ var states: Set = MangaListFilter.EMPTY.states
+ var contentRating: Set = MangaListFilter.EMPTY.contentRating
+ var types: Set = MangaListFilter.EMPTY.types
+ var demographics: Set = 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())
+ 1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer))
+ 2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer))
+ 3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer())?.toLocaleOrNull()
+ 4 -> originalLocale =
+ decodeNullableSerializableElement(descriptor, 4, serializer())?.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())
+ 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 {
+
+ override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) {
+ element("title")
+ element("key")
+ element("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),
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/PersistableFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/PersistableFilter.kt
new file mode 100644
index 000000000..12e6093cb
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/PersistableFilter.kt
@@ -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
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt
index eee0451da..c5300be2d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt
@@ -1,152 +1,99 @@
package org.koitharu.kotatsu.filter.data
-import android.content.SharedPreferences
+import android.content.Context
import androidx.core.content.edit
-import androidx.preference.PreferenceManager
+import dagger.Reusable
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 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.MangaTag
-import org.koitharu.kotatsu.parsers.model.MangaState
-import java.util.Locale
+import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
-import javax.inject.Singleton
-import android.content.Context
-@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"
- private val state = MutableStateFlow