Feat: Add Saved Filters Feature
This commit is contained in:
committed by
Koitharu
parent
a66283d035
commit
5fb8ff53f9
@@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
val data = it.tag
|
||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||
}
|
||||
private val chipOnLongClickListener = OnLongClickListener {
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
|
||||
}
|
||||
private val chipStyle: Int
|
||||
private val iconsVisible: Boolean
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
@@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||
|
||||
var onChipLongClickListener: OnChipLongClickListener? = null
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
||||
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
|
||||
@@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
setOnCloseIconClickListener(chipOnCloseListener)
|
||||
setEnsureMinTouchTargetSize(false)
|
||||
setOnClickListener(chipOnClickListener)
|
||||
setOnLongClickListener(chipOnLongClickListener)
|
||||
isElegantTextHeight = false
|
||||
}
|
||||
|
||||
@@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
|
||||
fun onChipCloseClick(chip: Chip, data: Any?)
|
||||
}
|
||||
|
||||
fun interface OnChipLongClickListener {
|
||||
|
||||
fun onChipLongClick(chip: Chip, data: Any?): Boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.koitharu.kotatsu.filter.data
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import android.content.Context
|
||||
|
||||
@Singleton
|
||||
class SavedFiltersRepository @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
private val keyRoot = "saved_filters_v1"
|
||||
|
||||
private val state = MutableStateFlow<Map<String, List<Preset>>>(emptyMap())
|
||||
|
||||
init {
|
||||
scope.launch { loadAll() }
|
||||
}
|
||||
|
||||
data class Preset(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val source: String,
|
||||
val payload: JSONObject,
|
||||
)
|
||||
|
||||
fun observe(source: String): StateFlow<List<Preset>> = MutableStateFlow(state.value[source].orEmpty()).also { out ->
|
||||
scope.launch {
|
||||
state.collect { all -> out.value = all[source].orEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
fun list(source: String): List<Preset> = state.value[source].orEmpty()
|
||||
|
||||
fun save(source: String, name: String, filter: MangaListFilter): Preset {
|
||||
val nowId = System.currentTimeMillis()
|
||||
val preset = Preset(
|
||||
id = nowId,
|
||||
name = name,
|
||||
source = source,
|
||||
payload = serializeFilter(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)
|
||||
}
|
||||
|
||||
fun delete(source: String, id: Long) {
|
||||
val list = list(source).filterNot { it.id == id }
|
||||
persist(source, list)
|
||||
}
|
||||
|
||||
private fun persist(source: String, list: List<Preset>) {
|
||||
val root = JSONObject(prefs.getString(keyRoot, "{}"))
|
||||
root.put(source, JSONArray(list.map { presetToJson(it) }))
|
||||
prefs.edit { putString(keyRoot, root.toString()) }
|
||||
state.value = state.value.toMutableMap().also { it[source] = list }
|
||||
}
|
||||
|
||||
private fun loadAll() {
|
||||
val root = JSONObject(prefs.getString(keyRoot, "{}"))
|
||||
val map = mutableMapOf<String, List<Preset>>()
|
||||
for (key in root.keys()) {
|
||||
val arr = root.optJSONArray(key) ?: continue
|
||||
map[key] = (0 until arr.length()).mapNotNull { i -> jsonToPreset(arr.optJSONObject(i), key) }
|
||||
}
|
||||
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 jsonToPreset(obj: JSONObject?, source: String): Preset? {
|
||||
obj ?: return null
|
||||
val id = obj.optLong("id", 0L)
|
||||
val name = obj.optString("name", null) ?: return null
|
||||
val payload = obj.optJSONObject("payload") ?: return null
|
||||
return Preset(id, name, source, payload)
|
||||
}
|
||||
|
||||
fun serializeFilter(f: MangaListFilter): JSONObject = JSONObject().apply {
|
||||
put("query", f.query)
|
||||
put("author", f.author)
|
||||
put("locale", f.locale?.toLanguageTag())
|
||||
put("originalLocale", f.originalLocale?.toLanguageTag())
|
||||
put("states", JSONArray(f.states.map { it.name }))
|
||||
put("contentRating", JSONArray(f.contentRating.map { it.name }))
|
||||
put("types", JSONArray(f.types.map { it.name }))
|
||||
put("demographics", JSONArray(f.demographics.map { it.name }))
|
||||
put("tags", JSONArray(f.tags.map { it.key }))
|
||||
put("tagsExclude", JSONArray(f.tagsExclude.map { it.key }))
|
||||
put("year", f.year)
|
||||
put("yearFrom", f.yearFrom)
|
||||
put("yearTo", f.yearTo)
|
||||
}
|
||||
|
||||
fun deserializeFilter(
|
||||
obj: JSONObject,
|
||||
resolveTags: (Set<String>) -> Set<MangaTag>,
|
||||
): MangaListFilter {
|
||||
return MangaListFilter(
|
||||
query = obj.optString("query").takeIf { it.isNotEmpty() },
|
||||
author = obj.optString("author").takeIf { it.isNotEmpty() },
|
||||
locale = obj.optString("locale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
|
||||
originalLocale = obj.optString("originalLocale").takeIf { it.isNotEmpty() }?.let { Locale.forLanguageTag(it) },
|
||||
states = obj.optJSONArray("states")?.toStringSet()?.mapNotNull { runCatching { MangaState.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
|
||||
contentRating = obj.optJSONArray("contentRating")?.toStringSet()?.mapNotNull { runCatching { ContentRating.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
|
||||
types = obj.optJSONArray("types")?.toStringSet()?.mapNotNull { runCatching { ContentType.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
|
||||
demographics = obj.optJSONArray("demographics")?.toStringSet()?.mapNotNull { runCatching { Demographic.valueOf(it) }.getOrNull() }?.toSet().orEmpty(),
|
||||
tags = resolveTags(obj.optJSONArray("tags")?.toStringSet().orEmpty()).toSet(),
|
||||
tagsExclude = resolveTags(obj.optJSONArray("tagsExclude")?.toStringSet().orEmpty()).toSet(),
|
||||
year = obj.optInt("year"),
|
||||
yearFrom = obj.optInt("yearFrom"),
|
||||
yearTo = obj.optInt("yearTo"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONArray.toStringSet(): Set<String> = buildSet {
|
||||
for (i in 0 until length()) {
|
||||
val v = optString(i)
|
||||
if (!v.isNullOrEmpty()) add(v)
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,20 @@ 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.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.SavedFiltersRepository
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
@@ -42,6 +46,7 @@ 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
|
||||
@@ -51,6 +56,7 @@ class FilterCoordinator @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
private val savedFiltersRepository: SavedFiltersRepository,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
) {
|
||||
|
||||
@@ -60,9 +66,27 @@ class FilterCoordinator @Inject constructor(
|
||||
|
||||
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
|
||||
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
|
||||
private val currentPresetId = MutableStateFlow<Long?>(null)
|
||||
private var lastAppliedPayload: JSONObject? = null
|
||||
|
||||
private val availableSortOrders = repository.sortOrders
|
||||
private val filterOptions = suspendLazy { repository.getFilterOptions() }
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
currentListFilter.collect { lf ->
|
||||
val applied = lastAppliedPayload
|
||||
if (applied != null) {
|
||||
val cur = savedFiltersRepository.serializeFilter(lf)
|
||||
if (cur.toString() != applied.toString()) {
|
||||
currentPresetId.value = null
|
||||
lastAppliedPayload = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val capabilities = repository.filterCapabilities
|
||||
|
||||
val mangaSource: MangaSource
|
||||
@@ -249,6 +273,12 @@ class FilterCoordinator @Inject constructor(
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
val savedPresets: StateFlow<List<SavedFiltersRepository.Preset>> =
|
||||
savedFiltersRepository.observe(repository.source.unwrap().name)
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val selectedPresetId: StateFlow<Long?> = currentPresetId
|
||||
|
||||
fun reset() {
|
||||
currentListFilter.value = MangaListFilter.EMPTY
|
||||
}
|
||||
@@ -277,17 +307,44 @@ class FilterCoordinator @Inject constructor(
|
||||
author = null,
|
||||
)
|
||||
}
|
||||
if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) {
|
||||
newFilter = newFilter.copy(
|
||||
query = null,
|
||||
)
|
||||
}
|
||||
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
|
||||
newFilter = MangaListFilter(query = newFilter.query)
|
||||
}
|
||||
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 applyPreset(preset: SavedFiltersRepository.Preset) {
|
||||
coroutineScope.launch {
|
||||
val available = filterOptions.asFlow().map { it.getOrNull()?.availableTags.orEmpty() }.first()
|
||||
val byKey: (Set<String>) -> Set<MangaTag> = { keys ->
|
||||
val all = available.associateBy { it.key }
|
||||
keys.mapNotNull { all[it] }.toSet()
|
||||
}
|
||||
val filter = savedFiltersRepository.deserializeFilter(preset.payload, byKey)
|
||||
setAdjusted(filter)
|
||||
currentPresetId.value = preset.id
|
||||
lastAppliedPayload = preset.payload
|
||||
}
|
||||
}
|
||||
|
||||
fun renamePreset(id: Long, newName: String) {
|
||||
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 setQuery(value: String?) {
|
||||
val newQuery = value?.trim()?.nullIfEmpty()
|
||||
currentListFilter.update { oldValue ->
|
||||
|
||||
@@ -27,6 +27,7 @@ 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.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
|
||||
@@ -37,6 +38,8 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
|
||||
import org.koitharu.kotatsu.parsers.util.toIntUp
|
||||
import java.util.Locale
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import android.widget.EditText
|
||||
|
||||
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
AdapterView.OnItemSelectedListener,
|
||||
@@ -46,6 +49,69 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
private fun onSavedPresetsChanged(list: List<SavedFiltersRepository.Preset>, selectedId: Long?) {
|
||||
val b = viewBinding ?: return
|
||||
if (list.isEmpty()) {
|
||||
b.layoutSavedFilters.isGone = true
|
||||
b.chipsSavedFilters.setChips(emptyList())
|
||||
return
|
||||
}
|
||||
b.layoutSavedFilters.isGone = false
|
||||
val chips = list.map { p ->
|
||||
ChipsView.ChipModel(
|
||||
title = p.name,
|
||||
isChecked = p.id == selectedId,
|
||||
data = p,
|
||||
)
|
||||
}
|
||||
b.chipsSavedFilters.setChips(chips)
|
||||
}
|
||||
|
||||
private fun promptPresetName(onSubmit: (String) -> Unit) {
|
||||
val ctx = requireContext()
|
||||
val input = EditText(ctx)
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.enter_name)
|
||||
.setView(input)
|
||||
.setPositiveButton(R.string.save) { d, _ ->
|
||||
val text = input.text?.toString()?.trim()
|
||||
if (!text.isNullOrEmpty()) onSubmit(text)
|
||||
d.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showPresetOptions(filter: FilterCoordinator, preset: SavedFiltersRepository.Preset) {
|
||||
val ctx = requireContext()
|
||||
val items = arrayOf(getString(R.string.edit), getString(R.string.delete))
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setItems(items) { d, which ->
|
||||
when (which) {
|
||||
0 -> promptRename(filter, preset)
|
||||
1 -> filter.deletePreset(preset.id)
|
||||
}
|
||||
d.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun promptRename(filter: FilterCoordinator, preset: SavedFiltersRepository.Preset) {
|
||||
val ctx = requireContext()
|
||||
val input = EditText(ctx)
|
||||
input.setText(preset.name)
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.edit)
|
||||
.setView(input)
|
||||
.setPositiveButton(R.string.save) { d, _ ->
|
||||
val text = input.text?.toString()?.trim()
|
||||
if (!text.isNullOrEmpty()) filter.renamePreset(preset.id, text)
|
||||
d.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
if (dialog == null) {
|
||||
@@ -89,6 +155,38 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
binding.layoutGenresExclude.setOnMoreButtonClickListener {
|
||||
router.showTagsCatalogSheet(excludeMode = true)
|
||||
}
|
||||
|
||||
binding.chipsSavedFilters.onChipClickListener = ChipsView.OnChipClickListener { chip, data ->
|
||||
when (data) {
|
||||
is SavedFiltersRepository.Preset -> filter.applyPreset(data)
|
||||
}
|
||||
}
|
||||
binding.chipsSavedFilters.onChipLongClickListener = ChipsView.OnChipLongClickListener { chip, data ->
|
||||
when (data) {
|
||||
is SavedFiltersRepository.Preset -> {
|
||||
showPresetOptions(filter, data)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
filter.savedPresets.observe(viewLifecycleOwner) { list ->
|
||||
val selectedId = filter.selectedPresetId.value
|
||||
onSavedPresetsChanged(list, selectedId)
|
||||
}
|
||||
filter.selectedPresetId.observe(viewLifecycleOwner) { selectedId ->
|
||||
onSavedPresetsChanged(filter.savedPresets.value, selectedId)
|
||||
}
|
||||
|
||||
filter.observe().observe(viewLifecycleOwner) {
|
||||
binding.buttonSaveFilter.isEnabled = filter.isFilterApplied
|
||||
}
|
||||
binding.buttonSaveFilter.setOnClickListener {
|
||||
promptPresetName { name ->
|
||||
filter.saveCurrentPreset(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
app:title="@string/filter" />
|
||||
|
||||
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scrollView"
|
||||
android:layout_width="match_parent"
|
||||
@@ -29,6 +31,23 @@
|
||||
android:paddingHorizontal="@dimen/margin_small"
|
||||
android:paddingBottom="@dimen/margin_normal">
|
||||
|
||||
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
|
||||
android:id="@+id/layout_saved_filters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
app:title="@string/saved_filters">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
android:id="@+id/chips_saved_filters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/margin_small"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
|
||||
|
||||
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
|
||||
|
||||
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
|
||||
android:id="@+id/layout_order"
|
||||
android:layout_width="match_parent"
|
||||
@@ -254,4 +273,15 @@
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_save_filter"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/margin_small"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:layout_marginBottom="@dimen/margin_normal"
|
||||
android:text="@string/save"
|
||||
android:enabled="false" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<string name="by_rating">Rating</string>
|
||||
<string name="sort_order">Sorting order</string>
|
||||
<string name="filter">Filter</string>
|
||||
<string name="saved_filters">Saved filters</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="light">Light</string>
|
||||
<string name="dark">Dark</string>
|
||||
@@ -208,6 +209,7 @@
|
||||
<string name="enabled">Enabled</string>
|
||||
<string name="disabled">Disabled</string>
|
||||
<string name="reset_filter">Reset filter</string>
|
||||
<string name="enter_name">Enter name</string>
|
||||
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
|
||||
<string name="never">Never</string>
|
||||
<string name="only_using_wifi">Only on Wi-Fi</string>
|
||||
|
||||
Reference in New Issue
Block a user