diff --git a/app/build.gradle b/app/build.gradle index 4f5df7db6..fcab50ecc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -201,6 +201,8 @@ dependencies { // Preferences implementation 'com.github.tfcporciuncula:flow-preferences:1.3.0' + implementation 'com.github.ByteHamster:SearchPreference:v1.0.3' + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" // Model View Presenter final nucleus_version = '3.0.0' diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsControllerFactory.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsControllerFactory.kt new file mode 100644 index 000000000..7f892f56d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsControllerFactory.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.ui.setting + +import android.content.Context +import com.bytehamster.lib.preferencesearch.SearchPreference +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys + +class SettingsControllerFactory(context: Context) { + var searchablePrefs = Keys::class.members.map { member -> SearchPreference(context).key = member.name } + + companion object Factory { + var controllers: List? = null + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index 9c3f48580..72527ee64 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -1,14 +1,23 @@ package eu.kanade.tachiyomi.ui.setting +import android.view.Menu +import android.view.MenuInflater +import androidx.appcompat.widget.SearchView import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.setting.settingssearch.SettingsSearchController import eu.kanade.tachiyomi.util.preference.iconRes import eu.kanade.tachiyomi.util.preference.iconTint import eu.kanade.tachiyomi.util.preference.onClick import eu.kanade.tachiyomi.util.preference.preference import eu.kanade.tachiyomi.util.preference.titleRes import eu.kanade.tachiyomi.util.system.getResourceColor +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.QueryTextEvent +import reactivecircus.flowbinding.appcompat.queryTextEvents class SettingsMainController : SettingsController() { @@ -82,4 +91,29 @@ class SettingsMainController : SettingsController() { private fun navigateTo(controller: SettingsController) { router.pushController(controller.withFadeTransaction()) } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + // Inflate menu + inflater.inflate(R.menu.settings_main, menu) + + // Initialize search option. + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + // Change hint to show global search. + searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint) + + // Create query listener which opens the global search view. + searchView.queryTextEvents() + .filterIsInstance() + .onEach { performSettingsSearch(it.queryText.toString()) } + .launchIn(scope) + } + + private fun performSettingsSearch(query: String) { + router.pushController( + SettingsSearchController(query).withFadeTransaction() + ) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchAdapter.kt new file mode 100644 index 000000000..a733df777 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchAdapter.kt @@ -0,0 +1,81 @@ +package eu.kanade.tachiyomi.ui.setting.settingssearch + +import android.os.Bundle +import android.os.Parcelable +import android.util.SparseArray +import androidx.preference.Preference +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter + +/** + * Adapter that holds the search cards. + * + * @param controller instance of [SettingsSearchController]. + */ +class SettingsSearchAdapter(val controller: SettingsSearchController) : + FlexibleAdapter(null, controller, true) { + + val titleClickListener: OnTitleClickListener = controller + + /** + * Bundle where the view state of the holders is saved. + */ + private var bundle = Bundle() + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { + super.onBindViewHolder(holder, position, payloads) + restoreHolderState(holder) + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + super.onViewRecycled(holder) + saveHolderState(holder, bundle) + } + + override fun onSaveInstanceState(outState: Bundle) { + val holdersBundle = Bundle() + allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) } + outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle) + super.onSaveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!! + } + + /** + * Saves the view state of the given holder. + * + * @param holder The holder to save. + * @param outState The bundle where the state is saved. + */ + private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) { + val key = "holder_${holder.bindingAdapterPosition}" + val holderState = SparseArray() + holder.itemView.saveHierarchyState(holderState) + outState.putSparseParcelableArray(key, holderState) + } + + /** + * Restores the view state of the given holder. + * + * @param holder The holder to restore. + */ + private fun restoreHolderState(holder: RecyclerView.ViewHolder) { + val key = "holder_${holder.bindingAdapterPosition}" + val holderState = bundle.getSparseParcelableArray(key) + if (holderState != null) { + holder.itemView.restoreHierarchyState(holderState) + bundle.remove(key) + } + } + + interface OnTitleClickListener { + fun onTitleClick(pref: Preference) + } + + private companion object { + const val HOLDER_BUNDLE_KEY = "holder_bundle" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchController.kt new file mode 100644 index 000000000..4b9a74233 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchController.kt @@ -0,0 +1,177 @@ +package eu.kanade.tachiyomi.ui.setting.settingssearch + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.SearchView +import androidx.preference.Preference +import androidx.recyclerview.widget.LinearLayoutManager +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.SettingsSearchControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.setting.SettingsControllerFactory +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.QueryTextEvent +import reactivecircus.flowbinding.appcompat.queryTextEvents + +/** + * This controller shows and manages the different search result in settings search. + * This controller should only handle UI actions, IO actions should be done by [SettingsSearchPresenter] + * [SettingsSearchAdapter.WhatListener] called when preference is clicked in settings search + */ +open class SettingsSearchController( + protected val initialQuery: String? = null, + protected val extensionFilter: String? = null +) : NucleusController(), + SettingsSearchAdapter.OnTitleClickListener { + + /** + * Adapter containing search results grouped by lang. + */ + protected var adapter: SettingsSearchAdapter? = null + + protected var controllers = SettingsControllerFactory.controllers + + init { + setHasOptionsMenu(true) + } + + /** + * Initiate the view with [R.layout.settings_search_controller]. + * + * @param inflater used to load the layout xml. + * @param container containing parent views. + * @return inflated view + */ + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = SettingsSearchControllerBinding.inflate(inflater) + return binding.root + } + + override fun getTitle(): String? { + return presenter.query + } + + /** + * Create the [SettingsSearchPresenter] used in controller. + * + * @return instance of [SettingsSearchPresenter] + */ + override fun createPresenter(): SettingsSearchPresenter { + return SettingsSearchPresenter(initialQuery, extensionFilter) + } + + /** + * Adds items to the options menu. + * + * @param menu menu containing options. + * @param inflater used to load the menu xml. + */ + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + // Inflate menu. + inflater.inflate(R.menu.settings_main, menu) + + // Initialize search menu + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + searchView.onActionViewExpanded() // Required to show the query in the view + searchView.setQuery(presenter.query, false) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + return true + } + }) + + searchView.queryTextEvents() + .filterIsInstance() + .onEach { + presenter.search(it.queryText.toString()) + searchItem.collapseActionView() + setTitle() // Update toolbar title + } + .launchIn(scope) + } + + /** + * Called when the view is created + * + * @param view view of controller + */ + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = SettingsSearchAdapter(this) + + // Create recycler and set adapter. + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.adapter = adapter + } + + override fun onDestroyView(view: View) { + adapter = null + super.onDestroyView(view) + } + + override fun onSaveViewState(view: View, outState: Bundle) { + super.onSaveViewState(view, outState) + adapter?.onSaveInstanceState(outState) + } + + override fun onRestoreViewState(view: View, savedViewState: Bundle) { + super.onRestoreViewState(view, savedViewState) + adapter?.onRestoreInstanceState(savedViewState) + } + + /** + * Returns the view holder for the given preference. + * + * @param pref used to find holder containing source + * @return the holder of the preference or null if it's not bound. + */ + private fun getHolder(pref: Preference): SettingsSearchHolder? { + val adapter = adapter ?: return null + + adapter.allBoundViewHolders.forEach { holder -> + val item = adapter.getItem(holder.bindingAdapterPosition) + if (item != null && pref.key == item.pref.key) { + return holder as SettingsSearchHolder + } + } + + return null + } + + /** + * Add search result to adapter. + * + * @param searchResult result of search. + */ + fun setItems(searchResult: List) { + adapter?.updateDataSet(searchResult) + } + + /** + * Opens a catalogue with the given search. + */ + override fun onTitleClick(pref: Preference) { + // TODO - These asserts will be the death of me, fix them. + for (ctrl in this!!.controllers!!) { + if (ctrl.findPreference(pref.key) != null) { + router.pushController(ctrl.withFadeTransaction()) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchHolder.kt new file mode 100644 index 000000000..a5c91bec1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchHolder.kt @@ -0,0 +1,51 @@ +package eu.kanade.tachiyomi.ui.setting.settingssearch + +import android.view.View +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import kotlinx.android.synthetic.main.settings_search_controller_card.setting +import kotlinx.android.synthetic.main.settings_search_controller_card.title_wrapper + +/** + * Holder that binds the [SettingsSearchItem] containing catalogue cards. + * + * @param view view of [SettingsSearchItem] + * @param adapter instance of [SettingsSearchAdapter] + */ +class SettingsSearchHolder(view: View, val adapter: SettingsSearchAdapter) : + BaseFlexibleViewHolder(view, adapter) { + + /** + * Adapter containing preference from search results. + */ + private val settingsAdapter = SettingsSearchAdapter(adapter.controller) + + private var lastBoundResults: List? = null + + init { + title_wrapper.setOnClickListener { + adapter.getItem(bindingAdapterPosition)?.let { + adapter.titleClickListener.onTitleClick(it.pref) + } + } + } + + /** + * Show the loading of source search result. + * + * @param item item of card. + */ + fun bind(item: SettingsSearchItem) { + val preference = item.pref + val results = item.results + + val titlePrefix = if (item.highlighted) "▶ " else "" + + // Set Title with country code if available. + setting.text = titlePrefix + preference.key + + if (results !== lastBoundResults) { + settingsAdapter.updateDataSet(results) + lastBoundResults = results + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchItem.kt new file mode 100644 index 000000000..60fb219a2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchItem.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.ui.setting.settingssearch + +import android.view.View +import androidx.preference.Preference +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R + +/** + * Item that contains search result information. + * + * @param pref the source for the search results. + * @param results the search results. + * @param highlighted whether this search item should be highlighted/marked in the catalogue search view. + */ +class SettingsSearchItem(val pref: Preference, val results: List?, val highlighted: Boolean = false) : + AbstractFlexibleItem() { + + /** + * Set view. + * + * @return id of view + */ + override fun getLayoutRes(): Int { + return R.layout.settings_search_controller_card + } + + /** + * Create view holder (see [SettingsSearchAdapter]. + * + * @return holder of view. + */ + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SettingsSearchHolder { + return SettingsSearchHolder(view, adapter as SettingsSearchAdapter) + } + + /** + * Bind item to view. + */ + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: SettingsSearchHolder, + position: Int, + payloads: List? + ) { + holder.bind(this) + } + + /** + * Used to check if two items are equal. + * + * @return items are equal? + */ + override fun equals(other: Any?): Boolean { + if (other is SettingsSearchItem) { + return pref.key == other.pref.key + } + return false + } + + /** + * Return hash code of item. + * + * @return hashcode + */ + override fun hashCode(): Int { + return pref.key.toInt() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchPresenter.kt new file mode 100644 index 000000000..8fd1a5259 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/settingssearch/SettingsSearchPresenter.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.ui.setting.settingssearch + +import android.os.Bundle +import androidx.preference.Preference +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import rx.Subscription +import rx.subjects.PublishSubject +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +/** + * Presenter of [SettingsSearchController] + * Function calls should be done from here. UI calls should be done from the controller. + * + * @param sourceManager manages the different sources. + * @param db manages the database calls. + * @param preferences manages the preference calls. + */ +open class SettingsSearchPresenter( + val initialQuery: String? = "", + val initialExtensionFilter: String? = null, + val sourceManager: SourceManager = Injekt.get(), + val db: DatabaseHelper = Injekt.get(), + val preferences: PreferencesHelper = Injekt.get() +) : BasePresenter() { + + /** + * Query from the view. + */ + var query = "" + private set + + /** + * Fetches the different sources by user settings. + */ + private var fetchSourcesSubscription: Subscription? = null + + /** + * Subject which fetches image of given manga. + */ + private val fetchImageSubject = PublishSubject.create, Source>>() + + /** + * Subscription for fetching images of manga. + */ + private var fetchImageSubscription: Subscription? = null + + private val extensionManager by injectLazy() + + private var extensionFilter: String? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + extensionFilter = savedState?.getString(SettingsSearchPresenter::extensionFilter.name) + ?: initialExtensionFilter + + // TODO - Perform a search with previous or initial state + } + + override fun onDestroy() { + fetchSourcesSubscription?.unsubscribe() + fetchImageSubscription?.unsubscribe() + super.onDestroy() + } + + override fun onSave(state: Bundle) { + state.putString(BrowseSourcePresenter::query.name, query) + state.putString(SettingsSearchPresenter::extensionFilter.name, extensionFilter) + super.onSave(state) + } + + fun search(toString: String) { + // TODO - My ignorance of kotlin pattern is showing here... why would the search logic take place in the Presenter? + } +} diff --git a/app/src/main/res/layout/settings_search_controller.xml b/app/src/main/res/layout/settings_search_controller.xml new file mode 100644 index 000000000..807f904dd --- /dev/null +++ b/app/src/main/res/layout/settings_search_controller.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/settings_search_controller_card.xml b/app/src/main/res/layout/settings_search_controller_card.xml new file mode 100644 index 000000000..94c63ed4b --- /dev/null +++ b/app/src/main/res/layout/settings_search_controller_card.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/settings_main.xml b/app/src/main/res/menu/settings_main.xml new file mode 100644 index 000000000..112431a72 --- /dev/null +++ b/app/src/main/res/menu/settings_main.xml @@ -0,0 +1,12 @@ + + + + +