Improve saved filters

This commit is contained in:
Koitharu
2025-10-21 12:22:30 +03:00
parent 1181860e41
commit b414758f32
9 changed files with 117 additions and 36 deletions

View File

@@ -5,6 +5,7 @@ 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.ArrayAdapter
import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.FrameLayout
@@ -21,6 +22,7 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
import com.google.android.material.R as materialR
inline fun buildAlertDialog(
@@ -96,3 +98,27 @@ fun <B : AlertDialog.Builder> B.setEditText(
setView(layout)
return editText
}
fun <B : AlertDialog.Builder> B.setEditText(
entries: List<CharSequence>,
inputType: Int,
singleLine: Boolean,
): EditText {
if (entries.isEmpty()) {
return setEditText(inputType, singleLine)
}
val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context))
binding.autoCompleteTextView.setAdapter(
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries),
)
binding.dropdown.setOnClickListener {
binding.autoCompleteTextView.showDropDown()
}
binding.autoCompleteTextView.inputType = inputType
if (singleLine) {
binding.autoCompleteTextView.setSingleLine()
binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE
}
setView(binding.root)
return binding.autoCompleteTextView
}

View File

@@ -21,7 +21,7 @@ data class PersistableFilter(
) {
val id: Int
get() = filter.hashCode()
get() = name.hashCode()
companion object {

View File

@@ -61,13 +61,19 @@ class SavedFiltersRepository @Inject constructor(
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))
val newFilter = filter.copy(name = newName)
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(key(id))
putString(key(newFilter.id), Json.encodeToString(newFilter))
}
newFilter
}
suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(FILTER_PREFIX + id)
remove(key(id))
}
}
@@ -75,13 +81,13 @@ class SavedFiltersRepository @Inject constructor(
val prefs = getPrefs(source)
val json = Json.encodeToString(persistableFilter)
prefs.edit(commit = true) {
putString(FILTER_PREFIX + persistableFilter.id, json)
putString(key(persistableFilter.id), json)
}
}
private fun load(source: MangaSource, id: Int): PersistableFilter? {
val prefs = getPrefs(source)
val json = prefs.getString(FILTER_PREFIX + id, null) ?: return null
val json = prefs.getString(key(id), null) ?: return null
return try {
Json.decodeFromString<PersistableFilter>(json)
} catch (e: SerializationException) {
@@ -95,5 +101,7 @@ class SavedFiltersRepository @Inject constructor(
private companion object {
const val FILTER_PREFIX = "__pf_"
fun key(id: Int) = FILTER_PREFIX + id
}
}

View File

@@ -72,6 +72,7 @@ class FilterHeaderProducer @Inject constructor(
data = saved,
)
if (model.isChecked) {
selectedTags.removeAll(saved.filter.tags)
result.addFirst(model)
} else {
result.addLast(model)

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
@@ -16,7 +17,6 @@ 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
@@ -30,6 +30,7 @@ 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
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getDisplayName
@@ -49,8 +50,10 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toIntUp
import java.util.Locale
import java.util.TreeSet
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener,
@@ -110,9 +113,6 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
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(),
@@ -122,7 +122,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.buttonSave.isEnabled = it
}
binding.buttonSave.setOnClickListener(this)
binding.buttonReset.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
@@ -135,8 +135,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
override fun onClick(v: View) {
when (v.id) {
R.id.button_reset -> FilterCoordinator.require(this).reset()
R.id.button_save -> onSaveFilterClick()
R.id.button_done -> dismiss()
R.id.button_save -> onSaveFilterClick("")
}
}
@@ -446,26 +446,29 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
menu.show()
}
private fun onSaveFilterClick() {
private fun onSaveFilterClick(name: String) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems
.mapTo(TreeSet(AlphanumComparator()), PersistableFilter::name)
buildAlertDialog(context ?: return) {
val input = setEditText(
entries = existingNames.toList(),
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.setHint(R.string.enter_name)
input.setText(name)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
setTitle(R.string.save_filter)
setPositiveButton(R.string.save) { d, _ ->
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (!text.isNullOrEmpty()) {
filter.saveCurrentFilter(text)
if (text.isNullOrEmpty()) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
onSaveFilterClick("")
} else if (text in existingNames) {
askForFilterOverwrite(filter, text)
} else {
Snackbar.make(
viewBinding?.scrollView ?: return@setPositiveButton,
R.string.invalid_value_message,
Snackbar.LENGTH_SHORT,
).show()
filter.saveCurrentFilter(text)
}
}
setNegativeButton(android.R.string.cancel, null)
@@ -474,6 +477,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
private fun onRenameFilterClick(preset: PersistableFilter) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems.mapToSet { it.name }
buildAlertDialog(context ?: return) {
val input = setEditText(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
@@ -485,17 +489,26 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
setTitle(R.string.rename)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (!text.isNullOrEmpty()) {
filter.renameSavedFilter(preset.id, text)
if (text.isNullOrEmpty() || text in existingNames) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
} else {
Snackbar.make(
viewBinding?.scrollView ?: return@setPositiveButton,
R.string.invalid_value_message,
Snackbar.LENGTH_SHORT,
).show()
filter.renameSavedFilter(preset.id, text)
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun askForFilterOverwrite(filter: FilterCoordinator, name: String) {
buildAlertDialog(context ?: return) {
setTitle(R.string.save_filter)
setMessage(getString(R.string.filter_overwrite_confirm, name))
setPositiveButton(R.string.overwrite) { _, _ ->
filter.saveCurrentFilter(name)
}
setNegativeButton(android.R.string.cancel) { _, _ ->
onSaveFilterClick(name)
}
}.show()
}
}

View File

@@ -42,7 +42,7 @@
android:importantForAutofill="no"
android:minHeight="48dp" />
<ImageView
<ImageButton
android:id="@+id/dropdown"
android:layout_width="48dp"
android:layout_height="48dp"

View File

@@ -286,26 +286,24 @@
android:gravity="center_vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_reset"
android:id="@+id/button_save"
style="?materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_small"
android:layout_weight="1"
android:enabled="false"
android:text="@string/reset"
android:text="@string/save"
tools:enabled="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_save"
android:id="@+id/button_done"
style="?materialButtonTonalStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_small"
android:layout_weight="1"
android:enabled="false"
android:text="@string/save"
tools:enabled="true"
tools:ignore="ButtonStyle" />
android:text="@string/done" />
</LinearLayout>
</com.google.android.material.dockedtoolbar.DockedToolbarLayout>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/screen_padding"
android:paddingTop="@dimen/margin_small">
<AutoCompleteTextView
android:id="@+id/autoCompleteTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/dropdown"
tools:ignore="LabelFor" />
<ImageButton
android:id="@+id/dropdown"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_gravity="center_vertical|end"
android:background="?selectableItemBackgroundBorderless"
android:paddingBottom="2dp"
android:scaleType="center"
android:src="@drawable/ic_expand_more"
tools:ignore="ContentDescription" />
</RelativeLayout>

View File

@@ -893,4 +893,6 @@
<string name="test_parser">Test manga source</string>
<string name="rename">Rename</string>
<string name="save_filter">Save filter</string>
<string name="overwrite">Overwrite</string>
<string name="filter_overwrite_confirm">A filter named \"%s\" already exists. Do you want to overwrite it?</string>
</resources>