diff --git a/CHANGELOG.md b/CHANGELOG.md index cd31bdfea6..be74d403d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 75a95ee35e..deb59dc23a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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*") diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt index 5890885938..76aa137449 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt @@ -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()) + 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() for (i in presenter.sourceFilters) { if (i is Filter.Group<*>) { @@ -394,50 +412,94 @@ open class BrowseSourceController(bundle: Bundle) : oldFilters.add(i.state) } } - sheet.onSearchClicked = { - var matches = true - for (i in presenter.sourceFilters.indices) { - val filter = oldFilters.getOrNull(i) - if (filter is List<*>) { - for (j in filter.indices) { - if (filter[j] != - ( - (presenter.sourceFilters[i] as Filter.Group<*>).state[j] as - Filter<*> - ).state - ) { - matches = false - break - } - } - } else if (filter != presenter.sourceFilters[i].state) { - matches = false - break - } - if (!matches) break - } - if (!matches) { - val allDefault = presenter.filtersMatchDefault() - showProgressBar() - adapter?.clear() - presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters) - updatePopLatestIcons() - } - } - sheet.onResetClicked = { - presenter.appliedFilters = FilterList() - val newFilters = presenter.source.getFilterList() - presenter.sourceFilters = newFilters - sheet.setFilters(presenter.filterItems) - } - sheet.setOnDismissListener { - filterSheet = null - } - sheet.setOnCancelListener { - filterSheet = null - } - sheet.show() + filterSheet = SourceFilterSheet( + activity = activity!!, + searches = { savedSearches }, + onSearchClicked = { + var matches = true + for (i in presenter.sourceFilters.indices) { + val filter = oldFilters.getOrNull(i) + if (filter is List<*>) { + for (j in filter.indices) { + if (filter[j] != + ( + (presenter.sourceFilters[i] as Filter.Group<*>).state[j] as + Filter<*> + ).state + ) { + matches = false + break + } + } + } else if (filter != presenter.sourceFilters[i].state) { + matches = false + break + } + if (!matches) break + } + if (!matches) { + applyFilters() + } + }, + onResetClicked = { + presenter.appliedFilters = FilterList() + val newFilters = presenter.source.getFilterList() + presenter.sourceFilters = newFilters + filterSheet?.setFilters(presenter.filterItems) + }, + onSaveClicked = { + viewScope.launchIO { + val names = presenter.loadSearches().map { it.name } + var searchName = "" + withUIContext { + activity!!.materialAlertDialog() + .setTitle(activity!!.getString(MR.strings.save_search)) + .setTextInput(hint = activity!!.getString(MR.strings.save_search_hint)) { input -> + searchName = input + } + .setPositiveButton(MR.strings.save) { _, _ -> + if (searchName.isNotBlank() && searchName !in names) { + presenter.saveSearch(searchName.trim(), presenter.query, presenter.sourceFilters) + filterSheet?.scrollToTop() + } else { + activity!!.toast(MR.strings.save_search_invalid_name) + } + } + .setNegativeButton(MR.strings.cancel, null) + .show() + } + } + }, + onSavedSearchClicked = ss@{ searchId -> + viewScope.launchIO { + val search = presenter.loadSearch(searchId) // Grab the latest data from database + if (search?.filters == null) return@launchIO + + withUIContext { + presenter.sourceFilters = search.filters + filterSheet?.setFilters(presenter.filterItems) + // This will call onSaveClicked() + filterSheet?.dismiss() + } + } + }, + onDeleteSavedSearchClicked = { searchId -> + activity!!.materialAlertDialog() + .setTitle(MR.strings.save_search_delete) + .setMessage(MR.strings.save_search_delete) + .setPositiveButton(MR.strings.cancel, null) + .setNegativeButton(android.R.string.ok) { _, _ -> presenter.deleteSearch(searchId) } + .show() + } + ) + filterSheet?.setFilters(presenter.filterItems) + presenter.filtersChanged = false + + filterSheet?.setOnCancelListener { filterSheet = null } + filterSheet?.setOnDismissListener { filterSheet = null } + + filterSheet?.show() } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt index 693e04c267..8ed61311f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt @@ -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 { + return getSavedSearch.awaitAllBySourceId(sourceId).applyAllSave(source.getFilterList()) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SavedSearchExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SavedSearchExtensions.kt new file mode 100644 index 0000000000..d07c2f9430 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SavedSearchExtensions.kt @@ -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(filtersJson!!) + } catch (e: Exception) { + null + } ?: return rt + + try { + filterSerializer.deserialize(originalFilters, filters) + return rt.copy(filters = originalFilters) + } catch (e: Exception) { + return rt + } +} + +fun List.applyAllSave( + originalFilters: FilterList, + json: Json = Injekt.get(), + filterSerializer: FilterSerializer = Injekt.get(), +) = this.map { it.applySave(originalFilters, json, filterSerializer) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SavedSearchesAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SavedSearchesAdapter.kt new file mode 100644 index 0000000000..5dfca7f047 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SavedSearchesAdapter.kt @@ -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, + val onSavedSearchClicked: (Long) -> Unit, + val onDeleteSavedSearchClicked: (Long) -> Unit, +) : + RecyclerView.Adapter() { + + 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, + ) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt index 9200f41ca9..2eb00a48df 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt @@ -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(activity) { - - private var filterChanged = true +class SourceFilterSheet( + val activity: Activity, + searches: () -> List = { emptyList() }, + val onSearchClicked: () -> Unit, + val onResetClicked: () -> Unit, + val onSaveClicked: () -> Unit, + val onSavedSearchClicked: (Long) -> Unit, + val onDeleteSavedSearchClicked: (Long) -> Unit, +) : E2EBottomSheetDialog(activity) { val adapter: FlexibleAdapter> = FlexibleAdapter>(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 { val fullHeight = activity.window.decorView.height - matchConstraintMaxHeight = - fullHeight - insets.getInsets(systemBars()).top - - binding.titleLayout.height - 75.dpToPx + matchConstraintMaxHeight = fullHeight - insets.getInsets(systemBars()).top - binding.titleLayout.height - 75.dpToPx } } @@ -85,7 +94,7 @@ class SourceFilterSheet(val activity: Activity) : }, ) - binding.filtersRecycler.viewTreeObserver.addOnScrollChangedListener { + recyclerView?.viewTreeObserver?.addOnScrollChangedListener { updateBottomButtons() } @@ -93,11 +102,13 @@ class SourceFilterSheet(val activity: Activity) : updateBottomButtons() } - binding.filtersRecycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) - binding.filtersRecycler.addItemDecoration(VertPaddingDecoration(12.dpToPx)) - binding.filtersRecycler.clipToPadding = false - binding.filtersRecycler.adapter = adapter - binding.filtersRecycler.setHasFixedSize(false) + recyclerView?.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) + recyclerView?.addItemDecoration(VertPaddingDecoration(12.dpToPx)) + recyclerView?.adapter = ConcatAdapter( + savedSearchesAdapter, + adapter, + ) + recyclerView?.setHasFixedSize(false) sheetBehavior.addBottomSheetCallback( object : BottomSheetBehavior.BottomSheetCallback() { @@ -112,7 +123,7 @@ class SourceFilterSheet(val activity: Activity) : ) } - fun setCardViewMax(insets: WindowInsetsCompat) { + private fun setCardViewMax(insets: WindowInsetsCompat) { val fullHeight = activity.window.decorView.height val newHeight = fullHeight - insets.getInsets(systemBars()).top - binding.titleLayout.height - 75.dpToPx @@ -126,8 +137,10 @@ class SourceFilterSheet(val activity: Activity) : override fun onStart() { super.onStart() sheetBehavior.collapse() + scrollToTop() // Force the sheet to scroll to the very top when it shows up updateBottomButtons() binding.root.post { + scrollToTop() // Force the sheet to scroll to the very top when it shows up updateBottomButtons() } } @@ -157,12 +170,14 @@ class SourceFilterSheet(val activity: Activity) : override fun dismiss() { super.dismiss() - if (filterChanged) { - onSearchClicked() - } + onSearchClicked() } fun setFilters(items: List>) { adapter.updateDataSet(items) } + + fun scrollToTop() { + recyclerView?.layoutManager?.scrollToPosition(0) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/MaterialAlertDialogExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/MaterialAlertDialogExtensions.kt index a855434180..59b9ccd9df 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/MaterialAlertDialogExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/MaterialAlertDialogExtensions.kt @@ -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()?.showSoftInput(this, 0) + } + } + return setView(binding.root) +} diff --git a/app/src/main/java/yokai/core/di/DomainModule.kt b/app/src/main/java/yokai/core/di/DomainModule.kt index 31e98fc4fa..406e6457db 100644 --- a/app/src/main/java/yokai/core/di/DomainModule.kt +++ b/app/src/main/java/yokai/core/di/DomainModule.kt @@ -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 { SavedSearchRepositoryImpl(get()) } + factory { DeleteSavedSearch(get()) } + factory { GetSavedSearch(get()) } + factory { InsertSavedSearch(get()) } + factory { FilterSerializer() } } diff --git a/app/src/main/java/yokai/domain/source/browse/filter/FilterSerializer.kt b/app/src/main/java/yokai/domain/source/browse/filter/FilterSerializer.kt new file mode 100644 index 0000000000..ec7e66a12f --- /dev/null +++ b/app/src/main/java/yokai/domain/source/browse/filter/FilterSerializer.kt @@ -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>( + HeaderSerializer(this), + SeparatorSerializer(this), + SelectSerializer(this), + TextSerializer(this), + CheckBoxSerializer(this), + TriStateSerializer(this), + GroupSerializer(this), + SortSerializer(this), + ) + + fun serialize(filters: FilterList) = buildJsonArray { + filters.filterIsInstance>().forEach { add(serialize(it)) } + } + + fun serialize(filter: Filter): JsonObject { + return serializers + .filterIsInstance>>() + .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>().zip(json).forEach { (filter, obj) -> + deserialize(filter, obj.jsonObject) + } + } + + fun deserialize(filter: Filter, json: JsonObject) { + val serializer = serializers + .filterIsInstance>>() + .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 Any?>).set(filter, res) + } + } + } +} diff --git a/app/src/main/java/yokai/domain/source/browse/filter/FilterTypeSerializer.kt b/app/src/main/java/yokai/domain/source/browse/filter/FilterTypeSerializer.kt new file mode 100644 index 0000000000..c763e6ca4e --- /dev/null +++ b/app/src/main/java/yokai/domain/source/browse/filter/FilterTypeSerializer.kt @@ -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> { + fun JsonObjectBuilder.serialize(filter: T) {} + fun deserialize(json: JsonObject, filter: T) {} + + fun mappings(): List>> = emptyList() + + val serializer: FilterSerializer + val type: String + val clazz: KClass + + companion object { + const val TYPE = "_type" + const val NAME = "name" + const val STATE = "state" + } +} + +class HeaderSerializer(override val serializer: FilterSerializer) : Serializer { + 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 { + 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> { + override val type = "SELECT" + override val clazz = Filter.Select::class + + override fun JsonObjectBuilder.serialize(filter: Filter.Select) { + putJsonArray("values") { + addAll(filter.values.map { it.toString() }) + } + } + + override fun mappings() = listOf( + Serializer.NAME to Filter.Select::name, + Serializer.STATE to Filter.Select::state, + ) +} + +class TextSerializer(override val serializer: FilterSerializer) : Serializer { + 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 { + 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 { + 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> { + override val type = "GROUP" + override val clazz = Filter.Group::class + + override fun JsonObjectBuilder.serialize(filter: Filter.Group) { + putJsonArray(Serializer.STATE) { + filter.state.forEach { state -> + @Suppress("UNCHECKED_CAST") + add((state as? Filter)?.let { serializer.serialize(it) } ?: JsonNull) + } + } + } + + override fun deserialize(json: JsonObject, filter: Filter.Group) { + json[Serializer.STATE]!!.jsonArray.forEachIndexed { index, element -> + if (element == JsonNull) return@forEachIndexed + + @Suppress("UNCHECKED_CAST") + serializer.deserialize(filter.state[index] as Filter, element.jsonObject) + } + } + + override fun mappings() = listOf( + Serializer.NAME to Filter.Group::name, + ) +} + +class SortSerializer(override val serializer: FilterSerializer) : Serializer { + 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" + } +} diff --git a/app/src/main/java/yokai/domain/source/browse/filter/interactor/DeleteSavedSearch.kt b/app/src/main/java/yokai/domain/source/browse/filter/interactor/DeleteSavedSearch.kt new file mode 100644 index 0000000000..6c460bca89 --- /dev/null +++ b/app/src/main/java/yokai/domain/source/browse/filter/interactor/DeleteSavedSearch.kt @@ -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) +} diff --git a/app/src/main/java/yokai/domain/source/browse/filter/interactor/GetSavedSearch.kt b/app/src/main/java/yokai/domain/source/browse/filter/interactor/GetSavedSearch.kt new file mode 100644 index 0000000000..da8a8fd5c6 --- /dev/null +++ b/app/src/main/java/yokai/domain/source/browse/filter/interactor/GetSavedSearch.kt @@ -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) +} diff --git a/app/src/main/java/yokai/domain/source/browse/filter/interactor/InsertSavedSearch.kt b/app/src/main/java/yokai/domain/source/browse/filter/interactor/InsertSavedSearch.kt new file mode 100644 index 0000000000..eb8e6ee08d --- /dev/null +++ b/app/src/main/java/yokai/domain/source/browse/filter/interactor/InsertSavedSearch.kt @@ -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) +} diff --git a/app/src/main/java/yokai/domain/source/browse/filter/models/SavedSearch.kt b/app/src/main/java/yokai/domain/source/browse/filter/models/SavedSearch.kt new file mode 100644 index 0000000000..d18e8ad2c2 --- /dev/null +++ b/app/src/main/java/yokai/domain/source/browse/filter/models/SavedSearch.kt @@ -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?, +) diff --git a/app/src/main/res/layout/browse_source_controller.xml b/app/src/main/res/layout/browse_source_controller.xml index 699d8109ca..31e054b8fc 100644 --- a/app/src/main/res/layout/browse_source_controller.xml +++ b/app/src/main/res/layout/browse_source_controller.xml @@ -25,11 +25,12 @@ tools:visibility="visible"/> - + + + + + + + + + + diff --git a/app/src/main/res/layout/source_filter_sheet.xml b/app/src/main/res/layout/source_filter_sheet.xml index 54bc53f241..a8e76eb145 100644 --- a/app/src/main/res/layout/source_filter_sheet.xml +++ b/app/src/main/res/layout/source_filter_sheet.xml @@ -1,12 +1,13 @@ - + + + + app:layout_constraintWidth_min="120dp" /> + + + + + + + diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 6f17a84c4b..4d826d7242 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -10,6 +10,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { + api(projects.domain) api(libs.bundles.db) } } diff --git a/data/src/commonMain/kotlin/yokai/data/source/browse/filter/SavedSearchRepositoryImpl.kt b/data/src/commonMain/kotlin/yokai/data/source/browse/filter/SavedSearchRepositoryImpl.kt new file mode 100644 index 0000000000..84696c3ad7 --- /dev/null +++ b/data/src/commonMain/kotlin/yokai/data/source/browse/filter/SavedSearchRepositoryImpl.kt @@ -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 = 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() + } +} diff --git a/data/src/commonMain/sqldelight/tachiyomi/data/saved_search.sq b/data/src/commonMain/sqldelight/tachiyomi/data/saved_search.sq new file mode 100644 index 0000000000..8a8e21252c --- /dev/null +++ b/data/src/commonMain/sqldelight/tachiyomi/data/saved_search.sq @@ -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(); diff --git a/data/src/commonMain/sqldelight/tachiyomi/migrations/28.sqm b/data/src/commonMain/sqldelight/tachiyomi/migrations/28.sqm new file mode 100644 index 0000000000..4a1f0540da --- /dev/null +++ b/data/src/commonMain/sqldelight/tachiyomi/migrations/28.sqm @@ -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) +); diff --git a/domain/src/commonMain/kotlin/yokai/domain/source/browse/filter/SavedSearchRepository.kt b/domain/src/commonMain/kotlin/yokai/domain/source/browse/filter/SavedSearchRepository.kt new file mode 100644 index 0000000000..abce233d30 --- /dev/null +++ b/domain/src/commonMain/kotlin/yokai/domain/source/browse/filter/SavedSearchRepository.kt @@ -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 + fun subscribeAllBySourceId(sourceId: Long): Flow> + suspend fun findAllBySourceId(sourceId: Long): List + 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? +} diff --git a/domain/src/commonMain/kotlin/yokai/domain/source/browse/filter/models/RawSavedSearch.kt b/domain/src/commonMain/kotlin/yokai/domain/source/browse/filter/models/RawSavedSearch.kt new file mode 100644 index 0000000000..b1ca1bdd2a --- /dev/null +++ b/domain/src/commonMain/kotlin/yokai/domain/source/browse/filter/models/RawSavedSearch.kt @@ -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, + ) + } +} diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 7198556527..32a846286a 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -1153,6 +1153,12 @@ Default orientation Orientation Save + Saved searches + Save current search query? + Save search name + Invalid saved search name + Delete this saved search query? + Are you sure you wish to delete this saved search query: \'%1$s\'? Search Search %1$s Select all