New search suggestion UI

This commit is contained in:
Koitharu
2021-05-31 20:50:15 +03:00
parent bc0c5ac71a
commit cd7d6d7674
28 changed files with 831 additions and 206 deletions

10
.idea/misc.xml generated
View File

@@ -9,6 +9,11 @@
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_alert_dialog_material.xml" value="0.25885416666666666" /> <entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_alert_dialog_material.xml" value="0.25885416666666666" />
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_select_dialog_material.xml" value="0.25885416666666666" /> <entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_select_dialog_material.xml" value="0.25885416666666666" />
<entry key="../../../../.gradle/caches/transforms-3/688e95ad986d2d0286c79f787589b7cb/transformed/material-1.3.0/res/layout/mtrl_alert_dialog.xml" value="0.25885416666666666" /> <entry key="../../../../.gradle/caches/transforms-3/688e95ad986d2d0286c79f787589b7cb/transformed/material-1.3.0/res/layout/mtrl_alert_dialog.xml" value="0.25885416666666666" />
<entry key="../../../../.gradle/caches/transforms-3/7bbda65156c2f797f689a169a6aaa2eb/transformed/appcompat-1.2.0/res/drawable/abc_switch_thumb_material.xml" value="0.2609375" />
<entry key="app/src/main/res/drawable/ic_alert_outline.xml" value="0.2609375" />
<entry key="app/src/main/res/drawable/ic_clear_all.xml" value="0.275" />
<entry key="app/src/main/res/drawable/ic_complete.xml" value="0.275" />
<entry key="app/src/main/res/drawable/ic_history.xml" value="0.275" />
<entry key="app/src/main/res/drawable/tab_indicator.xml" value="0.28512820512820514" /> <entry key="app/src/main/res/drawable/tab_indicator.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/drawable/tabs_background.xml" value="0.28512820512820514" /> <entry key="app/src/main/res/drawable/tabs_background.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/layout-w600dp/activity_details.xml" value="0.18072916666666666" /> <entry key="app/src/main/res/layout-w600dp/activity_details.xml" value="0.18072916666666666" />
@@ -23,6 +28,7 @@
<entry key="app/src/main/res/layout/fragment_favourites.xml" value="0.26296296296296295" /> <entry key="app/src/main/res/layout/fragment_favourites.xml" value="0.26296296296296295" />
<entry key="app/src/main/res/layout/fragment_feed.xml" value="0.2601851851851852" /> <entry key="app/src/main/res/layout/fragment_feed.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/fragment_list.xml" value="0.2601851851851852" /> <entry key="app/src/main/res/layout/fragment_list.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/fragment_search_suggestion.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/layout/item_branch.xml" value="0.24739583333333334" /> <entry key="app/src/main/res/layout/item_branch.xml" value="0.24739583333333334" />
<entry key="app/src/main/res/layout/item_branch_dropdown.xml" value="0.25743589743589745" /> <entry key="app/src/main/res/layout/item_branch_dropdown.xml" value="0.25743589743589745" />
<entry key="app/src/main/res/layout/item_category_checkable.xml" value="0.2601851851851852" /> <entry key="app/src/main/res/layout/item_category_checkable.xml" value="0.2601851851851852" />
@@ -31,7 +37,11 @@
<entry key="app/src/main/res/layout/item_page_thumb.xml" value="0.2601851851851852" /> <entry key="app/src/main/res/layout/item_page_thumb.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_page_webtoon.xml" value="0.13095238095238096" /> <entry key="app/src/main/res/layout/item_page_webtoon.xml" value="0.13095238095238096" />
<entry key="app/src/main/res/layout/item_recent.xml" value="0.2601851851851852" /> <entry key="app/src/main/res/layout/item_recent.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_search_suggestion_header.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/layout/item_search_suggestion_manga.xml" value="0.24479166666666666" />
<entry key="app/src/main/res/layout/item_search_suggestion_query.xml" value="0.587248322147651" />
<entry key="app/src/main/res/layout/item_source_config.xml" value="0.25885416666666666" /> <entry key="app/src/main/res/layout/item_source_config.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/layout/item_tracklog.xml" value="0.24479166666666666" />
<entry key="app/src/main/res/layout/sheet_pages.xml" value="0.2601851851851852" /> <entry key="app/src/main/res/layout/sheet_pages.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/menu/opt_protect.xml" value="0.26927083333333335" /> <entry key="app/src/main/res/menu/opt_protect.xml" value="0.26927083333333335" />
<entry key="app/src/main/res/menu/popup_category.xml" value="0.2601851851851852" /> <entry key="app/src/main/res/menu/popup_category.xml" value="0.2601851851851852" />

View File

@@ -13,6 +13,14 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE manga_id = :id") @Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags? 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) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(manga: MangaEntity): Long abstract suspend fun insert(manga: MangaEntity): Long

View File

@@ -11,9 +11,12 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.*
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar 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.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment 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.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import java.io.Closeable
class MainActivity : BaseActivity<ActivityMainBinding>(), class MainActivity : BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener, NavigationView.OnNavigationItemSelectedListener,
View.OnClickListener { View.OnClickListener, SearchSuggestionListener, MenuItem.OnActionExpandListener {
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE) private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var drawerToggle: ActionBarDrawerToggle private lateinit var drawerToggle: ActionBarDrawerToggle
private var closeable: Closeable? = null private var searchUi: SearchUI? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityMainBinding.inflate(layoutInflater)) setContentView(ActivityMainBinding.inflate(layoutInflater))
drawerToggle = drawerToggle = ActionBarDrawerToggle(
ActionBarDrawerToggle(
this, this,
binding.drawer, binding.drawer,
binding.toolbar, binding.toolbar,
@@ -68,7 +78,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
setOnClickListener(this@MainActivity) setOnClickListener(this@MainActivity)
} }
supportFragmentManager.findFragmentById(R.id.container)?.let { supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
binding.fab.isVisible = it is HistoryListFragment binding.fab.isVisible = it is HistoryListFragment
} ?: run { } ?: run {
openDefaultSection() openDefaultSection()
@@ -84,11 +94,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.remoteSources.observe(this, this::updateSideMenu)
} }
override fun onDestroy() {
closeable?.close()
super.onDestroy()
}
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState) super.onPostCreate(savedInstanceState)
drawerToggle.syncState() drawerToggle.syncState()
@@ -109,8 +114,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu) menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem -> searchUi = menu.findItem(R.id.action_search)?.let { menuItem ->
closeable = SearchHelper.setupSearchView(menuItem) onMenuItemActionCollapse(menuItem)
menuItem.setOnActionExpandListener(this)
SearchUI.from(menuItem, this)
} }
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -131,28 +138,32 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (item.groupId == R.id.group_remote_sources) { if (item.groupId == R.id.group_remote_sources) {
val source = MangaSource.values().getOrNull(item.itemId) ?: return false val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source)) setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) { searchSuggestionViewModel.onSourceChanged(source)
R.id.nav_history -> { } else {
viewModel.defaultSection = AppSection.HISTORY searchSuggestionViewModel.onSourceChanged(null)
setPrimaryFragment(HistoryListFragment.newInstance()) 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() binding.drawer.closeDrawers()
return true 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) { private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation( ActivityOptions.makeClipRevealAnimation(
@@ -234,8 +301,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment) .replace(R.id.container, fragment, TAG_PRIMARY)
.commit() .commit()
binding.fab.isVisible = fragment is HistoryListFragment binding.fab.isVisible = fragment is HistoryListFragment
} }
private companion object {
const val TAG_PRIMARY = "primary"
const val TAG_SEARCH = "search"
}
} }

View File

@@ -1,17 +1,22 @@
package org.koitharu.kotatsu.search package org.koitharu.kotatsu.search
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.search.domain.MangaSearchRepository 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.SearchViewModel
import org.koitharu.kotatsu.search.ui.global.GlobalSearchViewModel import org.koitharu.kotatsu.search.ui.global.GlobalSearchViewModel
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
val searchModule val searchModule
get() = module { get() = module {
single { MangaSearchRepository(get()) } single { MangaSearchRepository(get(), get(), androidContext(), get()) }
factory { MangaSuggestionsProvider.createSuggestions(androidContext()) }
viewModel { (source: MangaSource, query: String) -> viewModel { (source: MangaSource, query: String) ->
SearchViewModel(get(named(source)), query, get()) SearchViewModel(get(named(source)), query, get())
@@ -19,4 +24,5 @@ val searchModule
viewModel { (query: String) -> viewModel { (query: String) ->
GlobalSearchViewModel(query, get(), get()) GlobalSearchViewModel(query, get(), get())
} }
viewModel { SearchSuggestionViewModel(get()) }
} }

View File

@@ -1,14 +1,29 @@
package org.koitharu.kotatsu.search.domain package org.koitharu.kotatsu.search.domain
import android.annotation.SuppressLint 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.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.domain.MangaProviderFactory 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.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.utils.ext.levenshteinDistance 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> = fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
MangaProviderFactory.getSources(settings, includeHidden = false).asFlow() MangaProviderFactory.getSources(settings, includeHidden = false).asFlow()
@@ -22,16 +37,81 @@ class MangaSearchRepository(private val settings: AppSettings) {
match(it, query) 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 companion object {
private val REGEX_SPACE = Regex("\\s+") private val REGEX_SPACE = Regex("\\s+")
val SUGGESTION_PROJECTION = arrayOf(SearchManager.SUGGEST_COLUMN_QUERY)
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
fun match(manga: Manga, query: String): Boolean { fun match(manga: Manga, query: String): Boolean {
val words = HashSet<String>() val words = HashSet<String>()
words += manga.title.toLowerCase().split(REGEX_SPACE) words += manga.title.lowercase().split(REGEX_SPACE)
words += manga.altTitle?.toLowerCase()?.split(REGEX_SPACE).orEmpty() words += manga.altTitle?.lowercase()?.split(REGEX_SPACE).orEmpty()
val words2 = query.toLowerCase().split(REGEX_SPACE).toSet() val words2 = query.lowercase().split(REGEX_SPACE).toSet()
for (w in words) { for (w in words) {
for (w2 in words2) { for (w2 in words2) {
val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f) val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f)

View File

@@ -4,49 +4,14 @@ import android.app.SearchManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.SearchRecentSuggestionsProvider import android.content.SearchRecentSuggestionsProvider
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.provider.SearchRecentSuggestions 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.BuildConfig
import org.koitharu.kotatsu.R
class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() { class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
init { init {
setupSuggestions( setupSuggestions(AUTHORITY, MODE)
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()
}
} }
companion object { companion object {
@@ -54,65 +19,16 @@ class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider" private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider"
private const val MODE = DATABASE_MODE_QUERIES 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) .scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY) .authority(AUTHORITY)
.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY) .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY)
.build() .build()
private val projection = arrayOf("_id", SearchManager.SUGGEST_COLUMN_QUERY) val URI: Uri = Uri.parse("content://$AUTHORITY/suggestions")
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
)
}
}
}
} }
} }

View File

@@ -7,14 +7,20 @@ import android.os.Parcelable
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding 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.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.ActivitySearchBinding import org.koitharu.kotatsu.databinding.ActivitySearchBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.utils.ext.showKeyboard import org.koitharu.kotatsu.utils.ext.showKeyboard
class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener { class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener {
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var source: MangaSource private lateinit var source: MangaSource
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -28,8 +34,6 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
with(binding.searchView) { with(binding.searchView) {
queryHint = getString(R.string.search_on_s, source.title) queryHint = getString(R.string.search_on_s, source.title)
suggestionsAdapter = MangaSuggestionsProvider.getSuggestionAdapter(this@SearchActivity)
setOnSuggestionListener(SearchHelper.SuggestionListener(this))
setOnQueryTextListener(this@SearchActivity) setOnQueryTextListener(this@SearchActivity)
if (query.isNullOrBlank()) { 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) { override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding( binding.toolbar.updatePadding(
top = insets.top, top = insets.top,
@@ -55,19 +54,20 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
} }
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
return if (!query.isNullOrBlank()) { val q = query?.trim()
title = query if (q.isNullOrEmpty()) {
supportFragmentManager return false
.beginTransaction() }
.replace(R.id.container, SearchFragment.newInstance(source, query)) title = query
.commit() supportFragmentManager.commit {
binding.searchView.clearFocus() replace(R.id.container, SearchFragment.newInstance(source, q))
MangaSuggestionsProvider.saveQueryAsync(applicationContext, query) }
true binding.searchView.clearFocus()
} else false searchSuggestionViewModel.saveQuery(q)
return true
} }
override fun onQueryTextChange(newText: String?) = false override fun onQueryTextChange(newText: String?): Boolean = false
companion object { companion object {

View File

@@ -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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.Cache 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.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.FileSizeUtils 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) { class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) {
private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE) 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?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history) 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 -> findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewLifecycleScope.launchWhenResumed { viewLifecycleScope.launchWhenResumed {
val items = MangaSuggestionsProvider.getItemsCount(pref.context) val items = searchRepository.getSearchHistoryCount()
pref.summary = pref.summary =
pref.context.resources.getQuantityString(R.plurals.items, items, items) 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 -> { AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
viewLifecycleScope.launch { viewLifecycleScope.launch {
MangaSuggestionsProvider.clearHistory(preference.context) searchRepository.clearSearchHistory()
preference.summary = preference.context.resources preference.summary = preference.context.resources
.getQuantityString(R.plurals.items, 0, 0) .getQuantityString(R.plurals.items, 0, 0)
Snackbar.make( Snackbar.make(

View File

@@ -16,6 +16,7 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
fun View.hideKeyboard() { fun View.hideKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
@@ -153,4 +154,8 @@ fun RecyclerView.findCenterViewPosition(): Int {
val centerY = height / 2f val centerY = height / 2f
val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
return getChildAdapterPosition(view) return getChildAdapterPosition(view)
}
inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
} }

View 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>

View 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>

View 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" />

View 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>

View 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>

View 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>

View File

@@ -206,4 +206,5 @@
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="password_length_hint">Пароль должен содержать не менее 4 символов</string> <string name="password_length_hint">Пароль должен содержать не менее 4 символов</string>
<string name="hide_toolbar">Прятать заголовок при прокрутке</string> <string name="hide_toolbar">Прятать заголовок при прокрутке</string>
<string name="search_only_on_s">Поиск только по %s</string>
</resources> </resources>

View File

@@ -208,4 +208,6 @@
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="password_length_hint">Password must be at least 4 characters</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="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> </resources>