Migrate settings search view to Compose

This commit is contained in:
arkon 2022-05-23 18:33:46 -04:00
parent 3b2362c784
commit 9b0d85bf6c
9 changed files with 134 additions and 366 deletions

View file

@ -0,0 +1,85 @@
package eu.kanade.presentation.more.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.ui.setting.SettingsController
import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchHelper
import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchPresenter
import kotlin.reflect.full.createInstance
@Composable
fun SettingsSearchScreen(
nestedScroll: NestedScrollConnection,
presenter: SettingsSearchPresenter,
onClickResult: (SettingsController) -> Unit,
) {
val results by presenter.state.collectAsState()
val scrollState = rememberLazyListState()
ScrollbarLazyColumn(
modifier = Modifier
.nestedScroll(nestedScroll),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
state = scrollState,
) {
items(
items = results,
key = { it.key.toString() },
) { result ->
SearchResult(result, onClickResult)
}
}
}
@Composable
private fun SearchResult(
result: SettingsSearchHelper.SettingsSearchResult,
onClickResult: (SettingsController) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = 8.dp)
.clickable {
// Must pass a new Controller instance to avoid this error
// https://github.com/bluelinelabs/Conductor/issues/446
val controller = result.searchController::class.createInstance()
controller.preferenceKey = result.key
onClickResult(controller)
},
) {
Text(
text = result.title,
)
Text(
text = result.summary,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.outline,
),
)
Text(
text = result.breadcrumb,
style = MaterialTheme.typography.bodySmall,
)
}
}

View file

@ -1,82 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.search
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.ui.setting.SettingsController
/**
* Adapter that holds the search cards.
*
* @param controller instance of [SettingsSearchController].
*/
class SettingsSearchAdapter(val controller: SettingsSearchController) :
FlexibleAdapter<SettingsSearchItem>(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<Any?>,
) {
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<Parcelable>()
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}"
bundle.getSparseParcelableArray<Parcelable>(key)?.let {
holder.itemView.restoreHierarchyState(it)
bundle.remove(key)
}
}
interface OnTitleClickListener {
fun onTitleClick(ctrl: SettingsController)
}
}
private const val HOLDER_BUNDLE_KEY = "holder_bundle"

View file

@ -1,68 +1,45 @@
package eu.kanade.tachiyomi.ui.setting.search package eu.kanade.tachiyomi.ui.setting.search
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.LinearLayoutManager import androidx.compose.runtime.Composable
import dev.chrisbanes.insetter.applyInsetter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import eu.kanade.presentation.more.settings.SettingsSearchScreen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.SettingsSearchControllerBinding import eu.kanade.tachiyomi.ui.base.controller.ComposeController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.setting.SettingsController
/** class SettingsSearchController : ComposeController<SettingsSearchPresenter>() {
* This controller shows and manages the different search result in settings search.
* [SettingsSearchAdapter.OnTitleClickListener] called when preference is clicked in settings search
*/
class SettingsSearchController :
NucleusController<SettingsSearchControllerBinding, SettingsSearchPresenter>(),
SettingsSearchAdapter.OnTitleClickListener {
/**
* Adapter containing search results grouped by lang.
*/
private var adapter: SettingsSearchAdapter? = null
private lateinit var searchView: SearchView private lateinit var searchView: SearchView
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
} }
override fun createBinding(inflater: LayoutInflater) = SettingsSearchControllerBinding.inflate(inflater) override fun getTitle() = presenter.query
override fun getTitle(): String? { override fun createPresenter() = SettingsSearchPresenter()
return presenter.query
@Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
SettingsSearchScreen(
nestedScroll = nestedScrollInterop,
presenter = presenter,
onClickResult = { controller ->
searchView.query.let {
presenter.setLastSearchQuerySearchSettings(it.toString())
}
router.pushController(controller)
},
)
} }
/**
* Create the [SettingsSearchPresenter] used in controller.
*
* @return instance of [SettingsSearchPresenter]
*/
override fun createPresenter(): SettingsSearchPresenter {
return SettingsSearchPresenter()
}
/**
* 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) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.settings_main, menu) inflater.inflate(R.menu.settings_main, menu)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
// Initialize search menu // Initialize search menu
val searchItem = menu.findItem(R.id.action_search) val searchItem = menu.findItem(R.id.action_search)
searchView = searchItem.actionView as SearchView searchView = searchItem.actionView as SearchView
@ -70,7 +47,6 @@ class SettingsSearchController :
searchView.queryHint = applicationContext?.getString(R.string.action_search_settings) searchView.queryHint = applicationContext?.getString(R.string.action_search_settings)
searchItem.expandActionView() searchItem.expandActionView()
setItems(getResultSet())
searchItem.setOnActionExpandListener( searchItem.setOnActionExpandListener(
object : MenuItem.OnActionExpandListener { object : MenuItem.OnActionExpandListener {
@ -88,76 +64,17 @@ class SettingsSearchController :
searchView.setOnQueryTextListener( searchView.setOnQueryTextListener(
object : SearchView.OnQueryTextListener { object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
setItems(getResultSet(query)) presenter.searchSettings(query)
return false return false
} }
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
setItems(getResultSet(newText)) presenter.searchSettings(newText)
return false return false
} }
}, },
) )
searchView.setQuery(presenter.preferences.lastSearchQuerySearchSettings().get(), true) searchView.setQuery(presenter.getLastSearchQuerySearchSettings(), true)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = SettingsSearchAdapter(this)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
// load all search results
SettingsSearchHelper.initPreferenceSearchResultCollection(presenter.preferences.context)
}
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 a list of `SettingsSearchItem` to be shown as search results
* Future update: should we add a minimum length to the query before displaying results? Consider other languages.
*/
fun getResultSet(query: String? = null): List<SettingsSearchItem> {
if (!query.isNullOrBlank()) {
return SettingsSearchHelper.getFilteredResults(query)
.map { SettingsSearchItem(it, null) }
}
return mutableListOf()
}
/**
* Add search result to adapter.
*
* @param searchResult result of search.
*/
fun setItems(searchResult: List<SettingsSearchItem>) {
adapter?.updateDataSet(searchResult)
}
/**
* Opens a catalogue with the given search.
*/
override fun onTitleClick(ctrl: SettingsController) {
searchView.query.let {
presenter.preferences.lastSearchQuerySearchSettings().set(it.toString())
}
router.pushController(ctrl)
} }
} }

View file

@ -46,7 +46,7 @@ object SettingsSearchHelper {
* Must be called to populate `prefSearchResultList` * Must be called to populate `prefSearchResultList`
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
fun initPreferenceSearchResultCollection(context: Context) { fun initPreferenceSearchResults(context: Context) {
val preferenceManager = PreferenceManager(context) val preferenceManager = PreferenceManager(context)
prefSearchResultList.clear() prefSearchResultList.clear()

View file

@ -1,41 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.search
import android.view.View
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.databinding.SettingsSearchControllerCardBinding
import kotlin.reflect.full.createInstance
/**
* 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) :
FlexibleViewHolder(view, adapter) {
private val binding = SettingsSearchControllerCardBinding.bind(view)
init {
binding.titleWrapper.setOnClickListener {
adapter.getItem(bindingAdapterPosition)?.let {
val ctrl = it.settingsSearchResult.searchController::class.createInstance()
ctrl.preferenceKey = it.settingsSearchResult.key
// must pass a new Controller instance to avoid this error https://github.com/bluelinelabs/Conductor/issues/446
adapter.titleClickListener.onTitleClick(ctrl)
}
}
}
/**
* Show the loading of source search result.
*
* @param item item of card.
*/
fun bind(item: SettingsSearchItem) {
binding.searchResultPrefTitle.text = item.settingsSearchResult.title
binding.searchResultPrefSummary.text = item.settingsSearchResult.summary
binding.searchResultPrefBreadcrumb.text = item.settingsSearchResult.breadcrumb
}
}

View file

@ -1,57 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.search
import android.view.View
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.
*/
class SettingsSearchItem(
val settingsSearchResult: SettingsSearchHelper.SettingsSearchResult,
val results: List<SettingsSearchItem>?,
) :
AbstractFlexibleItem<SettingsSearchHolder>() {
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<IFlexible<RecyclerView.ViewHolder>>,
): SettingsSearchHolder {
return SettingsSearchHolder(view, adapter as SettingsSearchAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SettingsSearchHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (other is SettingsSearchItem) {
return settingsSearchResult == settingsSearchResult
}
return false
}
override fun hashCode(): Int {
return settingsSearchResult.hashCode()
}
}

View file

@ -3,20 +3,39 @@ package eu.kanade.tachiyomi.ui.setting.search
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class SettingsSearchPresenter : BasePresenter<SettingsSearchController>() { class SettingsSearchPresenter(
private val preferences: PreferencesHelper = Injekt.get(),
) : BasePresenter<SettingsSearchController>() {
val preferences: PreferencesHelper = Injekt.get() private val _state: MutableStateFlow<List<SettingsSearchHelper.SettingsSearchResult>> =
MutableStateFlow(emptyList())
val state: StateFlow<List<SettingsSearchHelper.SettingsSearchResult>> = _state.asStateFlow()
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
query = savedState?.getString(SettingsSearchPresenter::query.name) ?: "" // TODO - Some way to restore previous query?
SettingsSearchHelper.initPreferenceSearchResults(preferences.context)
} }
override fun onSave(state: Bundle) { fun getLastSearchQuerySearchSettings(): String {
state.putString(SettingsSearchPresenter::query.name, query) return preferences.lastSearchQuerySearchSettings().get()
super.onSave(state) }
fun setLastSearchQuerySearchSettings(query: String) {
preferences.lastSearchQuerySearchSettings().set(query)
}
fun searchSettings(query: String?) {
_state.value = if (!query.isNullOrBlank()) {
SettingsSearchHelper.getFilteredResults(query)
} else {
emptyList()
}
} }
} }

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="4dp"
tools:listitem="@layout/settings_search_controller_card" />
<FrameLayout
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0.75"
android:background="?attr/colorSurface" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</FrameLayout>

View file

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/title_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/search_result_pref_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItem"
tools:text="Title" />
<TextView
android:id="@+id/search_result_pref_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
tools:text="Summary" />
<TextView
android:id="@+id/search_result_pref_breadcrumb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?android:attr/textColorPrimary"
tools:text="Location" />
</LinearLayout>