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 @@ + + + - - - - - - - - - - - - - - - - - - - - - - - - \ 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>>(emptyMap()) - - init { - scope.launch { loadAll() } - } - - data class Preset( - val id: Long, - val name: String, - val source: String, - val payload: JSONObject, - ) - - fun observe(source: String): StateFlow> = MutableStateFlow(state.value[source].orEmpty()).also { out -> - scope.launch { - state.collect { all -> out.value = all[source].orEmpty() } + fun observeAll(source: MangaSource): Flow> = getPrefs(source).observeChanges() + .onStart { emit(null) } + .map { + getAll(source) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + + suspend fun getAll(source: MangaSource): List = 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 = 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 - } - - 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) + persist(source, persistableFilter) + persistableFilter } - fun delete(source: String, id: Long) { - val list = list(source).filterNot { it.id == id } - 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)) } - private fun persist(source: String, list: List) { - val root = JSONObject(prefs.getString(keyRoot, "{}")) - root.put(source, JSONArray(list.map { presetToJson(it) })) - prefs.edit { putString(keyRoot, root.toString()) } - state.value = state.value.toMutableMap().also { it[source] = list } + suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) { + val prefs = getPrefs(source) + prefs.edit(commit = true) { + remove(FILTER_PREFIX + id) + } } - private fun loadAll() { - val root = JSONObject(prefs.getString(keyRoot, "{}")) - val map = mutableMapOf>() - for (key in root.keys()) { - val arr = root.optJSONArray(key) ?: continue - map[key] = (0 until arr.length()).mapNotNull { i -> jsonToPreset(arr.optJSONObject(i), key) } + 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) } - 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 load(source: MangaSource, id: Int): PersistableFilter? { + val prefs = getPrefs(source) + val json = prefs.getString(FILTER_PREFIX + id, null) ?: return null + return try { + Json.decodeFromString(json) + } catch (e: SerializationException) { + e.printStackTraceDebug() + null + } } - private fun jsonToPreset(obj: JSONObject?, source: String): Preset? { - obj ?: return null - val id = obj.optLong("id", 0L) - val name = obj.optString("name", null) ?: return null - val payload = obj.optJSONObject("payload") ?: return null - return Preset(id, name, source, payload) - } + private fun getPrefs(source: MangaSource) = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) - fun serializeFilter(f: MangaListFilter): JSONObject = JSONObject().apply { - put("query", f.query) - put("author", f.author) - put("locale", f.locale?.toLanguageTag()) - put("originalLocale", f.originalLocale?.toLanguageTag()) - put("states", JSONArray(f.states.map { it.name })) - put("contentRating", JSONArray(f.contentRating.map { it.name })) - put("types", JSONArray(f.types.map { it.name })) - put("demographics", JSONArray(f.demographics.map { it.name })) - put("tags", JSONArray(f.tags.map { it.key })) - put("tagsExclude", JSONArray(f.tagsExclude.map { it.key })) - put("year", f.year) - put("yearFrom", f.yearFrom) - put("yearTo", f.yearTo) - } - - fun deserializeFilter( - obj: JSONObject, - resolveTags: (Set) -> Set, - ): MangaListFilter { - return MangaListFilter( - query = obj.optString("query").takeIf { it.isNotEmpty() }, - author = obj.optString("author").takeIf { it.isNotEmpty() }, - locale = obj.optString("locale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) }, - originalLocale = obj.optString("originalLocale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) }, - states = obj.optJSONArray("states")?.toStringSet()?.mapNotNull { runCatching { MangaState.valueOf(it) }.getOrNull() }?.toSet().orEmpty(), - contentRating = obj.optJSONArray("contentRating")?.toStringSet()?.mapNotNull { runCatching { ContentRating.valueOf(it) }.getOrNull() }?.toSet().orEmpty(), - types = obj.optJSONArray("types")?.toStringSet()?.mapNotNull { runCatching { ContentType.valueOf(it) }.getOrNull() }?.toSet().orEmpty(), - demographics = obj.optJSONArray("demographics")?.toStringSet()?.mapNotNull { runCatching { Demographic.valueOf(it) }.getOrNull() }?.toSet().orEmpty(), - tags = resolveTags(obj.optJSONArray("tags")?.toStringSet().orEmpty()).toSet(), - tagsExclude = resolveTags(obj.optJSONArray("tagsExclude")?.toStringSet().orEmpty()).toSet(), - year = obj.optInt("year"), - yearFrom = obj.optInt("yearFrom"), - yearTo = obj.optInt("yearTo"), - ) - } -} + private companion object { -private fun JSONArray.toStringSet(): Set = buildSet { - for (i in 0 until length()) { - val v = optString(i) - if (!v.isNullOrEmpty()) add(v) + const val FILTER_PREFIX = "__pf_" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 791265a88..b1896a086 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -15,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(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> = - savedFiltersRepository.observe(repository.source.unwrap().name) - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - - val selectedPresetId: StateFlow = currentPresetId + val savedFilters: StateFlow> = 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) -> Set = { keys -> - val all = available.associateBy { it.key } - keys.mapNotNull { all[it] }.toSet() - } - val filter = savedFiltersRepository.deserializeFilter(preset.payload, byKey) - setAdjusted(filter) - currentPresetId.value = preset.id - lastAppliedPayload = preset.payload - } + fun 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 List.addFirstDistinct(other: Collection): List { - val result = ArrayDeque(this.size + other.size) - result.addAll(this) - for (item in other) { - if (item !in result) { - result.addFirst(item) - } - } - return result - } - - private fun List.addFirstDistinct(item: T): List { - val result = ArrayDeque(this.size + 1) - result.addAll(this) - if (item !in result) { - result.addFirst(item) - } - return result - } - - data class Snapshot( - val sortOrder: SortOrder, - val listFilter: MangaListFilter, - ) - - interface Owner { - - val filterCoordinator: FilterCoordinator - } - - companion object { - - 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 require(fragment: Fragment): FilterCoordinator { - return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found") - } - } + private fun List.addFirstDistinct(other: Collection): List { + val result = ArrayDeque(this.size + other.size) + result.addAll(this) + for (item in other) { + if (item !in result) { + result.addFirst(item) + } + } + return result + } + + private fun List.addFirstDistinct(item: T): List { + val result = ArrayDeque(this.size + 1) + result.addAll(this) + if (item !in result) { + result.addFirst(item) + } + return result + } + + data class Snapshot( + val sortOrder: SortOrder, + val listFilter: MangaListFilter, + ) + + interface Owner { + + val filterCoordinator: FilterCoordinator + } + + companion object { + + 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 require(fragment: Fragment): FilterCoordinator { + return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found") + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index 61cbd65a6..cde7079a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -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(), 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) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt index e30fdd6c0..6a67926af 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -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 { - 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 { + 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, - snapshot: MangaListFilter, - limit: Int, - ): List { - val result = ArrayDeque(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, + tagsProperty: FilterProperty, + snapshot: MangaListFilter, + limit: Int, + ): List { + val result = ArrayDeque(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, + ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index 7f7849a4d..336f9c9f5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -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(), - 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, 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 { + 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) { + 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) { + 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { + 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) { - 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) { - 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() + } } diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml index a151fd9eb..ebd9dbf75 100644 --- a/app/src/main/res/layout/sheet_filter.xml +++ b/app/src/main/res/layout/sheet_filter.xml @@ -1,287 +1,313 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + 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"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/popup_saved_filter.xml b/app/src/main/res/menu/popup_saved_filter.xml new file mode 100644 index 000000000..a69b54433 --- /dev/null +++ b/app/src/main/res/menu/popup_saved_filter.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b63231d7..552c3935b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,894 +1,896 @@ - Kotatsu - Local storage - Favorites - History - An error occurred - Network error - Details - Chapters - Liste - Detailed list - Grid - List mode - Settings - Manga sources - Loading… - Computing… - Chapter %1$d of %2$d - Close - Try again - - Retry - Clear history - Nothing found - No history yet - Read - No favorites yet - Favorite this - New category - Add - Save - Share - Create shortcut - Share %s - Search - Search manga - Downloading… - Processing… - Downloaded - Downloads - Name - Popular - Updated - Newest - Rating - Sorting order - Filter - Saved filters - Theme - Light - Dark - - Follow system - Pages - Clear - Remove - \"%s\" deleted from local storage - Save page - Page saved - Pages saved - Share image - Import - Delete - This operation is not supported - Either pick a ZIP or CBZ file. - No description - Clear page cache - B|kB|MB|GB|TB - Standard - Webtoon - Read mode - Grid size - Search on %s - Delete manga - Permanently delete \"%s\" from device? - Reader settings - Switch pages - Continue - Error - Clear thumbnails cache - Clear search history - Cleared - Internal storage - External storage - Domain - A new version of the app is available - Open in web browser - Notifications - %1$d of %2$d on - New chapters - Download - Notifications settings - Notification sound - LED indicator - Vibration - Favorite categories - Remove - It\'s kind of empty here… - Try to reformulate the query. - What you read will be displayed here - Find what to read in the «Explore» section - There are no manga matching the filters you selected - Save something first - Save something from an online catalog or import it from a file. - Shelf - Recent - Page animation - Downloads folder - Not available - No available storage - Other storage - Done - All favorites - Empty category - Read later - Updates - New chapters of what you are reading are shown here - Search results - New version: %s - Size: %s - Clear updates feed - Cleared - Rotate screen - Update - Feed update will start soon - Look for updates - Don\'t check - Enter password - Wrong password - Protect the app - Ask for password when starting Kotatsu - Repeat the password - Mismatching passwords - About - Version %s - Check for updates - No updates available - Right-to-left - New category - Scale mode - Fit center - Fit to height - Fit to width - Keep at start - Black - Uses less power on AMOLED screens - Backup and restore - Create data backup - Restore from backup - Restored - Preparing… - File not found - All data was restored - The data was restored, but there are errors - You can create backup of your history and favorites and restore it - Just now - Yesterday - Long ago - Group - Today - Tap to try again - The chosen configuration will be remembered for this manga - Silent - CAPTCHA required - Solve - Clear cookies - All cookies were removed - Clear feed - Clear all update history permanently? - Check for new chapters - Reverse - Grid view - Sign in - Sign in to view this content - Default: %s - Next - Enter a password to start the app with - Confirm - The password must be 4 characters or more - Remove all recent search queries permanently? - Welcome - Backup saved - Some devices have different system behavior, which may break background tasks. - Read more - Queued - The chapter is missing - Translate this app - Translation - Authorized - Logging in on %s is not supported - You will be logged out from all sources - Genres - Finished - Ongoing - Default - Exclude NSFW manga from history - Numbered pages - Screenshot policy - Allow - Block on NSFW - Always block - Suggestions - Enable suggestions - Suggest manga based on your preferences - All data is only analyzed locally on this device and never sent anywhere. - Start reading manga and you will get personalized suggestions - Do not suggest NSFW manga - Enabled - Disabled - Reset filter - Enter name - Select languages which you want to read manga. You can change it later in settings. - Never - Only on Wi-Fi - Always - Preload pages - Logged in as %s - 18+ - 16+ - Various languages - Find chapter - No chapters in this manga - %1$s%% - Appearance - Suggestions updating - Exclude genres - Specify genres that you do not want to see in the suggestions - Delete selected items from device permanently? - Removal completed - Shikimori - AniList - Download slowdown - Helps avoid blocking your IP address - Saved manga processing - Chapters will be removed in the background - Canceled - Account already exists - Back - Synchronization - Sync your data - Enter your email to continue - Hide - New manga sources are available - Check for new chapters and notify about it - You will receive notifications about updates of manga you are reading - You will not receive notifications but new chapters will be highlighted in the lists - Enable notifications - Name - Edit - Edit category - Tracking - No favorite categories - Log out - Add bookmark - Remove bookmark - Bookmarks - Bookmark removed - Bookmark added - Undo - Removed from history - DNS over HTTPS - Default mode - Autodetect reader mode - Automatically detect if manga is webtoon - Disable battery optimization - Helps with background updates checks - Something went wrong. Please submit a bug report to the developers to help us fix it. - Send - Planned - Reading - Re-reading - Completed - On hold - Dropped - Disable all - Use biometric if available - Manga from your favorites - Your recently read manga - Report - Show reading progress indicators - Data deletion - Show percentage read in history and favorites - Manga marked as NSFW will never be added to the history and your progress will not be saved - Can help in case of some issues. All authorizations will be invalidated - Show all - Invalid domain - Invalid server address - Select range - Clear all history - Last 2 hours - History cleared - Manage - No bookmarks yet - You can create bookmark while reading manga - Bookmarks removed - No manga sources - Enable manga sources to read manga online - Random - Are you sure you want to delete the selected favorite categories?\nAll manga in it will be lost and this cannot be undone. - Reorder - Empty - Explore - Press "Back" again to exit - Press "Back" twice to exit the app - Exit confirmation - Saved manga - Pages cache - Other cache - Storage usage - Available - %1$s - %2$s - Removed from favorites - Options - Content not found or removed - %1$s · %2$s - Incognito mode - No chapters - Automatic scroll - Ch. %1$d/%2$d Pg. %3$d/%4$d - Show information bar in reader - Comics archive - Folder with images - Importing manga - Import completed - You can delete the original file from storage to save space - Import will start soon - Feed - Error details:<br><tt>%1$s</tt><br><br>1. Try to <a href="%2$s">open manga in a web browser</a> to ensure it is available on its source<br>2. Make sure you are using the <a href="kotatsu://about">latest version of Kotatsu</a><br>3. If it is available, send an error report to the developers. - Show recent manga shortcuts - Make recent manga available by long pressing on application icon - Do not adjust the page switching direction to the reader mode, e. g. pressing the right key always switches to the next page. This option affects only hardware input devices - Ergonomic reader control - Color correction - Brightness - Contrast - Reset - Save or discard unsaved changes\? - Discard - No space left on device - Show page switching slider - Webtoon zoom - Network is not available - Turn on Wi-Fi or mobile network to read manga online - Server side error (%1$d). Please try again later - Also clear information about new chapters - Compact - MyAnimeList - Source disabled - Content preloading - Mark as current - Language - Share logs - Enable logging - Record some actions for debug purposes. Don\'t turn it on if you\'re not sure what you\'re doing - Show suspicious content - Dynamic - Expressive (Test) - Color scheme - Show in grid view - Miku - Asuka - Mion - Rikka - Sakura - Mamimi - Kanade - There is nothing here - To track reading progress, select Menu → Track on the manga details screen. - Services - Allow unstable updates - Receive notifications about unstable builds - Download started - Got it - Tap and hold on an item to reorder them - UserAgent header - Please restart the application to apply these changes - You can select one or more .cbz or .zip files, each file will be recognized as a separate manga. - You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter. - Speed - Show on the Shelf - You can sign in into an existing account or create a new one - Find similar - Synchronization settings - Server address - You can use a self-hosted synchronization server or a default one. Don\'t change this if you\'re not sure what you\'re doing. - Ignore SSL errors - Choose mirror automatically - Automatically switch domains for manga sources on errors if mirrors are available - Pause - Resume - Paused - - Remove completed - Cancel all - Download only via Wi-Fi - Stop downloading when switching to a mobile network - Suggestion: %s - Sometimes show notifications with suggested manga - More - Enable - No thanks - All active downloads will be cancelled, partially downloaded data will be lost - Your downloads history will be permanently deleted. No downloaded files will be affected - You don\'t have any downloads - Downloads have been resumed - Downloads have been paused - Downloads have been removed - Downloads have been cancelled - Do you want to receive personalized manga suggestions? - WebView not available: check if WebView provider is installed - Clear network cache - Type - Address - Port - Proxy - Invalid value - Kitsu - Enter your email and password to continue - Downloaded - Images optimization proxy - Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible - Invert colors - Username - Password - Authorization (optional) - Invalid port number - Network - Data and privacy - Restore previously created backup - Allow zoom in gesture in webtoon mode - Release to open previous chapter - Release to open next chapter - No previous chapter - No next chapter - Show the current time and reading progress at the top of the screen - Show page numbers in bottom corner - Clear cookies for specified domain only. In most cases will invalidate authorization - All chapters with translation %s - The whole manga - First %s - Next unread %s - All unread chapters - All unread chapters (%s) - Select chapters manually - Pick custom directory - You have no access to this file or directory - Local manga directories - Description - This month - Voice search - Related manga - Light - Dark - White - Black - Background - Data was not restored - Make sure you have selected the correct backup file - Manage categories - Do not update suggestions using metered network connections - Do not check for new chapters using metered network connections - Enter manga title, genre or source name - Progress - Added - Show - %s requires a captcha to be resolved to work properly - Languages - Unknown - In progress - Disable NSFW - Too many requests. Try again later - Too many requests. Try again after %s - Show a list of related manga. In some cases it may be inaccurate or missing - Advanced - Manga list - Invalid data is returned or file is corrupted - On device - Directories - Main screen sections - No more items can be added - To top - Moved to top - Zoom out - Zoom in - Show zoom buttons - Whether to show zoom control buttons in the bottom right corner - Keep screen on - Do not turn the screen off while you\'re reading manga - Dropped - Reduces banding, but may impact performance - 32-bit color mode - Suggest new sources after app update - Prompt to enable newly added sources after updating the application - List options - Relevance - Categories - Online variant - Periodic backups - Backup creation frequency - Every day - Every 2 days - Once per week - Twice per month - Once per month - Enable periodic backups - Backups output directory - Last successful backup: %s - x%.1f - Lock screen rotation - Manga - Hentai - Comics - Other - %1$s, %2$s - Sources catalog - Source enabled - There are no sources available in this section, or all of it might have been already added.\nStay tuned - No available manga sources found by your query - Catalog - Manage sources - Manual - Available: %1$d - Disable NSFW sources and hide adult manga from list if possible - Paused - Reduce memory consumption (beta) - Reduce offscreen pages quality to use less memory - State - Filtering by multiple genres is not supported by this manga source - Filtering by multiple states is not supported by this manga source - Search is not supported by this manga source - You can enable download slowdown for each manga source individually in the source settings if you are having problems with server-side blocking - Skip - Grayscale - Globally - This manga - These settings can be applied globally or only to the current manga. If applied globally, individual settings will not be overridden. - Apply - Filtering by both genres and locale is not supported by this source - Filtering by both genres and states is not supported by this source - Start typing the genre name - Might help with getting the download started if you have any issues with it - Please select which content sources you would like to enable. This can also be configured later in settings - Login to sync account - Restore - Backup date: %s - Upcoming - Name reversed - Content rating - Exclude genres - Safe - Suggestive - Adult - Default tab - Mark as completed - Mark selected manga as completely read?\n\nWarning: current reading progress will be lost. - This category was hidden from the main screen and is accessible through Menu → Manage categories - %1$s %2$s - Volume %d - Unknown volume - Your reading progress will not be saved - Vertical - Last read - Show menu - Show/hide UI - Previous chapter - Next chapter - Previous page - Next page - Reader actions - Configure actions for tappable screen areas - Enable volume buttons - Use volume buttons for switching pages - Invert navigation controls - Swap the direction of volume button and directional hardware key navigation (left/up/down/right) - Tap action - Long tap action - None - Reset settings to default values? This action cannot be undone. - Use two pages layout on landscape orientation (beta) - Two-Page Scroll Sensitivity - Default webtoon zoom out - Fullscreen mode - Hide system status and navigation bars - %1$d/%2$d - Show estimated reading time - The time estimation value may be inaccurate - Suggestions feature is disabled - Checking for new chapters is disabled - Show labels in navigation bar - Saving pages - Ask for the destination dir every time - Default page save directory - Remove from history - Location - Preferred download format - Automatic - Single CBZ file - Multiple CBZ files - Reading statistics - Other manga - Less than a minute - Statistics - Clear statistics - Statistics cleared - Do you really want to clear all reading statistics? This action cannot be undone. - Week - Month - All time - Day - Three months - There are no statistics for the selected period - Pages read: %s - Alternatives - Migrate - Manga \"%1$s\" from \"%2$s\" will be replaced with \"%3$s\" from \"%4$s\" in your history and favorites (if present) - Manga migration - Migration completed - Delete read chapters - No chapters have been deleted - Removed %1$s, cleared %2$s - Delete chapters you have already read from local storage to free up space - This will permanently delete all chapters marked as read from your local storage. You can re-download it later, but the imported chapters may be lost forever - Delete read chapters automatically - Runs when the application starts - Split by translations - Show chapters with different translations separately, rather than in one list - Oldest - Long time ago read - Unread - Enable source - This manga source is not supported - Show pages thumbnails - Enable the \"Pages\" tab on the details screen - No data was received from server - Please select a proper Kotatsu backup file - (+%d) - - %d h - - %d m - - %d s - - %1$d h %2$d m - - %1$d m %2$d s - Fix - There is no permission to access manga on external storage - Last used - Show updated - Gaps in webtoon mode - Show vertical gaps between pages in webtoon mode - Enable pull gesture - Use pull gesture to switch chapters in webtoon - Less frequently - More frequently - Frequency of check - %1$s: %2$d - Pin navigation UI - Do not hide navigation bar and search view on scroll - Search suggestions - Recent queries - Suggested queries - Authors - You are blocked by the server. Try to use a different network connection (VPN, Proxy, etc.) - Disable - Sources disabled - Disable connectivity check - You can disable SSL certificates verification in case you face an SSL-related issues when accessing network resources. This may affect your security. Application restarting is required after changing this setting. - Skip the connectivity check in case you have issues with it (e.g. going offline mode even though the network is connected) - Disable NSFW notifications - Do not show notifications about NSFW manga updates - Checking for new chapters log - Debug information about background checks for new chapters - - New - All languages - Block when incognito mode - Preferred image server - %1$s: %2$s - Crop pages - Pin - Unpin - Source pinned - Source unpinned - Sources unpinned - Sources pinned - Recent sources - Percent read - Percent left - Chapters read - Chapters left - External/plugin - Incompatible plugin or internal error. Make sure you are using the latest version of the plugin and Kotatsu - Plugin error: %s\n Make sure you are using the latest version of the plugin and Kotatsu - Connection is OK - Invalid proxy configuration - Show quick filters - Provides the ability to filter manga lists by certain parameters - SFW - Skip all - Stuck - Not in favorites - Updated long ago - Unpopular - Low rating - Ascending - Descending - Date - Popularity - Sign in to %s to continue - Sign in to set up integration with %s. This will allow you to track your manga reading progress and status - Unstable feature - This function is experimental. Please make sure you have a backup to avoid data loss - Background downloads - Download new chapters - Manga with downloaded chapters - Manga \"%1$s\" (%2$s) replaced with \"%3$s\" (%4$s) - Fixing manga - Fixed successfully - No fix required for \"%s\" - No alternatives found for \"%s\" - This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background - Novel - Manhua - Manhwa - Recently added - Added long ago - Popular this hour - Popular today - Popular this week - Popular this month - Popular this year - Original language - Year - Demographics - Shounen - Shoujo - Seinen - Josei - Years - Any - This source does not support search with filters. Your filters have been cleared - Kodomo - One shot - Doujinshi - Image set - Artist CG - Game CG - Debug - Source code - User manual - Telegram group - Unsupported image format: %s - Invalid format: expected image but got %s - Start download - Save selected manga? This may consume traffic and disk space - Save manga - Genre - Download added - More options - Destination directory - You can select chapters to download by long click on item in the chapter list. - - All - Downloading over cellular network - Allow downloads over cellular network? - Don\'t allow - Allow always - Allow once - Ask every time - Screen orientation - Portrait - Landscape - "]]> - Access denied (403) - Max number of backups - Delete old backups - Automatically delete old backup files to save storage space - Handle links - Handle manga links from external applications (e.g. web browser). You may also need to enable it manually in the application\'s system settings - Email - This source requires solving a captcha to continue - Author - Rating - Source - Translation - %1$s (%2$s) - Show slider - - Incognito - Connection reset by remote host - Check if API works - Test message - Chat ID is not set - Telegram chat ID - Open the Telegram bot - Send backups in Telegram - Test connection - Enter the chat ID where backups should be sent - Press to open chat with Kotatsu Backup Bot - Clear database - Delete information about manga that is not used - Enable all manga sources - All available manga sources will be enabled permanently - All sources are enabled - Show chapter change popup - Show a pop-up message with a chapter title when it is changed - Transparent reader information bar - The backup will be restored in the background - Restoring backup - Reader controls in bottom bar - Chapters and pages - Page switch slider - Screen rotation has been locked - Screen rotation has been unlocked - Badges in lists - Search everywhere - Simple - Global search - Disable captcha notifications - You will not receive notifications about solving CAPTCHA for this source but this can lead to breaking background operations (checking for new chapters, obtaining recommendations, etc) - Vol %1$s Chapter %2$s - Chapter %s - Unnamed chapter - Search through disabled sources - Error details - Try to open manga in a web browser to ensure it is available on its source. - It looks like your version of Kotatsu is out of date. Please install the latest version to get all available fixes. - You can submit a bug report to the developers. This will help us investigate and fix the issue. - Link to manga on %s - Link to manga in Kotatsu - Clear browser data - Clear browser data such as cache and cookies. Warning: Authorization in manga sources may become invalid - Does not have permission to write a file - Adult manga will not be shown in suggestions. This option may work inaccurate with some sources - Include disabled sources - Show suggestions from all manga sources, including disabled ones - Highlight dangerous genres - Highlight genres that may be inappropriate for most users - The selected path cannot be used because it does not denote a file or directory - These changes will affect how manga is displayed in the app - Use default cover - Pick manga page - Pick custom file - Change cover - The page will switch every ~%d seconds - Don\'t ask again - This manga may contain adult content. Do you want to use incognito mode? - Incognito mode for NSFW manga - Additional action is required - Hide from main screen - Changelog - Changes history for recently released versions - Collapse - Expand - Block ads in browser - Block advertisement in the built-in browser (beta) - Collapse long description - Creating backup - Share backup - Open reader in a separate task - Allows you to keep multiple readers with different manga open at the same time - Itsuka - Totoro - Yellowish background (blue filter) - Local storage cleanup - Failed to create backup - Main screen - Show floating Continue button - Allows to continue reading in a one click. This button will not appear in incognito mode or when the history is empty - Corrupted ZIP archive (%s) - Discord - Discord Rich Presence - Discord Token - Enter your Discord Token to enable Rich Presence - Enter your Discord Token or click %s to get it using browser - Paste your Discord Token here - Show your reading status on Discord - Obtain - Reading manga on Kotatsu - a manga reader app - Reading %s - Read on %s - Do not use RPC for adult content - Invalid token: %s - Show floating control button - Unavailable - This manga is not available to read in this source. Try searching for it in other sources or opening it in a browser for more information - This manga does not contain any chapters - Failed to load chapter list - Telegram integration - Test manga source + Kotatsu + Local storage + Favorites + History + An error occurred + Network error + Details + Chapters + Liste + Detailed list + Grid + List mode + Settings + Manga sources + Loading… + Computing… + Chapter %1$d of %2$d + Close + Try again + + Retry + Clear history + Nothing found + No history yet + Read + No favorites yet + Favorite this + New category + Add + Save + Share + Create shortcut + Share %s + Search + Search manga + Downloading… + Processing… + Downloaded + Downloads + Name + Popular + Updated + Newest + Rating + Sorting order + Filter + Saved filters + Theme + Light + Dark + + Follow system + Pages + Clear + Remove + \"%s\" deleted from local storage + Save page + Page saved + Pages saved + Share image + Import + Delete + This operation is not supported + Either pick a ZIP or CBZ file. + No description + Clear page cache + B|kB|MB|GB|TB + Standard + Webtoon + Read mode + Grid size + Search on %s + Delete manga + Permanently delete \"%s\" from device? + Reader settings + Switch pages + Continue + Error + Clear thumbnails cache + Clear search history + Cleared + Internal storage + External storage + Domain + A new version of the app is available + Open in web browser + Notifications + %1$d of %2$d on + New chapters + Download + Notifications settings + Notification sound + LED indicator + Vibration + Favorite categories + Remove + It\'s kind of empty here… + Try to reformulate the query. + What you read will be displayed here + Find what to read in the «Explore» section + There are no manga matching the filters you selected + Save something first + Save something from an online catalog or import it from a file. + Shelf + Recent + Page animation + Downloads folder + Not available + No available storage + Other storage + Done + All favorites + Empty category + Read later + Updates + New chapters of what you are reading are shown here + Search results + New version: %s + Size: %s + Clear updates feed + Cleared + Rotate screen + Update + Feed update will start soon + Look for updates + Don\'t check + Enter password + Wrong password + Protect the app + Ask for password when starting Kotatsu + Repeat the password + Mismatching passwords + About + Version %s + Check for updates + No updates available + Right-to-left + New category + Scale mode + Fit center + Fit to height + Fit to width + Keep at start + Black + Uses less power on AMOLED screens + Backup and restore + Create data backup + Restore from backup + Restored + Preparing… + File not found + All data was restored + The data was restored, but there are errors + You can create backup of your history and favorites and restore it + Just now + Yesterday + Long ago + Group + Today + Tap to try again + The chosen configuration will be remembered for this manga + Silent + CAPTCHA required + Solve + Clear cookies + All cookies were removed + Clear feed + Clear all update history permanently? + Check for new chapters + Reverse + Grid view + Sign in + Sign in to view this content + Default: %s + Next + Enter a password to start the app with + Confirm + The password must be 4 characters or more + Remove all recent search queries permanently? + Welcome + Backup saved + Some devices have different system behavior, which may break background tasks. + Read more + Queued + The chapter is missing + Translate this app + Translation + Authorized + Logging in on %s is not supported + You will be logged out from all sources + Genres + Finished + Ongoing + Default + Exclude NSFW manga from history + Numbered pages + Screenshot policy + Allow + Block on NSFW + Always block + Suggestions + Enable suggestions + Suggest manga based on your preferences + All data is only analyzed locally on this device and never sent anywhere. + Start reading manga and you will get personalized suggestions + Do not suggest NSFW manga + Enabled + Disabled + Reset filter + Enter name + Select languages which you want to read manga. You can change it later in settings. + Never + Only on Wi-Fi + Always + Preload pages + Logged in as %s + 18+ + 16+ + Various languages + Find chapter + No chapters in this manga + %1$s%% + Appearance + Suggestions updating + Exclude genres + Specify genres that you do not want to see in the suggestions + Delete selected items from device permanently? + Removal completed + Shikimori + AniList + Download slowdown + Helps avoid blocking your IP address + Saved manga processing + Chapters will be removed in the background + Canceled + Account already exists + Back + Synchronization + Sync your data + Enter your email to continue + Hide + New manga sources are available + Check for new chapters and notify about it + You will receive notifications about updates of manga you are reading + You will not receive notifications but new chapters will be highlighted in the lists + Enable notifications + Name + Edit + Edit category + Tracking + No favorite categories + Log out + Add bookmark + Remove bookmark + Bookmarks + Bookmark removed + Bookmark added + Undo + Removed from history + DNS over HTTPS + Default mode + Autodetect reader mode + Automatically detect if manga is webtoon + Disable battery optimization + Helps with background updates checks + Something went wrong. Please submit a bug report to the developers to help us fix it. + Send + Planned + Reading + Re-reading + Completed + On hold + Dropped + Disable all + Use biometric if available + Manga from your favorites + Your recently read manga + Report + Show reading progress indicators + Data deletion + Show percentage read in history and favorites + Manga marked as NSFW will never be added to the history and your progress will not be saved + Can help in case of some issues. All authorizations will be invalidated + Show all + Invalid domain + Invalid server address + Select range + Clear all history + Last 2 hours + History cleared + Manage + No bookmarks yet + You can create bookmark while reading manga + Bookmarks removed + No manga sources + Enable manga sources to read manga online + Random + Are you sure you want to delete the selected favorite categories?\nAll manga in it will be lost and this cannot be undone. + Reorder + Empty + Explore + Press "Back" again to exit + Press "Back" twice to exit the app + Exit confirmation + Saved manga + Pages cache + Other cache + Storage usage + Available + %1$s - %2$s + Removed from favorites + Options + Content not found or removed + %1$s · %2$s + Incognito mode + No chapters + Automatic scroll + Ch. %1$d/%2$d Pg. %3$d/%4$d + Show information bar in reader + Comics archive + Folder with images + Importing manga + Import completed + You can delete the original file from storage to save space + Import will start soon + Feed + Error details:<br><tt>%1$s</tt><br><br>1. Try to <a href="%2$s">open manga in a web browser</a> to ensure it is available on its source<br>2. Make sure you are using the <a href="kotatsu://about">latest version of Kotatsu</a><br>3. If it is available, send an error report to the developers. + Show recent manga shortcuts + Make recent manga available by long pressing on application icon + Do not adjust the page switching direction to the reader mode, e. g. pressing the right key always switches to the next page. This option affects only hardware input devices + Ergonomic reader control + Color correction + Brightness + Contrast + Reset + Save or discard unsaved changes\? + Discard + No space left on device + Show page switching slider + Webtoon zoom + Network is not available + Turn on Wi-Fi or mobile network to read manga online + Server side error (%1$d). Please try again later + Also clear information about new chapters + Compact + MyAnimeList + Source disabled + Content preloading + Mark as current + Language + Share logs + Enable logging + Record some actions for debug purposes. Don\'t turn it on if you\'re not sure what you\'re doing + Show suspicious content + Dynamic + Expressive (Test) + Color scheme + Show in grid view + Miku + Asuka + Mion + Rikka + Sakura + Mamimi + Kanade + There is nothing here + To track reading progress, select Menu → Track on the manga details screen. + Services + Allow unstable updates + Receive notifications about unstable builds + Download started + Got it + Tap and hold on an item to reorder them + UserAgent header + Please restart the application to apply these changes + You can select one or more .cbz or .zip files, each file will be recognized as a separate manga. + You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter. + Speed + Show on the Shelf + You can sign in into an existing account or create a new one + Find similar + Synchronization settings + Server address + You can use a self-hosted synchronization server or a default one. Don\'t change this if you\'re not sure what you\'re doing. + Ignore SSL errors + Choose mirror automatically + Automatically switch domains for manga sources on errors if mirrors are available + Pause + Resume + Paused + + Remove completed + Cancel all + Download only via Wi-Fi + Stop downloading when switching to a mobile network + Suggestion: %s + Sometimes show notifications with suggested manga + More + Enable + No thanks + All active downloads will be cancelled, partially downloaded data will be lost + Your downloads history will be permanently deleted. No downloaded files will be affected + You don\'t have any downloads + Downloads have been resumed + Downloads have been paused + Downloads have been removed + Downloads have been cancelled + Do you want to receive personalized manga suggestions? + WebView not available: check if WebView provider is installed + Clear network cache + Type + Address + Port + Proxy + Invalid value + Kitsu + Enter your email and password to continue + Downloaded + Images optimization proxy + Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible + Invert colors + Username + Password + Authorization (optional) + Invalid port number + Network + Data and privacy + Restore previously created backup + Allow zoom in gesture in webtoon mode + Release to open previous chapter + Release to open next chapter + No previous chapter + No next chapter + Show the current time and reading progress at the top of the screen + Show page numbers in bottom corner + Clear cookies for specified domain only. In most cases will invalidate authorization + All chapters with translation %s + The whole manga + First %s + Next unread %s + All unread chapters + All unread chapters (%s) + Select chapters manually + Pick custom directory + You have no access to this file or directory + Local manga directories + Description + This month + Voice search + Related manga + Light + Dark + White + Black + Background + Data was not restored + Make sure you have selected the correct backup file + Manage categories + Do not update suggestions using metered network connections + Do not check for new chapters using metered network connections + Enter manga title, genre or source name + Progress + Added + Show + %s requires a captcha to be resolved to work properly + Languages + Unknown + In progress + Disable NSFW + Too many requests. Try again later + Too many requests. Try again after %s + Show a list of related manga. In some cases it may be inaccurate or missing + Advanced + Manga list + Invalid data is returned or file is corrupted + On device + Directories + Main screen sections + No more items can be added + To top + Moved to top + Zoom out + Zoom in + Show zoom buttons + Whether to show zoom control buttons in the bottom right corner + Keep screen on + Do not turn the screen off while you\'re reading manga + Dropped + Reduces banding, but may impact performance + 32-bit color mode + Suggest new sources after app update + Prompt to enable newly added sources after updating the application + List options + Relevance + Categories + Online variant + Periodic backups + Backup creation frequency + Every day + Every 2 days + Once per week + Twice per month + Once per month + Enable periodic backups + Backups output directory + Last successful backup: %s + x%.1f + Lock screen rotation + Manga + Hentai + Comics + Other + %1$s, %2$s + Sources catalog + Source enabled + There are no sources available in this section, or all of it might have been already added.\nStay tuned + No available manga sources found by your query + Catalog + Manage sources + Manual + Available: %1$d + Disable NSFW sources and hide adult manga from list if possible + Paused + Reduce memory consumption (beta) + Reduce offscreen pages quality to use less memory + State + Filtering by multiple genres is not supported by this manga source + Filtering by multiple states is not supported by this manga source + Search is not supported by this manga source + You can enable download slowdown for each manga source individually in the source settings if you are having problems with server-side blocking + Skip + Grayscale + Globally + This manga + These settings can be applied globally or only to the current manga. If applied globally, individual settings will not be overridden. + Apply + Filtering by both genres and locale is not supported by this source + Filtering by both genres and states is not supported by this source + Start typing the genre name + Might help with getting the download started if you have any issues with it + Please select which content sources you would like to enable. This can also be configured later in settings + Login to sync account + Restore + Backup date: %s + Upcoming + Name reversed + Content rating + Exclude genres + Safe + Suggestive + Adult + Default tab + Mark as completed + Mark selected manga as completely read?\n\nWarning: current reading progress will be lost. + This category was hidden from the main screen and is accessible through Menu → Manage categories + %1$s %2$s + Volume %d + Unknown volume + Your reading progress will not be saved + Vertical + Last read + Show menu + Show/hide UI + Previous chapter + Next chapter + Previous page + Next page + Reader actions + Configure actions for tappable screen areas + Enable volume buttons + Use volume buttons for switching pages + Invert navigation controls + Swap the direction of volume button and directional hardware key navigation (left/up/down/right) + Tap action + Long tap action + None + Reset settings to default values? This action cannot be undone. + Use two pages layout on landscape orientation (beta) + Two-Page Scroll Sensitivity + Default webtoon zoom out + Fullscreen mode + Hide system status and navigation bars + %1$d/%2$d + Show estimated reading time + The time estimation value may be inaccurate + Suggestions feature is disabled + Checking for new chapters is disabled + Show labels in navigation bar + Saving pages + Ask for the destination dir every time + Default page save directory + Remove from history + Location + Preferred download format + Automatic + Single CBZ file + Multiple CBZ files + Reading statistics + Other manga + Less than a minute + Statistics + Clear statistics + Statistics cleared + Do you really want to clear all reading statistics? This action cannot be undone. + Week + Month + All time + Day + Three months + There are no statistics for the selected period + Pages read: %s + Alternatives + Migrate + Manga \"%1$s\" from \"%2$s\" will be replaced with \"%3$s\" from \"%4$s\" in your history and favorites (if present) + Manga migration + Migration completed + Delete read chapters + No chapters have been deleted + Removed %1$s, cleared %2$s + Delete chapters you have already read from local storage to free up space + This will permanently delete all chapters marked as read from your local storage. You can re-download it later, but the imported chapters may be lost forever + Delete read chapters automatically + Runs when the application starts + Split by translations + Show chapters with different translations separately, rather than in one list + Oldest + Long time ago read + Unread + Enable source + This manga source is not supported + Show pages thumbnails + Enable the \"Pages\" tab on the details screen + No data was received from server + Please select a proper Kotatsu backup file + (+%d) + + %d h + + %d m + + %d s + + %1$d h %2$d m + + %1$d m %2$d s + Fix + There is no permission to access manga on external storage + Last used + Show updated + Gaps in webtoon mode + Show vertical gaps between pages in webtoon mode + Enable pull gesture + Use pull gesture to switch chapters in webtoon + Less frequently + More frequently + Frequency of check + %1$s: %2$d + Pin navigation UI + Do not hide navigation bar and search view on scroll + Search suggestions + Recent queries + Suggested queries + Authors + You are blocked by the server. Try to use a different network connection (VPN, Proxy, etc.) + Disable + Sources disabled + Disable connectivity check + You can disable SSL certificates verification in case you face an SSL-related issues when accessing network resources. This may affect your security. Application restarting is required after changing this setting. + Skip the connectivity check in case you have issues with it (e.g. going offline mode even though the network is connected) + Disable NSFW notifications + Do not show notifications about NSFW manga updates + Checking for new chapters log + Debug information about background checks for new chapters + + New + All languages + Block when incognito mode + Preferred image server + %1$s: %2$s + Crop pages + Pin + Unpin + Source pinned + Source unpinned + Sources unpinned + Sources pinned + Recent sources + Percent read + Percent left + Chapters read + Chapters left + External/plugin + Incompatible plugin or internal error. Make sure you are using the latest version of the plugin and Kotatsu + Plugin error: %s\n Make sure you are using the latest version of the plugin and Kotatsu + Connection is OK + Invalid proxy configuration + Show quick filters + Provides the ability to filter manga lists by certain parameters + SFW + Skip all + Stuck + Not in favorites + Updated long ago + Unpopular + Low rating + Ascending + Descending + Date + Popularity + Sign in to %s to continue + Sign in to set up integration with %s. This will allow you to track your manga reading progress and status + Unstable feature + This function is experimental. Please make sure you have a backup to avoid data loss + Background downloads + Download new chapters + Manga with downloaded chapters + Manga \"%1$s\" (%2$s) replaced with \"%3$s\" (%4$s) + Fixing manga + Fixed successfully + No fix required for \"%s\" + No alternatives found for \"%s\" + This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background + Novel + Manhua + Manhwa + Recently added + Added long ago + Popular this hour + Popular today + Popular this week + Popular this month + Popular this year + Original language + Year + Demographics + Shounen + Shoujo + Seinen + Josei + Years + Any + This source does not support search with filters. Your filters have been cleared + Kodomo + One shot + Doujinshi + Image set + Artist CG + Game CG + Debug + Source code + User manual + Telegram group + Unsupported image format: %s + Invalid format: expected image but got %s + Start download + Save selected manga? This may consume traffic and disk space + Save manga + Genre + Download added + More options + Destination directory + You can select chapters to download by long click on item in the chapter list. + + All + Downloading over cellular network + Allow downloads over cellular network? + Don\'t allow + Allow always + Allow once + Ask every time + Screen orientation + Portrait + Landscape + "]]> + Access denied (403) + Max number of backups + Delete old backups + Automatically delete old backup files to save storage space + Handle links + Handle manga links from external applications (e.g. web browser). You may also need to enable it manually in the application\'s system settings + Email + This source requires solving a captcha to continue + Author + Rating + Source + Translation + %1$s (%2$s) + Show slider + + Incognito + Connection reset by remote host + Check if API works + Test message + Chat ID is not set + Telegram chat ID + Open the Telegram bot + Send backups in Telegram + Test connection + Enter the chat ID where backups should be sent + Press to open chat with Kotatsu Backup Bot + Clear database + Delete information about manga that is not used + Enable all manga sources + All available manga sources will be enabled permanently + All sources are enabled + Show chapter change popup + Show a pop-up message with a chapter title when it is changed + Transparent reader information bar + The backup will be restored in the background + Restoring backup + Reader controls in bottom bar + Chapters and pages + Page switch slider + Screen rotation has been locked + Screen rotation has been unlocked + Badges in lists + Search everywhere + Simple + Global search + Disable captcha notifications + You will not receive notifications about solving CAPTCHA for this source but this can lead to breaking background operations (checking for new chapters, obtaining recommendations, etc) + Vol %1$s Chapter %2$s + Chapter %s + Unnamed chapter + Search through disabled sources + Error details + Try to open manga in a web browser to ensure it is available on its source. + It looks like your version of Kotatsu is out of date. Please install the latest version to get all available fixes. + You can submit a bug report to the developers. This will help us investigate and fix the issue. + Link to manga on %s + Link to manga in Kotatsu + Clear browser data + Clear browser data such as cache and cookies. Warning: Authorization in manga sources may become invalid + Does not have permission to write a file + Adult manga will not be shown in suggestions. This option may work inaccurate with some sources + Include disabled sources + Show suggestions from all manga sources, including disabled ones + Highlight dangerous genres + Highlight genres that may be inappropriate for most users + The selected path cannot be used because it does not denote a file or directory + These changes will affect how manga is displayed in the app + Use default cover + Pick manga page + Pick custom file + Change cover + The page will switch every ~%d seconds + Don\'t ask again + This manga may contain adult content. Do you want to use incognito mode? + Incognito mode for NSFW manga + Additional action is required + Hide from main screen + Changelog + Changes history for recently released versions + Collapse + Expand + Block ads in browser + Block advertisement in the built-in browser (beta) + Collapse long description + Creating backup + Share backup + Open reader in a separate task + Allows you to keep multiple readers with different manga open at the same time + Itsuka + Totoro + Yellowish background (blue filter) + Local storage cleanup + Failed to create backup + Main screen + Show floating Continue button + Allows to continue reading in a one click. This button will not appear in incognito mode or when the history is empty + Corrupted ZIP archive (%s) + Discord + Discord Rich Presence + Discord Token + Enter your Discord Token to enable Rich Presence + Enter your Discord Token or click %s to get it using browser + Paste your Discord Token here + Show your reading status on Discord + Obtain + Reading manga on Kotatsu - a manga reader app + Reading %s + Read on %s + Do not use RPC for adult content + Invalid token: %s + Show floating control button + Unavailable + This manga is not available to read in this source. Try searching for it in other sources or opening it in a browser for more information + This manga does not contain any chapters + Failed to load chapter list + Telegram integration + Test manga source + Rename + Save filter