From cd7d6d7674cb2ed1a8408791b4ca49d550791693 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 31 May 2021 20:50:15 +0300 Subject: [PATCH] New search suggestion UI --- .idea/misc.xml | 10 ++ .../koitharu/kotatsu/core/db/dao/MangaDao.kt | 8 + .../koitharu/kotatsu/main/ui/MainActivity.kt | 145 +++++++++++++----- .../koitharu/kotatsu/search/SearchModule.kt | 8 +- .../search/domain/MangaSearchRepository.kt | 88 ++++++++++- .../search/ui/MangaSuggestionsProvider.kt | 98 +----------- .../kotatsu/search/ui/SearchActivity.kt | 36 ++--- .../kotatsu/search/ui/SearchHelper.kt | 53 ------- .../ui/suggestion/SearchSuggestionFragment.kt | 57 +++++++ .../SearchSuggestionItemCallback.kt | 42 +++++ .../ui/suggestion/SearchSuggestionListener.kt | 14 ++ .../suggestion/SearchSuggestionViewModel.kt | 95 ++++++++++++ .../kotatsu/search/ui/suggestion/SearchUI.kt | 51 ++++++ .../adapter/SearchSuggestionAdapter.kt | 51 ++++++ .../adapter/SearchSuggestionHeaderAD.kt | 29 ++++ .../adapter/SearchSuggestionMangaAD.kt | 46 ++++++ .../adapter/SearchSuggestionQueryAD.kt | 26 ++++ .../suggestion/model/SearchSuggestionItem.kt | 21 +++ .../settings/HistorySettingsFragment.kt | 7 +- .../org/koitharu/kotatsu/utils/ext/ViewExt.kt | 5 + app/src/main/res/drawable/ic_clear_all.xml | 11 ++ app/src/main/res/drawable/ic_complete.xml | 11 ++ .../res/layout/fragment_search_suggestion.xml | 13 ++ .../layout/item_search_suggestion_header.xml | 28 ++++ .../layout/item_search_suggestion_manga.xml | 46 ++++++ .../layout/item_search_suggestion_query.xml | 35 +++++ app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + 28 files changed, 831 insertions(+), 206 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/SearchHelper.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchUI.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionHeaderAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionMangaAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt create mode 100644 app/src/main/res/drawable/ic_clear_all.xml create mode 100644 app/src/main/res/drawable/ic_complete.xml create mode 100644 app/src/main/res/layout/fragment_search_suggestion.xml create mode 100644 app/src/main/res/layout/item_search_suggestion_header.xml create mode 100644 app/src/main/res/layout/item_search_suggestion_manga.xml create mode 100644 app/src/main/res/layout/item_search_suggestion_query.xml diff --git a/.idea/misc.xml b/.idea/misc.xml index 7f598c7c9..4fd703491 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -9,6 +9,11 @@ + + + + + @@ -23,6 +28,7 @@ + @@ -31,7 +37,11 @@ + + + + diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt index 51b7571db..ee8255dfc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt @@ -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 + + @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 + @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(manga: MangaEntity): Long diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 443ab118f..cd95e7e8a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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(), NavigationView.OnNavigationItemSelectedListener, - View.OnClickListener { + View.OnClickListener, SearchSuggestionListener, MenuItem.OnActionExpandListener { private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val searchSuggestionViewModel by viewModel( + 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(), 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(), 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(), 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(), 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(), } } + 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(), 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" + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt index a6a7db5c5..da6239420 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt @@ -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()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index 1efec6723..63e774884 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -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 = 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 { + 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 = 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(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() - 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt index 2889a24cf..c708db286 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt @@ -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") } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt index 05092438f..7221f6d11 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt @@ -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(), SearchView.OnQueryTextListener { + private val searchSuggestionViewModel by viewModel( + mode = LazyThreadSafetyMode.NONE + ) private lateinit var source: MangaSource override fun onCreate(savedInstanceState: Bundle?) { @@ -28,8 +34,6 @@ class SearchActivity : BaseActivity(), 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(), 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(), 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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchHelper.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchHelper.kt deleted file mode 100644 index 6f937b2eb..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchHelper.kt +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt new file mode 100644 index 000000000..e053df0fc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -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(), + SearchSuggestionItemCallback.SuggestionItemListener { + + private val viewModel by sharedViewModel() + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt new file mode 100644 index 000000000..e983be92b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt @@ -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() ?: return + listener.onRemoveQuery(item.query) + } + + interface SuggestionItemListener { + + fun onRemoveQuery(query: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt new file mode 100644 index 000000000..d82a47bb0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt new file mode 100644 index 000000000..d05382e72 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -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(null) + private val isLocalSearch = MutableStateFlow(false) + private var suggestionJob: Job? = null + + val suggestion = MutableLiveData>() + + 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(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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchUI.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchUI.kt new file mode 100644 index 000000000..fb9f79847 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchUI.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt new file mode 100644 index 000000000..06e6af858 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt @@ -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(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() { + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionHeaderAD.kt new file mode 100644 index 000000000..be60708cd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionHeaderAD.kt @@ -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( + { 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 + } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionMangaAD.kt new file mode 100644 index 000000000..2eed6b932 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionMangaAD.kt @@ -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( + { 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt new file mode 100644 index 000000000..70854cb2f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt @@ -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( + { 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt new file mode 100644 index 000000000..d2d9d04d7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt @@ -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, + ) : SearchSuggestionItem() +} diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index dfff7115f..a2710540e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -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(mode = LazyThreadSafetyMode.NONE) + private val searchRepository by inject(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(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( diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index 9a0fe450b..c73cff93f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -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 RecyclerView.ViewHolder.getItem(): T? { + return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T) } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_clear_all.xml b/app/src/main/res/drawable/ic_clear_all.xml new file mode 100644 index 000000000..99c92f173 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_all.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_complete.xml b/app/src/main/res/drawable/ic_complete.xml new file mode 100644 index 000000000..3735578eb --- /dev/null +++ b/app/src/main/res/drawable/ic_complete.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search_suggestion.xml b/app/src/main/res/layout/fragment_search_suggestion.xml new file mode 100644 index 000000000..a2ff52eda --- /dev/null +++ b/app/src/main/res/layout/fragment_search_suggestion.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_suggestion_header.xml b/app/src/main/res/layout/item_search_suggestion_header.xml new file mode 100644 index 000000000..62b29b12d --- /dev/null +++ b/app/src/main/res/layout/item_search_suggestion_header.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_suggestion_manga.xml b/app/src/main/res/layout/item_search_suggestion_manga.xml new file mode 100644 index 000000000..a92340c02 --- /dev/null +++ b/app/src/main/res/layout/item_search_suggestion_manga.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_suggestion_query.xml b/app/src/main/res/layout/item_search_suggestion_query.xml new file mode 100644 index 000000000..ace877682 --- /dev/null +++ b/app/src/main/res/layout/item_search_suggestion_query.xml @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3765820e9..b20c30211 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -206,4 +206,5 @@ Confirm Пароль должен содержать не менее 4 символов Прятать заголовок при прокрутке + Поиск только по %s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f5e7da6f..64fb9d0bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,4 +208,6 @@ Confirm Password must be at least 4 characters Hide toolbar when scrolling + Search only on %s + Do you really want to remove all recent search queries? This action cannot be undone. \ No newline at end of file