mirror of
https://github.com/null2264/yokai.git
synced 2025-06-20 18:24:42 +00:00
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:
parent
7a08ca294a
commit
f13f98f19a
26 changed files with 880 additions and 80 deletions
|
@ -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
|
||||
|
|
|
@ -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*")
|
||||
|
|
|
@ -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,7 +412,11 @@ open class BrowseSourceController(bundle: Bundle) :
|
|||
oldFilters.add(i.state)
|
||||
}
|
||||
}
|
||||
sheet.onSearchClicked = {
|
||||
|
||||
filterSheet = SourceFilterSheet(
|
||||
activity = activity!!,
|
||||
searches = { savedSearches },
|
||||
onSearchClicked = {
|
||||
var matches = true
|
||||
for (i in presenter.sourceFilters.indices) {
|
||||
val filter = oldFilters.getOrNull(i)
|
||||
|
@ -417,27 +439,67 @@ open class BrowseSourceController(bundle: Bundle) :
|
|||
if (!matches) break
|
||||
}
|
||||
if (!matches) {
|
||||
val allDefault = presenter.filtersMatchDefault()
|
||||
showProgressBar()
|
||||
adapter?.clear()
|
||||
presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters)
|
||||
updatePopLatestIcons()
|
||||
applyFilters()
|
||||
}
|
||||
}
|
||||
|
||||
sheet.onResetClicked = {
|
||||
},
|
||||
onResetClicked = {
|
||||
presenter.appliedFilters = FilterList()
|
||||
val newFilters = presenter.source.getFilterList()
|
||||
presenter.sourceFilters = newFilters
|
||||
sheet.setFilters(presenter.filterItems)
|
||||
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
|
||||
}
|
||||
sheet.setOnDismissListener {
|
||||
filterSheet = null
|
||||
.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)
|
||||
}
|
||||
sheet.setOnCancelListener {
|
||||
filterSheet = null
|
||||
}
|
||||
sheet.show()
|
||||
.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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
fun setFilters(items: List<IFlexible<*>>) {
|
||||
adapter.updateDataSet(items)
|
||||
}
|
||||
|
||||
fun scrollToTop() {
|
||||
recyclerView?.layoutManager?.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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?,
|
||||
)
|
|
@ -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
|
||||
|
|
19
app/src/main/res/layout/dialog_text_input.xml
Normal file
19
app/src/main/res/layout/dialog_text_input.xml
Normal 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>
|
|
@ -1,5 +1,6 @@
|
|||
<?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: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"
|
||||
|
@ -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"
|
||||
|
|
27
app/src/main/res/layout/source_filter_sheet_saved_search.xml
Normal file
27
app/src/main/res/layout/source_filter_sheet_saved_search.xml
Normal 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>
|
|
@ -10,6 +10,7 @@ kotlin {
|
|||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
api(projects.domain)
|
||||
api(libs.bundles.db)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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();
|
|
@ -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)
|
||||
);
|
|
@ -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?
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue