New search suggestion UI
This commit is contained in:
@@ -13,6 +13,14 @@ abstract class MangaDao {
|
||||
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
||||
abstract suspend fun find(id: Long): MangaWithTags?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insert(manga: MangaEntity): Long
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.*
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -24,34 +27,41 @@ import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSection
|
||||
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
|
||||
import org.koitharu.kotatsu.history.ui.HistoryListFragment
|
||||
import org.koitharu.kotatsu.local.ui.LocalListFragment
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
import org.koitharu.kotatsu.search.ui.SearchHelper
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchUI
|
||||
import org.koitharu.kotatsu.settings.AppUpdateChecker
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.tracker.ui.FeedFragment
|
||||
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
import java.io.Closeable
|
||||
|
||||
class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
NavigationView.OnNavigationItemSelectedListener,
|
||||
View.OnClickListener {
|
||||
View.OnClickListener, SearchSuggestionListener, MenuItem.OnActionExpandListener {
|
||||
|
||||
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
|
||||
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
|
||||
mode = LazyThreadSafetyMode.NONE
|
||||
)
|
||||
|
||||
private lateinit var drawerToggle: ActionBarDrawerToggle
|
||||
private var closeable: Closeable? = null
|
||||
private var searchUi: SearchUI? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMainBinding.inflate(layoutInflater))
|
||||
drawerToggle =
|
||||
ActionBarDrawerToggle(
|
||||
drawerToggle = ActionBarDrawerToggle(
|
||||
this,
|
||||
binding.drawer,
|
||||
binding.toolbar,
|
||||
@@ -68,7 +78,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
setOnClickListener(this@MainActivity)
|
||||
}
|
||||
|
||||
supportFragmentManager.findFragmentById(R.id.container)?.let {
|
||||
supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
|
||||
binding.fab.isVisible = it is HistoryListFragment
|
||||
} ?: run {
|
||||
openDefaultSection()
|
||||
@@ -84,11 +94,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
viewModel.remoteSources.observe(this, this::updateSideMenu)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
closeable?.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
drawerToggle.syncState()
|
||||
@@ -109,8 +114,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_main, menu)
|
||||
menu.findItem(R.id.action_search)?.let { menuItem ->
|
||||
closeable = SearchHelper.setupSearchView(menuItem)
|
||||
searchUi = menu.findItem(R.id.action_search)?.let { menuItem ->
|
||||
onMenuItemActionCollapse(menuItem)
|
||||
menuItem.setOnActionExpandListener(this)
|
||||
SearchUI.from(menuItem, this)
|
||||
}
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
@@ -131,28 +138,32 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
if (item.groupId == R.id.group_remote_sources) {
|
||||
val source = MangaSource.values().getOrNull(item.itemId) ?: return false
|
||||
setPrimaryFragment(RemoteListFragment.newInstance(source))
|
||||
} else when (item.itemId) {
|
||||
R.id.nav_history -> {
|
||||
viewModel.defaultSection = AppSection.HISTORY
|
||||
setPrimaryFragment(HistoryListFragment.newInstance())
|
||||
searchSuggestionViewModel.onSourceChanged(source)
|
||||
} else {
|
||||
searchSuggestionViewModel.onSourceChanged(null)
|
||||
when (item.itemId) {
|
||||
R.id.nav_history -> {
|
||||
viewModel.defaultSection = AppSection.HISTORY
|
||||
setPrimaryFragment(HistoryListFragment.newInstance())
|
||||
}
|
||||
R.id.nav_favourites -> {
|
||||
viewModel.defaultSection = AppSection.FAVOURITES
|
||||
setPrimaryFragment(FavouritesContainerFragment.newInstance())
|
||||
}
|
||||
R.id.nav_local_storage -> {
|
||||
viewModel.defaultSection = AppSection.LOCAL
|
||||
setPrimaryFragment(LocalListFragment.newInstance())
|
||||
}
|
||||
R.id.nav_feed -> {
|
||||
viewModel.defaultSection = AppSection.FEED
|
||||
setPrimaryFragment(FeedFragment.newInstance())
|
||||
}
|
||||
R.id.nav_action_settings -> {
|
||||
startActivity(SettingsActivity.newIntent(this))
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
R.id.nav_favourites -> {
|
||||
viewModel.defaultSection = AppSection.FAVOURITES
|
||||
setPrimaryFragment(FavouritesContainerFragment.newInstance())
|
||||
}
|
||||
R.id.nav_local_storage -> {
|
||||
viewModel.defaultSection = AppSection.LOCAL
|
||||
setPrimaryFragment(LocalListFragment.newInstance())
|
||||
}
|
||||
R.id.nav_feed -> {
|
||||
viewModel.defaultSection = AppSection.FEED
|
||||
setPrimaryFragment(FeedFragment.newInstance())
|
||||
}
|
||||
R.id.nav_action_settings -> {
|
||||
startActivity(SettingsActivity.newIntent(this))
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
binding.drawer.closeDrawers()
|
||||
return true
|
||||
@@ -171,6 +182,62 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMangaClick(manga: Manga) {
|
||||
startActivity(DetailsActivity.newIntent(this, manga))
|
||||
}
|
||||
|
||||
override fun onQueryClick(query: String, submit: Boolean) {
|
||||
if (submit) {
|
||||
if (query.isNotEmpty()) {
|
||||
val source = searchSuggestionViewModel.getLocalSearchSource()
|
||||
if (source != null) {
|
||||
startActivity(SearchActivity.newIntent(this, source, query))
|
||||
} else {
|
||||
startActivity(GlobalSearchActivity.newIntent(this, query))
|
||||
}
|
||||
searchSuggestionViewModel.saveQuery(query)
|
||||
}
|
||||
} else {
|
||||
searchUi?.query = query
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQueryChanged(query: String) {
|
||||
searchSuggestionViewModel.onQueryChanged(query)
|
||||
}
|
||||
|
||||
override fun onClearSearchHistory() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.clear_search_history)
|
||||
.setMessage(R.string.text_clear_search_history_prompt)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.clear) { _, _ ->
|
||||
searchSuggestionViewModel.clearSearchHistory()
|
||||
}.show()
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
|
||||
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
|
||||
if (fragment == null) {
|
||||
supportFragmentManager.commit {
|
||||
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
|
||||
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
|
||||
if (fragment != null) {
|
||||
supportFragmentManager.commit {
|
||||
remove(fragment)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onOpenReader(manga: Manga) {
|
||||
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
ActivityOptions.makeClipRevealAnimation(
|
||||
@@ -234,8 +301,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
|
||||
private fun setPrimaryFragment(fragment: Fragment) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.replace(R.id.container, fragment, TAG_PRIMARY)
|
||||
.commit()
|
||||
binding.fab.isVisible = fragment is HistoryListFragment
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val TAG_PRIMARY = "primary"
|
||||
const val TAG_SEARCH = "search"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
package org.koitharu.kotatsu.search
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.search.ui.SearchViewModel
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchViewModel
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
|
||||
val searchModule
|
||||
get() = module {
|
||||
|
||||
single { MangaSearchRepository(get()) }
|
||||
single { MangaSearchRepository(get(), get(), androidContext(), get()) }
|
||||
|
||||
factory { MangaSuggestionsProvider.createSuggestions(androidContext()) }
|
||||
|
||||
viewModel { (source: MangaSource, query: String) ->
|
||||
SearchViewModel(get(named(source)), query, get())
|
||||
@@ -19,4 +24,5 @@ val searchModule
|
||||
viewModel { (query: String) ->
|
||||
GlobalSearchViewModel(query, get(), get())
|
||||
}
|
||||
viewModel { SearchSuggestionViewModel(get()) }
|
||||
}
|
||||
@@ -1,14 +1,29 @@
|
||||
package org.koitharu.kotatsu.search.domain
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.SearchManager
|
||||
import android.content.Context
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.utils.ext.levenshteinDistance
|
||||
|
||||
class MangaSearchRepository(private val settings: AppSettings) {
|
||||
class MangaSearchRepository(
|
||||
private val settings: AppSettings,
|
||||
private val db: MangaDatabase,
|
||||
private val context: Context,
|
||||
private val recentSuggestions: SearchRecentSuggestions,
|
||||
) {
|
||||
|
||||
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
|
||||
MangaProviderFactory.getSources(settings, includeHidden = false).asFlow()
|
||||
@@ -22,16 +37,81 @@ class MangaSearchRepository(private val settings: AppSettings) {
|
||||
match(it, query)
|
||||
}
|
||||
|
||||
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
|
||||
if (query.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
return if (source != null) {
|
||||
db.mangaDao.searchByTitle("%$query%", source.name, limit)
|
||||
} else {
|
||||
db.mangaDao.searchByTitle("%$query%", limit)
|
||||
}.map { it.toManga() }
|
||||
.sortedBy { x -> x.title.levenshteinDistance(query) }
|
||||
}
|
||||
|
||||
suspend fun getQuerySuggestion(
|
||||
query: String,
|
||||
limit: Int,
|
||||
): List<String> = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.query(
|
||||
MangaSuggestionsProvider.QUERY_URI,
|
||||
SUGGESTION_PROJECTION,
|
||||
"${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?",
|
||||
arrayOf("%$query%"),
|
||||
"date DESC"
|
||||
)?.use { cursor ->
|
||||
val count = minOf(cursor.count, limit)
|
||||
if (count == 0) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val result = ArrayList<String>(count)
|
||||
if (cursor.moveToFirst()) {
|
||||
val index = cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_QUERY)
|
||||
do {
|
||||
result += cursor.getString(index)
|
||||
} while (currentCoroutineContext().isActive && cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
fun saveSearchQuery(query: String) {
|
||||
recentSuggestions.saveRecentQuery(query, null)
|
||||
}
|
||||
|
||||
suspend fun clearSearchHistory(): Unit = withContext(Dispatchers.IO) {
|
||||
recentSuggestions.clearHistory()
|
||||
}
|
||||
|
||||
suspend fun deleteSearchQuery(query: String) = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.delete(
|
||||
MangaSuggestionsProvider.URI,
|
||||
"display1 = ?",
|
||||
arrayOf(query),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.query(
|
||||
MangaSuggestionsProvider.QUERY_URI,
|
||||
SUGGESTION_PROJECTION,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor -> cursor.count } ?: 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private val REGEX_SPACE = Regex("\\s+")
|
||||
val SUGGESTION_PROJECTION = arrayOf(SearchManager.SUGGEST_COLUMN_QUERY)
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
fun match(manga: Manga, query: String): Boolean {
|
||||
val words = HashSet<String>()
|
||||
words += manga.title.toLowerCase().split(REGEX_SPACE)
|
||||
words += manga.altTitle?.toLowerCase()?.split(REGEX_SPACE).orEmpty()
|
||||
val words2 = query.toLowerCase().split(REGEX_SPACE).toSet()
|
||||
words += manga.title.lowercase().split(REGEX_SPACE)
|
||||
words += manga.altTitle?.lowercase()?.split(REGEX_SPACE).orEmpty()
|
||||
val words2 = query.lowercase().split(REGEX_SPACE).toSet()
|
||||
for (w in words) {
|
||||
for (w2 in words2) {
|
||||
val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f)
|
||||
|
||||
@@ -4,49 +4,14 @@ import android.app.SearchManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SearchRecentSuggestionsProvider
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.cursoradapter.widget.CursorAdapter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
|
||||
|
||||
init {
|
||||
setupSuggestions(
|
||||
AUTHORITY,
|
||||
MODE
|
||||
)
|
||||
}
|
||||
|
||||
private class SearchSuggestionAdapter(context: Context, cursor: Cursor) : CursorAdapter(
|
||||
context, cursor,
|
||||
FLAG_REGISTER_CONTENT_OBSERVER
|
||||
) {
|
||||
|
||||
override fun newView(context: Context, cursor: Cursor?, parent: ViewGroup?): View {
|
||||
return LayoutInflater.from(context)
|
||||
.inflate(R.layout.item_search_complete, parent, false)
|
||||
}
|
||||
|
||||
override fun bindView(view: View, context: Context, cursor: Cursor) {
|
||||
if (view !is TextView) return
|
||||
view.text = cursor.getString(cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY))
|
||||
}
|
||||
|
||||
override fun convertToString(cursor: Cursor?): CharSequence {
|
||||
return cursor?.getString(cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY))
|
||||
.orEmpty()
|
||||
}
|
||||
setupSuggestions(AUTHORITY, MODE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -54,65 +19,16 @@ class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
|
||||
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider"
|
||||
private const val MODE = DATABASE_MODE_QUERIES
|
||||
|
||||
private val uri = Uri.Builder()
|
||||
fun createSuggestions(context: Context): SearchRecentSuggestions {
|
||||
return SearchRecentSuggestions(context, AUTHORITY, MODE)
|
||||
}
|
||||
|
||||
val QUERY_URI: Uri = Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_CONTENT)
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY)
|
||||
.build()
|
||||
|
||||
private val projection = arrayOf("_id", SearchManager.SUGGEST_COLUMN_QUERY)
|
||||
|
||||
fun saveQueryAsync(context: Context, query: String) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
saveQuery(context, query)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveQuery(context: Context, query: String) {
|
||||
runCatching {
|
||||
SearchRecentSuggestions(
|
||||
context,
|
||||
AUTHORITY,
|
||||
MODE
|
||||
).saveRecentQuery(query, null)
|
||||
}.onFailure {
|
||||
if (BuildConfig.DEBUG) {
|
||||
it.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearHistory(context: Context) = withContext(Dispatchers.IO) {
|
||||
SearchRecentSuggestions(
|
||||
context,
|
||||
AUTHORITY,
|
||||
MODE
|
||||
).clearHistory()
|
||||
}
|
||||
|
||||
suspend fun getItemsCount(context: Context) = withContext(Dispatchers.IO) {
|
||||
getCursor(context)?.use { it.count } ?: 0
|
||||
}
|
||||
|
||||
private fun getCursor(context: Context): Cursor? {
|
||||
return context.contentResolver?.query(uri, projection, null, arrayOf(""), null)
|
||||
}
|
||||
|
||||
@Deprecated("Need async implementation")
|
||||
fun getSuggestionAdapter(context: Context): CursorAdapter? = getCursor(
|
||||
context
|
||||
)?.let { cursor ->
|
||||
SearchSuggestionAdapter(context, cursor).also {
|
||||
it.setFilterQueryProvider { q ->
|
||||
context.contentResolver?.query(
|
||||
uri,
|
||||
projection,
|
||||
" ?",
|
||||
arrayOf(q?.toString().orEmpty()),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val URI: Uri = Uri.parse("content://$AUTHORITY/suggestions")
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,20 @@ import android.os.Parcelable
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.commit
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.ActivitySearchBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
import org.koitharu.kotatsu.utils.ext.showKeyboard
|
||||
|
||||
class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener {
|
||||
|
||||
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
|
||||
mode = LazyThreadSafetyMode.NONE
|
||||
)
|
||||
private lateinit var source: MangaSource
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -28,8 +34,6 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
with(binding.searchView) {
|
||||
queryHint = getString(R.string.search_on_s, source.title)
|
||||
suggestionsAdapter = MangaSuggestionsProvider.getSuggestionAdapter(this@SearchActivity)
|
||||
setOnSuggestionListener(SearchHelper.SuggestionListener(this))
|
||||
setOnQueryTextListener(this@SearchActivity)
|
||||
|
||||
if (query.isNullOrBlank()) {
|
||||
@@ -41,11 +45,6 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
binding.searchView.suggestionsAdapter.changeCursor(null) //close cursor
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.toolbar.updatePadding(
|
||||
top = insets.top,
|
||||
@@ -55,19 +54,20 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return if (!query.isNullOrBlank()) {
|
||||
title = query
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, SearchFragment.newInstance(source, query))
|
||||
.commit()
|
||||
binding.searchView.clearFocus()
|
||||
MangaSuggestionsProvider.saveQueryAsync(applicationContext, query)
|
||||
true
|
||||
} else false
|
||||
val q = query?.trim()
|
||||
if (q.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
title = query
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.container, SearchFragment.newInstance(source, q))
|
||||
}
|
||||
binding.searchView.clearFocus()
|
||||
searchSuggestionViewModel.saveQuery(q)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?) = false
|
||||
override fun onQueryTextChange(newText: String?): Boolean = false
|
||||
|
||||
companion object {
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.koitharu.kotatsu.search.ui
|
||||
|
||||
import android.app.SearchManager
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||
import java.io.Closeable
|
||||
|
||||
object SearchHelper {
|
||||
|
||||
fun setupSearchView(menuItem: MenuItem): Closeable? {
|
||||
val view = menuItem.actionView as? SearchView ?: return null
|
||||
val context = view.context
|
||||
val adapter = MangaSuggestionsProvider.getSuggestionAdapter(context)
|
||||
view.queryHint = context.getString(R.string.search_manga)
|
||||
view.suggestionsAdapter = adapter
|
||||
view.setOnQueryTextListener(QueryListener(context))
|
||||
view.setOnSuggestionListener(SuggestionListener(view))
|
||||
return adapter?.cursor
|
||||
}
|
||||
|
||||
private class QueryListener(private val context: Context) :
|
||||
SearchView.OnQueryTextListener {
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return if (!query.isNullOrBlank()) {
|
||||
context.startActivity(GlobalSearchActivity.newIntent(context, query.trim()))
|
||||
MangaSuggestionsProvider.saveQueryAsync(context.applicationContext, query)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?) = false
|
||||
}
|
||||
|
||||
class SuggestionListener(private val view: SearchView) :
|
||||
SearchView.OnSuggestionListener {
|
||||
|
||||
override fun onSuggestionSelect(position: Int) = false
|
||||
|
||||
override fun onSuggestionClick(position: Int): Boolean {
|
||||
val query = runCatching {
|
||||
val c = view.suggestionsAdapter.getItem(position) as? Cursor
|
||||
c?.getString(c.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY))
|
||||
}.getOrNull() ?: return false
|
||||
view.setQuery(query, true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
|
||||
|
||||
class SearchSuggestionFragment : BaseFragment<FragmentSearchSuggestionBinding>(),
|
||||
SearchSuggestionItemCallback.SuggestionItemListener {
|
||||
|
||||
private val viewModel by sharedViewModel<SearchSuggestionViewModel>()
|
||||
|
||||
override fun onInflateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentSearchSuggestionBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val adapter = SearchSuggestionAdapter(
|
||||
coil = get(),
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
listener = requireActivity() as SearchSuggestionListener,
|
||||
)
|
||||
binding.root.adapter = adapter
|
||||
viewModel.suggestion.observe(viewLifecycleOwner) {
|
||||
adapter.items = it
|
||||
}
|
||||
ItemTouchHelper(SearchSuggestionItemCallback(this))
|
||||
.attachToRecyclerView(binding.root)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRemoveQuery(query: String) {
|
||||
viewModel.deleteQuery(query)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = SearchSuggestionFragment()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion
|
||||
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
import org.koitharu.kotatsu.utils.ext.getItem
|
||||
|
||||
class SearchSuggestionItemCallback(
|
||||
private val listener: SuggestionItemListener,
|
||||
) : ItemTouchHelper.Callback() {
|
||||
|
||||
private val movementFlags = makeMovementFlags(
|
||||
0,
|
||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||
)
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
): Int = if (viewHolder.itemViewType == SearchSuggestionAdapter.ITEM_TYPE_QUERY) {
|
||||
movementFlags
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = false
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val item = viewHolder.getItem<SearchSuggestionItem.RecentQuery>() ?: return
|
||||
listener.onRemoveQuery(item.query)
|
||||
}
|
||||
|
||||
interface SuggestionItemListener {
|
||||
|
||||
fun onRemoveQuery(query: String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion
|
||||
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
|
||||
interface SearchSuggestionListener {
|
||||
|
||||
fun onMangaClick(manga: Manga)
|
||||
|
||||
fun onQueryClick(query: String, submit: Boolean)
|
||||
|
||||
fun onQueryChanged(query: String)
|
||||
|
||||
fun onClearSearchHistory()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
class SearchSuggestionViewModel(
|
||||
private val repository: MangaSearchRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val query = MutableStateFlow("")
|
||||
private val source = MutableStateFlow<MangaSource?>(null)
|
||||
private val isLocalSearch = MutableStateFlow(false)
|
||||
private var suggestionJob: Job? = null
|
||||
|
||||
val suggestion = MutableLiveData<List<SearchSuggestionItem>>()
|
||||
|
||||
init {
|
||||
setupSuggestion()
|
||||
}
|
||||
|
||||
fun onQueryChanged(newQuery: String) {
|
||||
query.value = newQuery
|
||||
}
|
||||
|
||||
fun onSourceChanged(newSource: MangaSource?) {
|
||||
source.value = newSource
|
||||
}
|
||||
|
||||
fun saveQuery(query: String) {
|
||||
repository.saveSearchQuery(query)
|
||||
}
|
||||
|
||||
fun getLocalSearchSource(): MangaSource? {
|
||||
return source.value?.takeIf { isLocalSearch.value }
|
||||
}
|
||||
|
||||
fun clearSearchHistory() {
|
||||
launchJob {
|
||||
repository.clearSearchHistory()
|
||||
setupSuggestion()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteQuery(query: String) {
|
||||
launchJob {
|
||||
repository.deleteSearchQuery(query)
|
||||
setupSuggestion()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSuggestion() {
|
||||
suggestionJob?.cancel()
|
||||
suggestionJob = combine(
|
||||
query
|
||||
.debounce(DEBOUNCE_TIMEOUT)
|
||||
.mapLatest { q ->
|
||||
q to repository.getQuerySuggestion(q, MAX_QUERY_ITEMS)
|
||||
},
|
||||
source,
|
||||
isLocalSearch
|
||||
) { (q, queries), src, srcOnly ->
|
||||
val result = ArrayList<SearchSuggestionItem>(MAX_SUGGESTION_ITEMS)
|
||||
if (src != null) {
|
||||
result += SearchSuggestionItem.Header(src, isLocalSearch)
|
||||
}
|
||||
if (q.length >= SEARCH_THRESHOLD) {
|
||||
repository.getMangaSuggestion(q, MAX_MANGA_ITEMS, src.takeIf { srcOnly })
|
||||
.mapTo(result) {
|
||||
SearchSuggestionItem.MangaItem(it)
|
||||
}
|
||||
}
|
||||
queries.mapTo(result) { SearchSuggestionItem.RecentQuery(it) }
|
||||
result
|
||||
}.onEach {
|
||||
suggestion.postValue(it)
|
||||
}.launchIn(viewModelScope + Dispatchers.Default)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val DEBOUNCE_TIMEOUT = 500L
|
||||
const val SEARCH_THRESHOLD = 3
|
||||
const val MAX_MANGA_ITEMS = 3
|
||||
const val MAX_QUERY_ITEMS = 120
|
||||
const val MAX_SUGGESTION_ITEMS = MAX_MANGA_ITEMS + MAX_QUERY_ITEMS + 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion
|
||||
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class SearchUI(
|
||||
private val searchView: SearchView,
|
||||
listener: SearchSuggestionListener,
|
||||
hint: String? = null,
|
||||
) {
|
||||
|
||||
init {
|
||||
val context = searchView.context
|
||||
searchView.queryHint = hint ?: context.getString(R.string.search_manga)
|
||||
searchView.setOnQueryTextListener(QueryListener(listener))
|
||||
}
|
||||
|
||||
var query: String
|
||||
get() = searchView.query.toString()
|
||||
set(value) {
|
||||
searchView.setQuery(value, false)
|
||||
}
|
||||
|
||||
private class QueryListener(
|
||||
private val listener: SearchSuggestionListener,
|
||||
) : SearchView.OnQueryTextListener {
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return if (!query.isNullOrBlank()) {
|
||||
listener.onQueryClick(query.trim(), submit = true)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
listener.onQueryChanged(newText?.trim().orEmpty())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(
|
||||
menuItem: MenuItem,
|
||||
listener: SearchSuggestionListener,
|
||||
): SearchUI? = (menuItem.actionView as? SearchView)?.let {
|
||||
SearchUI(it, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
import kotlin.jvm.internal.Intrinsics
|
||||
|
||||
class SearchSuggestionAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: SearchSuggestionListener,
|
||||
) : AsyncListDifferDelegationAdapter<SearchSuggestionItem>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(ITEM_TYPE_MANGA, searchSuggestionMangaAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener))
|
||||
.addDelegate(ITEM_TYPE_HEADER, searchSuggestionHeaderAD(listener))
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<SearchSuggestionItem>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: SearchSuggestionItem,
|
||||
newItem: SearchSuggestionItem,
|
||||
): Boolean = when {
|
||||
oldItem is SearchSuggestionItem.MangaItem && newItem is SearchSuggestionItem.MangaItem -> {
|
||||
oldItem.manga.id == newItem.manga.id
|
||||
}
|
||||
oldItem is SearchSuggestionItem.RecentQuery && newItem is SearchSuggestionItem.RecentQuery -> {
|
||||
oldItem.query == newItem.query
|
||||
}
|
||||
oldItem is SearchSuggestionItem.Header && newItem is SearchSuggestionItem.Header -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: SearchSuggestionItem,
|
||||
newItem: SearchSuggestionItem,
|
||||
): Boolean = Intrinsics.areEqual(oldItem, newItem)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ITEM_TYPE_MANGA = 0
|
||||
const val ITEM_TYPE_QUERY = 1
|
||||
const val ITEM_TYPE_HEADER = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionHeaderBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
fun searchSuggestionHeaderAD(
|
||||
listener: SearchSuggestionListener,
|
||||
) = adapterDelegateViewBinding<SearchSuggestionItem.Header, SearchSuggestionItem, ItemSearchSuggestionHeaderBinding>(
|
||||
{ inflater, parent -> ItemSearchSuggestionHeaderBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.switchLocal.setOnCheckedChangeListener { _, isChecked ->
|
||||
item.isChecked.value = isChecked
|
||||
}
|
||||
binding.buttonClear.setOnClickListener {
|
||||
listener.onClearSearchHistory()
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.switchLocal.text = getString(
|
||||
R.string.search_only_on_s,
|
||||
item.source.title,
|
||||
)
|
||||
binding.switchLocal.isChecked = item.isChecked.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionMangaBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.utils.ext.textAndVisible
|
||||
|
||||
fun searchSuggestionMangaAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: SearchSuggestionListener,
|
||||
) = adapterDelegateViewBinding<SearchSuggestionItem.MangaItem, SearchSuggestionItem, ItemSearchSuggestionMangaBinding>(
|
||||
{ inflater, parent -> ItemSearchSuggestionMangaBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
var imageRequest: Disposable? = null
|
||||
|
||||
itemView.setOnClickListener {
|
||||
listener.onMangaClick(item.manga)
|
||||
}
|
||||
|
||||
bind {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = binding.imageViewCover.newImageRequest(item.manga.coverUrl)
|
||||
.placeholder(R.drawable.ic_placeholder)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.error(R.drawable.ic_placeholder)
|
||||
.allowRgb565(true)
|
||||
.lifecycle(lifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
binding.textViewTitle.text = item.manga.title
|
||||
binding.textViewSubtitle.textAndVisible = item.manga.altTitle
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
imageRequest?.dispose()
|
||||
binding.imageViewCover.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import android.view.View
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
fun searchSuggestionQueryAD(
|
||||
listener: SearchSuggestionListener,
|
||||
) = adapterDelegateViewBinding<SearchSuggestionItem.RecentQuery, SearchSuggestionItem, ItemSearchSuggestionQueryBinding>(
|
||||
{ inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
val viewClickListener = View.OnClickListener { v ->
|
||||
listener.onQueryClick(item.query, v.id != R.id.button_complete)
|
||||
}
|
||||
|
||||
binding.root.setOnClickListener(viewClickListener)
|
||||
binding.buttonComplete.setOnClickListener(viewClickListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.query
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.model
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
|
||||
sealed class SearchSuggestionItem {
|
||||
|
||||
data class MangaItem(
|
||||
val manga: Manga,
|
||||
) : SearchSuggestionItem()
|
||||
|
||||
data class RecentQuery(
|
||||
val query: String,
|
||||
) : SearchSuggestionItem()
|
||||
|
||||
data class Header(
|
||||
val source: MangaSource,
|
||||
val isChecked: MutableStateFlow<Boolean>,
|
||||
) : SearchSuggestionItem()
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.network.AndroidCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.Cache
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) {
|
||||
|
||||
private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE)
|
||||
private val searchRepository by inject<MangaSearchRepository>(mode = LazyThreadSafetyMode.NONE)
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_history)
|
||||
@@ -49,7 +50,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
|
||||
viewLifecycleScope.launchWhenResumed {
|
||||
val items = MangaSuggestionsProvider.getItemsCount(pref.context)
|
||||
val items = searchRepository.getSearchHistoryCount()
|
||||
pref.summary =
|
||||
pref.context.resources.getQuantityString(R.plurals.items, items, items)
|
||||
}
|
||||
@@ -87,7 +88,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
}
|
||||
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
|
||||
viewLifecycleScope.launch {
|
||||
MangaSuggestionsProvider.clearHistory(preference.context)
|
||||
searchRepository.clearSearchHistory()
|
||||
preference.summary = preference.context.resources
|
||||
.getQuantityString(R.plurals.items, 0, 0)
|
||||
Snackbar.make(
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||
|
||||
fun View.hideKeyboard() {
|
||||
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
@@ -153,4 +154,8 @@ fun RecyclerView.findCenterViewPosition(): Int {
|
||||
val centerY = height / 2f
|
||||
val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
|
||||
return getChildAdapterPosition(view)
|
||||
}
|
||||
|
||||
inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
|
||||
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
|
||||
}
|
||||
11
app/src/main/res/drawable/ic_clear_all.xml
Normal file
11
app/src/main/res/drawable/ic_clear_all.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M5,13h14v-2L5,11v2zM3,17h14v-2L3,15v2zM7,7v2h14L21,7L7,7z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_complete.xml
Normal file
11
app/src/main/res/drawable/ic_complete.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,17.59L17.59,19L7,8.41V15H5V5H15V7H8.41L19,17.59Z" />
|
||||
</vector>
|
||||
13
app/src/main/res/layout/fragment_search_suggestion.xml
Normal file
13
app/src/main/res/layout/fragment_search_suggestion.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:windowBackground"
|
||||
android:clickable="true"
|
||||
android:orientation="vertical"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:ignore="KeyboardInaccessibleWidget" />
|
||||
28
app/src/main/res/layout/item_search_suggestion_header.xml
Normal file
28
app/src/main/res/layout/item_search_suggestion_header.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?listPreferredItemHeightSmall"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd">
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_local"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="?listPreferredItemPaddingEnd"
|
||||
android:layout_weight="1"
|
||||
tools:text="@string/search_only_on_s" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_clear"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_clear_all" />
|
||||
|
||||
</LinearLayout>
|
||||
46
app/src/main/res/layout/item_search_suggestion_manga.xml
Normal file
46
app/src/main/res/layout/item_search_suggestion_manga.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
android:paddingBottom="2dp">
|
||||
|
||||
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
|
||||
android:id="@+id/imageView_cover"
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
|
||||
tools:text="@tools:sample/lorem[6]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
tools:text="@tools:sample/lorem[6]" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
35
app/src/main/res/layout/item_search_suggestion_query.xml
Normal file
35
app/src/main/res/layout/item_search_suggestion_query.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?listPreferredItemHeightSmall"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:drawablePadding="12dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
|
||||
app:drawableStartCompat="@drawable/ic_history"
|
||||
tools:text="@tools:sample/lorem[6]" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_complete"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_complete" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -206,4 +206,5 @@
|
||||
<string name="confirm">Confirm</string>
|
||||
<string name="password_length_hint">Пароль должен содержать не менее 4 символов</string>
|
||||
<string name="hide_toolbar">Прятать заголовок при прокрутке</string>
|
||||
<string name="search_only_on_s">Поиск только по %s</string>
|
||||
</resources>
|
||||
@@ -208,4 +208,6 @@
|
||||
<string name="confirm">Confirm</string>
|
||||
<string name="password_length_hint">Password must be at least 4 characters</string>
|
||||
<string name="hide_toolbar">Hide toolbar when scrolling</string>
|
||||
<string name="search_only_on_s">Search only on %s</string>
|
||||
<string name="text_clear_search_history_prompt">Do you really want to remove all recent search queries? This action cannot be undone.</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user