feat: Add the ability to save search queries

I got tired of putting the same tag over and over, so...
This commit is contained in:
Ahmad Ansori Palembani 2025-04-14 21:02:12 +07:00
parent 7a08ca294a
commit f13f98f19a
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
26 changed files with 880 additions and 80 deletions

View file

@ -12,6 +12,7 @@ The format is simplified version of [Keep a Changelog](https://keepachangelog.co
### Additions
- Add random library sort
- Add the ability to save search queries
### Changes
- Temporarily disable log file

View file

@ -25,6 +25,7 @@ fun runCommand(command: String): String {
return result.standardOutput.asText.get().trim()
}
@Suppress("PropertyName")
val _versionName = "1.9.8"
val betaCount by lazy {
val betaTags = runCommand("git tag -l --sort=refname v${_versionName}-b*")

View file

@ -10,6 +10,9 @@ import android.view.View
import android.view.ViewGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExploreOff
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible
@ -46,7 +49,9 @@ import eu.kanade.tachiyomi.util.system.connectivityManager
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.e
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.materialAlertDialog
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.setTextInput
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.view.activityBinding
@ -56,7 +61,11 @@ import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.isControllerVisible
import eu.kanade.tachiyomi.util.view.scrollViewWith
import eu.kanade.tachiyomi.util.view.setAction
import eu.kanade.tachiyomi.util.view.setMessage
import eu.kanade.tachiyomi.util.view.setNegativeButton
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.util.view.setPositiveButton
import eu.kanade.tachiyomi.util.view.setTitle
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.withFadeTransaction
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
@ -68,6 +77,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy
import yokai.domain.manga.interactor.GetManga
import yokai.domain.source.browse.filter.models.SavedSearch
import yokai.i18n.MR
import yokai.presentation.core.icons.CustomIcons
import yokai.presentation.core.icons.LocalSource
@ -140,6 +150,9 @@ open class BrowseSourceController(bundle: Bundle) :
private var filterSheet: SourceFilterSheet? = null
private var lastPosition: Int = -1
// Basically a cache just so the filter sheet is shown faster
var savedSearches by mutableStateOf(emptyList<SavedSearch>())
private val isBehindGlobalSearch: Boolean
get() = router.backstackSize >= 2 && router.backstack[router.backstackSize - 2].controller is GlobalSearchController
@ -183,6 +196,7 @@ open class BrowseSourceController(bundle: Bundle) :
binding.fab.isVisible = presenter.sourceFilters.isNotEmpty()
binding.fab.setOnClickListener { showFilters() }
activityBinding?.appBar?.y = 0f
activityBinding?.appBar?.updateAppBarAfterY(recycler)
activityBinding?.appBar?.lockYPos = true
@ -376,12 +390,16 @@ open class BrowseSourceController(bundle: Bundle) :
return true
}
private fun applyFilters() {
val allDefault = presenter.filtersMatchDefault()
showProgressBar()
adapter?.clear()
presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters)
updatePopLatestIcons()
}
private fun showFilters() {
if (filterSheet != null) return
val sheet = SourceFilterSheet(activity!!)
filterSheet = sheet
sheet.setFilters(presenter.filterItems)
presenter.filtersChanged = false
val oldFilters = mutableListOf<Any?>()
for (i in presenter.sourceFilters) {
if (i is Filter.Group<*>) {
@ -394,50 +412,94 @@ open class BrowseSourceController(bundle: Bundle) :
oldFilters.add(i.state)
}
}
sheet.onSearchClicked = {
var matches = true
for (i in presenter.sourceFilters.indices) {
val filter = oldFilters.getOrNull(i)
if (filter is List<*>) {
for (j in filter.indices) {
if (filter[j] !=
(
(presenter.sourceFilters[i] as Filter.Group<*>).state[j] as
Filter<*>
).state
) {
matches = false
break
}
}
} else if (filter != presenter.sourceFilters[i].state) {
matches = false
break
}
if (!matches) break
}
if (!matches) {
val allDefault = presenter.filtersMatchDefault()
showProgressBar()
adapter?.clear()
presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters)
updatePopLatestIcons()
}
}
sheet.onResetClicked = {
presenter.appliedFilters = FilterList()
val newFilters = presenter.source.getFilterList()
presenter.sourceFilters = newFilters
sheet.setFilters(presenter.filterItems)
}
sheet.setOnDismissListener {
filterSheet = null
}
sheet.setOnCancelListener {
filterSheet = null
}
sheet.show()
filterSheet = SourceFilterSheet(
activity = activity!!,
searches = { savedSearches },
onSearchClicked = {
var matches = true
for (i in presenter.sourceFilters.indices) {
val filter = oldFilters.getOrNull(i)
if (filter is List<*>) {
for (j in filter.indices) {
if (filter[j] !=
(
(presenter.sourceFilters[i] as Filter.Group<*>).state[j] as
Filter<*>
).state
) {
matches = false
break
}
}
} else if (filter != presenter.sourceFilters[i].state) {
matches = false
break
}
if (!matches) break
}
if (!matches) {
applyFilters()
}
},
onResetClicked = {
presenter.appliedFilters = FilterList()
val newFilters = presenter.source.getFilterList()
presenter.sourceFilters = newFilters
filterSheet?.setFilters(presenter.filterItems)
},
onSaveClicked = {
viewScope.launchIO {
val names = presenter.loadSearches().map { it.name }
var searchName = ""
withUIContext {
activity!!.materialAlertDialog()
.setTitle(activity!!.getString(MR.strings.save_search))
.setTextInput(hint = activity!!.getString(MR.strings.save_search_hint)) { input ->
searchName = input
}
.setPositiveButton(MR.strings.save) { _, _ ->
if (searchName.isNotBlank() && searchName !in names) {
presenter.saveSearch(searchName.trim(), presenter.query, presenter.sourceFilters)
filterSheet?.scrollToTop()
} else {
activity!!.toast(MR.strings.save_search_invalid_name)
}
}
.setNegativeButton(MR.strings.cancel, null)
.show()
}
}
},
onSavedSearchClicked = ss@{ searchId ->
viewScope.launchIO {
val search = presenter.loadSearch(searchId) // Grab the latest data from database
if (search?.filters == null) return@launchIO
withUIContext {
presenter.sourceFilters = search.filters
filterSheet?.setFilters(presenter.filterItems)
// This will call onSaveClicked()
filterSheet?.dismiss()
}
}
},
onDeleteSavedSearchClicked = { searchId ->
activity!!.materialAlertDialog()
.setTitle(MR.strings.save_search_delete)
.setMessage(MR.strings.save_search_delete)
.setPositiveButton(MR.strings.cancel, null)
.setNegativeButton(android.R.string.ok) { _, _ -> presenter.deleteSearch(searchId) }
.show()
}
)
filterSheet?.setFilters(presenter.filterItems)
presenter.filtersChanged = false
filterSheet?.setOnCancelListener { filterSheet = null }
filterSheet?.setOnDismissListener { filterSheet = null }
filterSheet?.show()
}
/**

View file

@ -28,6 +28,7 @@ import eu.kanade.tachiyomi.ui.source.filter.TextSectionItem
import eu.kanade.tachiyomi.ui.source.filter.TriStateItem
import eu.kanade.tachiyomi.ui.source.filter.TriStateSectionItem
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellableIO
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.asFlow
@ -35,8 +36,12 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@ -44,6 +49,11 @@ import yokai.domain.manga.interactor.GetManga
import yokai.domain.manga.interactor.InsertManga
import yokai.domain.manga.interactor.UpdateManga
import yokai.domain.manga.models.MangaUpdate
import yokai.domain.source.browse.filter.FilterSerializer
import yokai.domain.source.browse.filter.interactor.DeleteSavedSearch
import yokai.domain.source.browse.filter.interactor.GetSavedSearch
import yokai.domain.source.browse.filter.interactor.InsertSavedSearch
import yokai.domain.source.browse.filter.models.SavedSearch
import yokai.domain.ui.UiPreferences
// FIXME: Migrate to Compose
@ -63,6 +73,11 @@ open class BrowseSourcePresenter(
private val insertManga: InsertManga by injectLazy()
private val updateManga: UpdateManga by injectLazy()
private val deleteSavedSearch: DeleteSavedSearch by injectLazy()
private val getSavedSearch: GetSavedSearch by injectLazy()
private val insertSavedSearch: InsertSavedSearch by injectLazy()
private val filterSerializer: FilterSerializer by injectLazy()
/**
* Selected source.
*/
@ -129,6 +144,15 @@ open class BrowseSourcePresenter(
}
}
filtersChanged = false
runBlocking { view?.savedSearches = loadSearches() }
getSavedSearch.subscribeAllBySourceId(sourceId)
.map { it.applyAllSave(source.getFilterList()) }
.onEach {
withUIContext { view?.savedSearches = it }
}
.launchIn(presenterScope)
}
}
@ -360,4 +384,33 @@ open class BrowseSourcePresenter(
}
}
}
fun saveSearch(name: String, query: String, filters: FilterList) {
presenterScope.launchNonCancellableIO {
insertSavedSearch.await(
sourceId,
name,
query,
try {
Json.encodeToString(filterSerializer.serialize(filters))
} catch (e: Exception) {
"[]"
},
)
}
}
fun deleteSearch(searchId: Long) {
presenterScope.launchNonCancellableIO {
deleteSavedSearch.await(searchId)
}
}
suspend fun loadSearch(id: Long): SavedSearch? {
return getSavedSearch.awaitById(id)?.applySave(source.getFilterList())
}
suspend fun loadSearches(): List<SavedSearch> {
return getSavedSearch.awaitAllBySourceId(sourceId).applyAllSave(source.getFilterList())
}
}

View file

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.ui.source.browse
import eu.kanade.tachiyomi.source.model.FilterList
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import yokai.domain.source.browse.filter.FilterSerializer
import yokai.domain.source.browse.filter.models.RawSavedSearch
import yokai.domain.source.browse.filter.models.SavedSearch
fun RawSavedSearch.applySave(
originalFilters: FilterList,
json: Json = Injekt.get(),
filterSerializer: FilterSerializer = Injekt.get(),
): SavedSearch {
val rt = SavedSearch(
id = this.id,
name = this.name,
query = this.query.orEmpty(),
filters = null,
)
if (filtersJson == null) {
return rt
}
val filters = try {
json.decodeFromString<JsonArray>(filtersJson!!)
} catch (e: Exception) {
null
} ?: return rt
try {
filterSerializer.deserialize(originalFilters, filters)
return rt.copy(filters = originalFilters)
} catch (e: Exception) {
return rt
}
}
fun List<RawSavedSearch>.applyAllSave(
originalFilters: FilterList,
json: Json = Injekt.get(),
filterSerializer: FilterSerializer = Injekt.get(),
) = this.map { it.applySave(originalFilters, json, filterSerializer) }

View file

@ -0,0 +1,93 @@
package eu.kanade.tachiyomi.ui.source.browse
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ElevatedSuggestionChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.databinding.SourceFilterSheetSavedSearchBinding
import yokai.domain.source.browse.filter.models.SavedSearch
import yokai.presentation.theme.YokaiTheme
class SavedSearchesAdapter(
val searches: () -> List<SavedSearch>,
val onSavedSearchClicked: (Long) -> Unit,
val onDeleteSavedSearchClicked: (Long) -> Unit,
) :
RecyclerView.Adapter<SavedSearchesAdapter.SavedSearchesViewHolder>() {
private lateinit var binding: SourceFilterSheetSavedSearchBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SavedSearchesViewHolder {
binding = SourceFilterSheetSavedSearchBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return SavedSearchesViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: SavedSearchesViewHolder, position: Int) {
holder.bind()
}
inner class SavedSearchesViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
binding.savedSearches.setContent {
YokaiTheme {
Content()
}
}
binding.savedSearches.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
}
@Composable
fun Content() {
binding.savedSearchesTitle.isVisible = searches().isNotEmpty()
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
searches().forEach { search ->
val inputChipInteractionSource = remember { MutableInteractionSource() }
Box {
ElevatedSuggestionChip(
label = { Text(search.name) },
onClick = { },
interactionSource = inputChipInteractionSource,
colors = SuggestionChipDefaults.elevatedSuggestionChipColors().copy(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f),
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
// Workaround to add long click to chips
Box(
modifier = Modifier
.matchParentSize()
.combinedClickable(
onLongClick = { onDeleteSavedSearchClicked(search.id) },
onClick = { onSavedSearchClicked(search.id) },
interactionSource = inputChipInteractionSource,
indication = null,
)
)
}
}
}
}
}
}

View file

@ -11,6 +11,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePaddingRelative
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import eu.davidea.flexibleadapter.FlexibleAdapter
@ -22,27 +23,37 @@ import eu.kanade.tachiyomi.util.view.checkHeightThen
import eu.kanade.tachiyomi.util.view.collapse
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsetsCompat
import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog
import yokai.domain.source.browse.filter.models.SavedSearch
import yokai.presentation.component.recyclerview.VertPaddingDecoration
import android.R as AR
class SourceFilterSheet(val activity: Activity) :
E2EBottomSheetDialog<SourceFilterSheetBinding>(activity) {
private var filterChanged = true
class SourceFilterSheet(
val activity: Activity,
searches: () -> List<SavedSearch> = { emptyList() },
val onSearchClicked: () -> Unit,
val onResetClicked: () -> Unit,
val onSaveClicked: () -> Unit,
val onSavedSearchClicked: (Long) -> Unit,
val onDeleteSavedSearchClicked: (Long) -> Unit,
) : E2EBottomSheetDialog<SourceFilterSheetBinding>(activity) {
val adapter: FlexibleAdapter<IFlexible<*>> = FlexibleAdapter<IFlexible<*>>(null)
.setDisplayHeadersAtStartUp(true)
var onSearchClicked = {}
var onResetClicked = {}
override var recyclerView: RecyclerView? = binding.filtersRecycler
override fun createBinding(inflater: LayoutInflater) = SourceFilterSheetBinding.inflate(inflater)
private val savedSearchesAdapter = SavedSearchesAdapter(
searches = searches,
onSavedSearchClicked = onSavedSearchClicked,
onDeleteSavedSearchClicked = onDeleteSavedSearchClicked,
)
init {
binding.searchBtn.setOnClickListener { dismiss() }
binding.resetBtn.setOnClickListener { onResetClicked() }
binding.saveBtn.setOnClickListener { onSaveClicked() }
sheetBehavior.peekHeight = 450.dpToPx
sheetBehavior.collapse()
@ -54,9 +65,7 @@ class SourceFilterSheet(val activity: Activity) :
binding.cardView.doOnApplyWindowInsetsCompat { _, insets, _ ->
binding.cardView.updateLayoutParams<ConstraintLayout.LayoutParams> {
val fullHeight = activity.window.decorView.height
matchConstraintMaxHeight =
fullHeight - insets.getInsets(systemBars()).top -
binding.titleLayout.height - 75.dpToPx
matchConstraintMaxHeight = fullHeight - insets.getInsets(systemBars()).top - binding.titleLayout.height - 75.dpToPx
}
}
@ -85,7 +94,7 @@ class SourceFilterSheet(val activity: Activity) :
},
)
binding.filtersRecycler.viewTreeObserver.addOnScrollChangedListener {
recyclerView?.viewTreeObserver?.addOnScrollChangedListener {
updateBottomButtons()
}
@ -93,11 +102,13 @@ class SourceFilterSheet(val activity: Activity) :
updateBottomButtons()
}
binding.filtersRecycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context)
binding.filtersRecycler.addItemDecoration(VertPaddingDecoration(12.dpToPx))
binding.filtersRecycler.clipToPadding = false
binding.filtersRecycler.adapter = adapter
binding.filtersRecycler.setHasFixedSize(false)
recyclerView?.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context)
recyclerView?.addItemDecoration(VertPaddingDecoration(12.dpToPx))
recyclerView?.adapter = ConcatAdapter(
savedSearchesAdapter,
adapter,
)
recyclerView?.setHasFixedSize(false)
sheetBehavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
@ -112,7 +123,7 @@ class SourceFilterSheet(val activity: Activity) :
)
}
fun setCardViewMax(insets: WindowInsetsCompat) {
private fun setCardViewMax(insets: WindowInsetsCompat) {
val fullHeight = activity.window.decorView.height
val newHeight = fullHeight - insets.getInsets(systemBars()).top -
binding.titleLayout.height - 75.dpToPx
@ -126,8 +137,10 @@ class SourceFilterSheet(val activity: Activity) :
override fun onStart() {
super.onStart()
sheetBehavior.collapse()
scrollToTop() // Force the sheet to scroll to the very top when it shows up
updateBottomButtons()
binding.root.post {
scrollToTop() // Force the sheet to scroll to the very top when it shows up
updateBottomButtons()
}
}
@ -157,12 +170,14 @@ class SourceFilterSheet(val activity: Activity) :
override fun dismiss() {
super.dismiss()
if (filterChanged) {
onSearchClicked()
}
onSearchClicked()
}
fun setFilters(items: List<IFlexible<*>>) {
adapter.updateDataSet(items)
}
fun scrollToTop() {
recyclerView?.layoutManager?.scrollToPosition(0)
}
}

View file

@ -5,16 +5,20 @@ import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.CheckResult
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatCheckedTextView
import androidx.core.content.getSystemService
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.databinding.CustomDialogTitleMessageBinding
import eu.kanade.tachiyomi.databinding.DialogQuadstateBinding
import eu.kanade.tachiyomi.databinding.DialogTextInputBinding
import eu.kanade.tachiyomi.widget.TriStateCheckBox
import eu.kanade.tachiyomi.widget.materialdialogs.TriStateMultiChoiceDialogAdapter
import eu.kanade.tachiyomi.widget.materialdialogs.TriStateMultiChoiceListener
@ -155,3 +159,23 @@ val DialogInterface.isPromptChecked: Boolean
fun interface MaterialAlertDialogBuilderOnCheckClickListener {
fun onClick(var1: DialogInterface?, var3: Boolean)
}
fun MaterialAlertDialogBuilder.setTextInput(
hint: String? = null,
prefill: String? = null,
onTextChanged: (String) -> Unit,
): MaterialAlertDialogBuilder {
val binding = DialogTextInputBinding.inflate(LayoutInflater.from(context))
binding.textField.hint = hint
binding.textField.editText?.apply {
setText(prefill, TextView.BufferType.EDITABLE)
doAfterTextChanged {
onTextChanged(it?.toString() ?: "")
}
post {
requestFocusFromTouch()
context.getSystemService<InputMethodManager>()?.showSoftInput(this, 0)
}
}
return setView(binding.root)
}

View file

@ -7,6 +7,7 @@ import yokai.data.extension.repo.ExtensionRepoRepositoryImpl
import yokai.data.history.HistoryRepositoryImpl
import yokai.data.library.custom.CustomMangaRepositoryImpl
import yokai.data.manga.MangaRepositoryImpl
import yokai.data.source.browse.filter.SavedSearchRepositoryImpl
import yokai.data.track.TrackRepositoryImpl
import yokai.domain.category.CategoryRepository
import yokai.domain.category.interactor.DeleteCategories
@ -42,6 +43,11 @@ import yokai.domain.manga.interactor.GetManga
import yokai.domain.manga.interactor.InsertManga
import yokai.domain.manga.interactor.UpdateManga
import yokai.domain.recents.interactor.GetRecents
import yokai.domain.source.browse.filter.FilterSerializer
import yokai.domain.source.browse.filter.SavedSearchRepository
import yokai.domain.source.browse.filter.interactor.DeleteSavedSearch
import yokai.domain.source.browse.filter.interactor.GetSavedSearch
import yokai.domain.source.browse.filter.interactor.InsertSavedSearch
import yokai.domain.track.TrackRepository
import yokai.domain.track.interactor.DeleteTrack
import yokai.domain.track.interactor.GetTrack
@ -95,4 +101,10 @@ fun domainModule() = module {
factory { DeleteTrack(get()) }
factory { GetTrack(get()) }
factory { InsertTrack(get()) }
single<SavedSearchRepository> { SavedSearchRepositoryImpl(get()) }
factory { DeleteSavedSearch(get()) }
factory { GetSavedSearch(get()) }
factory { InsertSavedSearch(get()) }
factory { FilterSerializer() }
}

View file

@ -0,0 +1,94 @@
package yokai.domain.source.browse.filter
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.isSubclassOf
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.double
import kotlinx.serialization.json.float
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
class FilterSerializer {
private val serializers = listOf<Serializer<*>>(
HeaderSerializer(this),
SeparatorSerializer(this),
SelectSerializer(this),
TextSerializer(this),
CheckBoxSerializer(this),
TriStateSerializer(this),
GroupSerializer(this),
SortSerializer(this),
)
fun serialize(filters: FilterList) = buildJsonArray {
filters.filterIsInstance<Filter<Any?>>().forEach { add(serialize(it)) }
}
fun serialize(filter: Filter<Any?>): JsonObject {
return serializers
.filterIsInstance<Serializer<Filter<Any?>>>()
.firstOrNull { filter::class.isSubclassOf(it.clazz) }
?.let { serializer ->
buildJsonObject {
with(serializer) { serialize(filter) }
serializer.mappings().forEach {
val res = it.second.get(filter)
putJsonObject(it.first) {
put(Serializer.TYPE, res?.javaClass?.name ?: "null")
put("value", res.toString())
}
}
put(Serializer.TYPE, serializer.type)
}
} ?: throw IllegalArgumentException("Cannot serialize this Filter object!")
}
fun deserialize(filters: FilterList, json: JsonArray) {
filters.filterIsInstance<Filter<Any?>>().zip(json).forEach { (filter, obj) ->
deserialize(filter, obj.jsonObject)
}
}
fun deserialize(filter: Filter<Any?>, json: JsonObject) {
val serializer = serializers
.filterIsInstance<Serializer<Filter<Any?>>>()
.firstOrNull { it.type == json[Serializer.TYPE]!!.jsonPrimitive.content }
?: throw IllegalArgumentException("Cannot deserialize this type!")
serializer.deserialize(json, filter)
serializer.mappings().forEach {
if (it.second is KMutableProperty1) {
val valueObj = json[it.first]!!.jsonObject
val obj = valueObj["value"]!!.jsonPrimitive
val res: Any? = when (valueObj[Serializer.TYPE]!!.jsonPrimitive.content) {
java.lang.Integer::class.java.name -> obj.int
java.lang.Long::class.java.name -> obj.long
java.lang.Float::class.java.name -> obj.float
java.lang.Double::class.java.name -> obj.double
java.lang.String::class.java.name -> obj.content
java.lang.Boolean::class.java.name -> obj.boolean
java.lang.Byte::class.java.name -> obj.content.toByte()
java.lang.Short::class.java.name -> obj.content.toShort()
java.lang.Character::class.java.name -> obj.content[0]
"null" -> null
else -> throw IllegalArgumentException("Cannot deserialize this type!")
}
@Suppress("UNCHECKED_CAST")
(it.second as KMutableProperty1<in Filter<Any?>, in Any?>).set(filter, res)
}
}
}
}

View file

@ -0,0 +1,167 @@
package yokai.domain.source.browse.filter
import eu.kanade.tachiyomi.source.model.Filter
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addAll
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
interface Serializer<in T : Filter<out Any?>> {
fun JsonObjectBuilder.serialize(filter: T) {}
fun deserialize(json: JsonObject, filter: T) {}
fun mappings(): List<Pair<String, KProperty1<in T, *>>> = emptyList()
val serializer: FilterSerializer
val type: String
val clazz: KClass<in T>
companion object {
const val TYPE = "_type"
const val NAME = "name"
const val STATE = "state"
}
}
class HeaderSerializer(override val serializer: FilterSerializer) : Serializer<Filter.Header> {
override val type = "HEADER"
override val clazz = Filter.Header::class
override fun mappings() = listOf(
Serializer.NAME to Filter.Header::name,
)
}
class SeparatorSerializer(override val serializer: FilterSerializer) : Serializer<Filter.Separator> {
override val type = "SEPARATOR"
override val clazz = Filter.Separator::class
override fun mappings() = listOf(
Serializer.NAME to Filter.Separator::name,
)
}
class SelectSerializer(override val serializer: FilterSerializer) : Serializer<Filter.Select<Any>> {
override val type = "SELECT"
override val clazz = Filter.Select::class
override fun JsonObjectBuilder.serialize(filter: Filter.Select<Any>) {
putJsonArray("values") {
addAll(filter.values.map { it.toString() })
}
}
override fun mappings() = listOf(
Serializer.NAME to Filter.Select<Any>::name,
Serializer.STATE to Filter.Select<Any>::state,
)
}
class TextSerializer(override val serializer: FilterSerializer) : Serializer<Filter.Text> {
override val type = "TEXT"
override val clazz = Filter.Text::class
override fun mappings() = listOf(
Serializer.NAME to Filter.Text::name,
Serializer.STATE to Filter.Text::state,
)
}
class CheckBoxSerializer(override val serializer: FilterSerializer) : Serializer<Filter.CheckBox> {
override val type = "CHECKBOX"
override val clazz = Filter.CheckBox::class
override fun mappings() = listOf(
Serializer.NAME to Filter.CheckBox::name,
Serializer.STATE to Filter.CheckBox::state,
)
}
class TriStateSerializer(override val serializer: FilterSerializer) : Serializer<Filter.TriState> {
override val type = "TRI_STATE"
override val clazz = Filter.TriState::class
override fun mappings() = listOf(
Serializer.NAME to Filter.TriState::name,
Serializer.STATE to Filter.TriState::state,
)
}
class GroupSerializer(override val serializer: FilterSerializer) : Serializer<Filter.Group<Any?>> {
override val type = "GROUP"
override val clazz = Filter.Group::class
override fun JsonObjectBuilder.serialize(filter: Filter.Group<Any?>) {
putJsonArray(Serializer.STATE) {
filter.state.forEach { state ->
@Suppress("UNCHECKED_CAST")
add((state as? Filter<Any?>)?.let { serializer.serialize(it) } ?: JsonNull)
}
}
}
override fun deserialize(json: JsonObject, filter: Filter.Group<Any?>) {
json[Serializer.STATE]!!.jsonArray.forEachIndexed { index, element ->
if (element == JsonNull) return@forEachIndexed
@Suppress("UNCHECKED_CAST")
serializer.deserialize(filter.state[index] as Filter<Any?>, element.jsonObject)
}
}
override fun mappings() = listOf(
Serializer.NAME to Filter.Group<Any?>::name,
)
}
class SortSerializer(override val serializer: FilterSerializer) : Serializer<Filter.Sort> {
override val type = "SORT"
override val clazz = Filter.Sort::class
override fun JsonObjectBuilder.serialize(filter: Filter.Sort) {
putJsonArray(VALUES) {
filter.values.forEach { add(it) }
}
put(
Serializer.STATE,
filter.state?.let { (index, ascending) ->
buildJsonObject {
put(STATE_INDEX, index)
put(STATE_ASCENDING, ascending)
}
} ?: JsonNull,
)
}
override fun deserialize(json: JsonObject, filter: Filter.Sort) {
filter.state = (json[Serializer.STATE] as? JsonObject)?.let {
Filter.Sort.Selection(
it[STATE_INDEX]!!.jsonPrimitive.int,
it[STATE_ASCENDING]!!.jsonPrimitive.boolean,
)
}
}
override fun mappings() = listOf(
Pair(Serializer.NAME, Filter.Sort::name),
)
companion object {
const val VALUES = "values"
const val STATE_INDEX = "index"
const val STATE_ASCENDING = "ascending"
}
}

View file

@ -0,0 +1,9 @@
package yokai.domain.source.browse.filter.interactor
import yokai.domain.source.browse.filter.SavedSearchRepository
class DeleteSavedSearch(
private val repository: SavedSearchRepository,
) {
suspend fun await(searchId: Long) = repository.deleteById(searchId)
}

View file

@ -0,0 +1,13 @@
package yokai.domain.source.browse.filter.interactor
import yokai.domain.source.browse.filter.SavedSearchRepository
class GetSavedSearch(
private val repository: SavedSearchRepository,
) {
suspend fun awaitAll() = repository.findAll()
suspend fun awaitAllBySourceId(sourceId: Long) = repository.findAllBySourceId(sourceId)
fun subscribeAllBySourceId(sourceId: Long) = repository.subscribeAllBySourceId(sourceId)
suspend fun awaitBySourceIdAndName(sourceId: Long, name: String) = repository.findOneBySourceIdAndName(sourceId, name)
suspend fun awaitById(id: Long) = repository.findById(id)
}

View file

@ -0,0 +1,9 @@
package yokai.domain.source.browse.filter.interactor
import yokai.domain.source.browse.filter.SavedSearchRepository
class InsertSavedSearch(
private val repository: SavedSearchRepository,
) {
suspend fun await(sourceId: Long, name: String, query: String?, filtersJson: String?) = repository.insert(sourceId, name, query, filtersJson)
}

View file

@ -0,0 +1,10 @@
package yokai.domain.source.browse.filter.models
import eu.kanade.tachiyomi.source.model.FilterList
data class SavedSearch(
val id: Long,
val name: String,
val query: String,
val filters: FilterList?,
)

View file

@ -30,6 +30,7 @@
android:id="@+id/fab"
style="@style/Theme.Widget.FAB"
android:text="@string/filter"
android:visibility="gone"
app:icon="@drawable/ic_filter_list_24dp"/>
<eu.kanade.tachiyomi.widget.EmptyView

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="24dp"
android:paddingVertical="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/text_field"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</FrameLayout>

View file

@ -1,12 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/source_filter_sheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bottom_sheet_rounded_background"
app:layout_constraintVertical_chainStyle="packed"
android:backgroundTint="?attr/background">
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/source_filter_sheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bottom_sheet_rounded_background"
app:layout_constraintVertical_chainStyle="packed"
android:backgroundTint="?attr/background">
<androidx.cardview.widget.CardView
android:id="@+id/card_view"
@ -24,7 +25,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:clipToPadding="false"
android:background="@drawable/bottom_sheet_rounded_background"
android:backgroundTint="?attr/colorSurface"
app:layout_constraintBottom_toTopOf="@id/title_layout"
@ -65,6 +65,25 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/save_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="#00000000"
android:src="@drawable/ic_save_24dp"
android:contentDescription="@string/save"
android:tooltipText="@string/save"
app:tint="?colorOnBackground"
app:flow_verticalBias="1.0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/search_btn"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/search_btn"
android:layout_width="wrap_content"
@ -79,7 +98,7 @@
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/reset_btn"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="150dp" />
app:layout_constraintWidth_min="120dp" />
<View
android:id="@+id/bottom_divider"

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/saved_searches_title"
style="@style/Widget.Tachiyomi.Chip.Genre"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:visibility="visible"
android:text="@string/save_search_title" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/saved_searches"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:chipSpacingHorizontal="4dp" />
</LinearLayout>

View file

@ -10,6 +10,7 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
api(projects.domain)
api(libs.bundles.db)
}
}

View file

@ -0,0 +1,37 @@
package yokai.data.source.browse.filter
import yokai.data.DatabaseHandler
import yokai.domain.source.browse.filter.SavedSearchRepository
import yokai.domain.source.browse.filter.models.RawSavedSearch
class SavedSearchRepositoryImpl(private val handler: DatabaseHandler) : SavedSearchRepository {
override suspend fun findAll(): List<RawSavedSearch> = handler.awaitList {
saved_searchQueries.findAll(RawSavedSearch::mapper)
}
override fun subscribeAllBySourceId(sourceId: Long) = handler.subscribeToList {
saved_searchQueries.findBySourceId(sourceId, RawSavedSearch::mapper)
}
override suspend fun findAllBySourceId(sourceId: Long) = handler.awaitList {
saved_searchQueries.findBySourceId(sourceId, RawSavedSearch::mapper)
}
override suspend fun findOneBySourceIdAndName(sourceId: Long, name: String): RawSavedSearch? = handler.awaitFirstOrNull {
saved_searchQueries.findBySourceIdAndName(sourceId, name, RawSavedSearch::mapper)
}
override suspend fun findById(id: Long): RawSavedSearch? = handler.awaitFirstOrNull {
saved_searchQueries.findById(id, RawSavedSearch::mapper)
}
override suspend fun deleteById(id: Long) = handler.await {
saved_searchQueries.deleteById(id)
}
override suspend fun insert(sourceId: Long, name: String, query: String?, filtersJson: String?) =
handler.awaitOneOrNullExecutable(inTransaction = true) {
saved_searchQueries.insert(sourceId, name, query, filtersJson)
saved_searchQueries.selectLastInsertedRowId()
}
}

View file

@ -0,0 +1,35 @@
CREATE TABLE saved_search(
-- TODO: Migrate the other tables to use 'id' instead of '_id'
id INTEGER NOT NULL PRIMARY KEY,
source_id INTEGER NOT NULL,
name TEXT NOT NULL,
query TEXT,
filters_json TEXT,
UNIQUE (source_id, name)
);
findAll:
SELECT * FROM saved_search
ORDER BY name COLLATE NOCASE ASC;
findBySourceId:
SELECT * FROM saved_search WHERE source_id = :sourceId
ORDER BY name COLLATE NOCASE ASC;
findBySourceIdAndName:
SELECT * FROM saved_search WHERE source_id = :sourceId AND name = :name
ORDER BY name COLLATE NOCASE ASC;
findById:
SELECT * FROM saved_search WHERE id = :id
ORDER BY name COLLATE NOCASE ASC;
deleteById:
DELETE FROM saved_search WHERE id = :id;
insert:
INSERT INTO saved_search(source_id, name, query, filters_json)
VALUES (:sourceId, :name, :query, :filtersJson);
selectLastInsertedRowId:
SELECT last_insert_rowid();

View file

@ -0,0 +1,8 @@
CREATE TABLE saved_search(
id INTEGER NOT NULL PRIMARY KEY,
source_id INTEGER NOT NULL,
name TEXT NOT NULL,
query TEXT,
filters_json TEXT,
UNIQUE (source_id, name)
);

View file

@ -0,0 +1,14 @@
package yokai.domain.source.browse.filter
import kotlinx.coroutines.flow.Flow
import yokai.domain.source.browse.filter.models.RawSavedSearch
interface SavedSearchRepository {
suspend fun findAll(): List<RawSavedSearch>
fun subscribeAllBySourceId(sourceId: Long): Flow<List<RawSavedSearch>>
suspend fun findAllBySourceId(sourceId: Long): List<RawSavedSearch>
suspend fun findOneBySourceIdAndName(sourceId: Long, name: String): RawSavedSearch?
suspend fun findById(id: Long): RawSavedSearch?
suspend fun deleteById(id: Long)
suspend fun insert(sourceId: Long, name: String, query: String?, filtersJson: String?): Long?
}

View file

@ -0,0 +1,25 @@
package yokai.domain.source.browse.filter.models
data class RawSavedSearch(
val id: Long,
val sourceId: Long,
val name: String,
val query: String?,
val filtersJson: String?,
) {
companion object {
fun mapper(
id: Long,
sourceId: Long,
name: String,
query: String?,
filtersJson: String?,
) = RawSavedSearch(
id = id,
sourceId = sourceId,
name = name,
query = query,
filtersJson = filtersJson,
)
}
}

View file

@ -1153,6 +1153,12 @@
<string name="default_orientation">Default orientation</string>
<string name="orientation">Orientation</string>
<string name="save">Save</string>
<string name="save_search_title">Saved searches</string>
<string name="save_search">Save current search query?</string>
<string name="save_search_hint">Save search name</string>
<string name="save_search_invalid_name">Invalid saved search name</string>
<string name="save_search_delete">Delete this saved search query?</string>
<string name="save_search_delete_message">Are you sure you wish to delete this saved search query: \'%1$s\'?</string>
<string name="search">Search</string>
<string name="search_">Search %1$s</string>
<string name="select_all">Select all</string>