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 ### Additions
- Add random library sort - Add random library sort
- Add the ability to save search queries
### Changes ### Changes
- Temporarily disable log file - Temporarily disable log file

View file

@ -25,6 +25,7 @@ fun runCommand(command: String): String {
return result.standardOutput.asText.get().trim() return result.standardOutput.asText.get().trim()
} }
@Suppress("PropertyName")
val _versionName = "1.9.8" val _versionName = "1.9.8"
val betaCount by lazy { val betaCount by lazy {
val betaTags = runCommand("git tag -l --sort=refname v${_versionName}-b*") 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 android.view.ViewGroup
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExploreOff 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.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible 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.dpToPx
import eu.kanade.tachiyomi.util.system.e import eu.kanade.tachiyomi.util.system.e
import eu.kanade.tachiyomi.util.system.launchIO 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.openInBrowser
import eu.kanade.tachiyomi.util.system.setTextInput
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.view.activityBinding 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.isControllerVisible
import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.scrollViewWith
import eu.kanade.tachiyomi.util.view.setAction 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.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.snack
import eu.kanade.tachiyomi.util.view.withFadeTransaction import eu.kanade.tachiyomi.util.view.withFadeTransaction
import eu.kanade.tachiyomi.widget.AutofitRecyclerView import eu.kanade.tachiyomi.widget.AutofitRecyclerView
@ -68,6 +77,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import yokai.domain.manga.interactor.GetManga import yokai.domain.manga.interactor.GetManga
import yokai.domain.source.browse.filter.models.SavedSearch
import yokai.i18n.MR import yokai.i18n.MR
import yokai.presentation.core.icons.CustomIcons import yokai.presentation.core.icons.CustomIcons
import yokai.presentation.core.icons.LocalSource import yokai.presentation.core.icons.LocalSource
@ -140,6 +150,9 @@ open class BrowseSourceController(bundle: Bundle) :
private var filterSheet: SourceFilterSheet? = null private var filterSheet: SourceFilterSheet? = null
private var lastPosition: Int = -1 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 private val isBehindGlobalSearch: Boolean
get() = router.backstackSize >= 2 && router.backstack[router.backstackSize - 2].controller is GlobalSearchController 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.isVisible = presenter.sourceFilters.isNotEmpty()
binding.fab.setOnClickListener { showFilters() } binding.fab.setOnClickListener { showFilters() }
activityBinding?.appBar?.y = 0f activityBinding?.appBar?.y = 0f
activityBinding?.appBar?.updateAppBarAfterY(recycler) activityBinding?.appBar?.updateAppBarAfterY(recycler)
activityBinding?.appBar?.lockYPos = true activityBinding?.appBar?.lockYPos = true
@ -376,12 +390,16 @@ open class BrowseSourceController(bundle: Bundle) :
return true return true
} }
private fun applyFilters() {
val allDefault = presenter.filtersMatchDefault()
showProgressBar()
adapter?.clear()
presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters)
updatePopLatestIcons()
}
private fun showFilters() { private fun showFilters() {
if (filterSheet != null) return if (filterSheet != null) return
val sheet = SourceFilterSheet(activity!!)
filterSheet = sheet
sheet.setFilters(presenter.filterItems)
presenter.filtersChanged = false
val oldFilters = mutableListOf<Any?>() val oldFilters = mutableListOf<Any?>()
for (i in presenter.sourceFilters) { for (i in presenter.sourceFilters) {
if (i is Filter.Group<*>) { if (i is Filter.Group<*>) {
@ -394,50 +412,94 @@ open class BrowseSourceController(bundle: Bundle) :
oldFilters.add(i.state) 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 = { filterSheet = SourceFilterSheet(
presenter.appliedFilters = FilterList() activity = activity!!,
val newFilters = presenter.source.getFilterList() searches = { savedSearches },
presenter.sourceFilters = newFilters onSearchClicked = {
sheet.setFilters(presenter.filterItems) var matches = true
} for (i in presenter.sourceFilters.indices) {
sheet.setOnDismissListener { val filter = oldFilters.getOrNull(i)
filterSheet = null if (filter is List<*>) {
} for (j in filter.indices) {
sheet.setOnCancelListener { if (filter[j] !=
filterSheet = null (
} (presenter.sourceFilters[i] as Filter.Group<*>).state[j] as
sheet.show() 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.TriStateItem
import eu.kanade.tachiyomi.ui.source.filter.TriStateSectionItem import eu.kanade.tachiyomi.ui.source.filter.TriStateSectionItem
import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellableIO
import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
@ -35,8 +36,12 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy 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.InsertManga
import yokai.domain.manga.interactor.UpdateManga import yokai.domain.manga.interactor.UpdateManga
import yokai.domain.manga.models.MangaUpdate 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 import yokai.domain.ui.UiPreferences
// FIXME: Migrate to Compose // FIXME: Migrate to Compose
@ -63,6 +73,11 @@ open class BrowseSourcePresenter(
private val insertManga: InsertManga by injectLazy() private val insertManga: InsertManga by injectLazy()
private val updateManga: UpdateManga 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. * Selected source.
*/ */
@ -129,6 +144,15 @@ open class BrowseSourcePresenter(
} }
} }
filtersChanged = false 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.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePaddingRelative import androidx.core.view.updatePaddingRelative
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import eu.davidea.flexibleadapter.FlexibleAdapter 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.collapse
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsetsCompat import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsetsCompat
import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog
import yokai.domain.source.browse.filter.models.SavedSearch
import yokai.presentation.component.recyclerview.VertPaddingDecoration import yokai.presentation.component.recyclerview.VertPaddingDecoration
import android.R as AR import android.R as AR
class SourceFilterSheet(val activity: Activity) : class SourceFilterSheet(
E2EBottomSheetDialog<SourceFilterSheetBinding>(activity) { val activity: Activity,
searches: () -> List<SavedSearch> = { emptyList() },
private var filterChanged = true 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) val adapter: FlexibleAdapter<IFlexible<*>> = FlexibleAdapter<IFlexible<*>>(null)
.setDisplayHeadersAtStartUp(true) .setDisplayHeadersAtStartUp(true)
var onSearchClicked = {}
var onResetClicked = {}
override var recyclerView: RecyclerView? = binding.filtersRecycler override var recyclerView: RecyclerView? = binding.filtersRecycler
override fun createBinding(inflater: LayoutInflater) = SourceFilterSheetBinding.inflate(inflater) override fun createBinding(inflater: LayoutInflater) = SourceFilterSheetBinding.inflate(inflater)
private val savedSearchesAdapter = SavedSearchesAdapter(
searches = searches,
onSavedSearchClicked = onSavedSearchClicked,
onDeleteSavedSearchClicked = onDeleteSavedSearchClicked,
)
init { init {
binding.searchBtn.setOnClickListener { dismiss() } binding.searchBtn.setOnClickListener { dismiss() }
binding.resetBtn.setOnClickListener { onResetClicked() } binding.resetBtn.setOnClickListener { onResetClicked() }
binding.saveBtn.setOnClickListener { onSaveClicked() }
sheetBehavior.peekHeight = 450.dpToPx sheetBehavior.peekHeight = 450.dpToPx
sheetBehavior.collapse() sheetBehavior.collapse()
@ -54,9 +65,7 @@ class SourceFilterSheet(val activity: Activity) :
binding.cardView.doOnApplyWindowInsetsCompat { _, insets, _ -> binding.cardView.doOnApplyWindowInsetsCompat { _, insets, _ ->
binding.cardView.updateLayoutParams<ConstraintLayout.LayoutParams> { binding.cardView.updateLayoutParams<ConstraintLayout.LayoutParams> {
val fullHeight = activity.window.decorView.height val fullHeight = activity.window.decorView.height
matchConstraintMaxHeight = matchConstraintMaxHeight = fullHeight - insets.getInsets(systemBars()).top - binding.titleLayout.height - 75.dpToPx
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() updateBottomButtons()
} }
@ -93,11 +102,13 @@ class SourceFilterSheet(val activity: Activity) :
updateBottomButtons() updateBottomButtons()
} }
binding.filtersRecycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) recyclerView?.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context)
binding.filtersRecycler.addItemDecoration(VertPaddingDecoration(12.dpToPx)) recyclerView?.addItemDecoration(VertPaddingDecoration(12.dpToPx))
binding.filtersRecycler.clipToPadding = false recyclerView?.adapter = ConcatAdapter(
binding.filtersRecycler.adapter = adapter savedSearchesAdapter,
binding.filtersRecycler.setHasFixedSize(false) adapter,
)
recyclerView?.setHasFixedSize(false)
sheetBehavior.addBottomSheetCallback( sheetBehavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() { 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 fullHeight = activity.window.decorView.height
val newHeight = fullHeight - insets.getInsets(systemBars()).top - val newHeight = fullHeight - insets.getInsets(systemBars()).top -
binding.titleLayout.height - 75.dpToPx binding.titleLayout.height - 75.dpToPx
@ -126,8 +137,10 @@ class SourceFilterSheet(val activity: Activity) :
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
sheetBehavior.collapse() sheetBehavior.collapse()
scrollToTop() // Force the sheet to scroll to the very top when it shows up
updateBottomButtons() updateBottomButtons()
binding.root.post { binding.root.post {
scrollToTop() // Force the sheet to scroll to the very top when it shows up
updateBottomButtons() updateBottomButtons()
} }
} }
@ -157,12 +170,14 @@ class SourceFilterSheet(val activity: Activity) :
override fun dismiss() { override fun dismiss() {
super.dismiss() super.dismiss()
if (filterChanged) { onSearchClicked()
onSearchClicked()
}
} }
fun setFilters(items: List<IFlexible<*>>) { fun setFilters(items: List<IFlexible<*>>) {
adapter.updateDataSet(items) 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatCheckedTextView import androidx.appcompat.widget.AppCompatCheckedTextView
import androidx.core.content.getSystemService
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.databinding.CustomDialogTitleMessageBinding import eu.kanade.tachiyomi.databinding.CustomDialogTitleMessageBinding
import eu.kanade.tachiyomi.databinding.DialogQuadstateBinding import eu.kanade.tachiyomi.databinding.DialogQuadstateBinding
import eu.kanade.tachiyomi.databinding.DialogTextInputBinding
import eu.kanade.tachiyomi.widget.TriStateCheckBox import eu.kanade.tachiyomi.widget.TriStateCheckBox
import eu.kanade.tachiyomi.widget.materialdialogs.TriStateMultiChoiceDialogAdapter import eu.kanade.tachiyomi.widget.materialdialogs.TriStateMultiChoiceDialogAdapter
import eu.kanade.tachiyomi.widget.materialdialogs.TriStateMultiChoiceListener import eu.kanade.tachiyomi.widget.materialdialogs.TriStateMultiChoiceListener
@ -155,3 +159,23 @@ val DialogInterface.isPromptChecked: Boolean
fun interface MaterialAlertDialogBuilderOnCheckClickListener { fun interface MaterialAlertDialogBuilderOnCheckClickListener {
fun onClick(var1: DialogInterface?, var3: Boolean) 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.history.HistoryRepositoryImpl
import yokai.data.library.custom.CustomMangaRepositoryImpl import yokai.data.library.custom.CustomMangaRepositoryImpl
import yokai.data.manga.MangaRepositoryImpl import yokai.data.manga.MangaRepositoryImpl
import yokai.data.source.browse.filter.SavedSearchRepositoryImpl
import yokai.data.track.TrackRepositoryImpl import yokai.data.track.TrackRepositoryImpl
import yokai.domain.category.CategoryRepository import yokai.domain.category.CategoryRepository
import yokai.domain.category.interactor.DeleteCategories 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.InsertManga
import yokai.domain.manga.interactor.UpdateManga import yokai.domain.manga.interactor.UpdateManga
import yokai.domain.recents.interactor.GetRecents 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.TrackRepository
import yokai.domain.track.interactor.DeleteTrack import yokai.domain.track.interactor.DeleteTrack
import yokai.domain.track.interactor.GetTrack import yokai.domain.track.interactor.GetTrack
@ -95,4 +101,10 @@ fun domainModule() = module {
factory { DeleteTrack(get()) } factory { DeleteTrack(get()) }
factory { GetTrack(get()) } factory { GetTrack(get()) }
factory { InsertTrack(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

@ -25,11 +25,12 @@
tools:visibility="visible"/> tools:visibility="visible"/>
</FrameLayout> </FrameLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab" android:id="@+id/fab"
style="@style/Theme.Widget.FAB" style="@style/Theme.Widget.FAB"
android:text="@string/filter" android:text="@string/filter"
android:visibility="gone"
app:icon="@drawable/ic_filter_list_24dp"/> app:icon="@drawable/ic_filter_list_24dp"/>
<eu.kanade.tachiyomi.widget.EmptyView <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"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/source_filter_sheet" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:id="@+id/source_filter_sheet"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:background="@drawable/bottom_sheet_rounded_background" android:layout_height="wrap_content"
app:layout_constraintVertical_chainStyle="packed" android:background="@drawable/bottom_sheet_rounded_background"
android:backgroundTint="?attr/background"> app:layout_constraintVertical_chainStyle="packed"
android:backgroundTint="?attr/background">
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
android:id="@+id/card_view" android:id="@+id/card_view"
@ -24,7 +25,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:clipToPadding="false"
android:background="@drawable/bottom_sheet_rounded_background" android:background="@drawable/bottom_sheet_rounded_background"
android:backgroundTint="?attr/colorSurface" android:backgroundTint="?attr/colorSurface"
app:layout_constraintBottom_toTopOf="@id/title_layout" app:layout_constraintBottom_toTopOf="@id/title_layout"
@ -65,6 +65,25 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="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 <com.google.android.material.button.MaterialButton
android:id="@+id/search_btn" android:id="@+id/search_btn"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -79,7 +98,7 @@
app:layout_constraintHorizontal_chainStyle="spread" app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/reset_btn" app:layout_constraintStart_toEndOf="@id/reset_btn"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="150dp" /> app:layout_constraintWidth_min="120dp" />
<View <View
android:id="@+id/bottom_divider" 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 { sourceSets {
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
api(projects.domain)
api(libs.bundles.db) 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="default_orientation">Default orientation</string>
<string name="orientation">Orientation</string> <string name="orientation">Orientation</string>
<string name="save">Save</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</string>
<string name="search_">Search %1$s</string> <string name="search_">Search %1$s</string>
<string name="select_all">Select all</string> <string name="select_all">Select all</string>