Migrate to mdc search view

This commit is contained in:
Koitharu
2025-05-22 09:05:25 +03:00
parent a52730fff0
commit 1b5720f2a5
14 changed files with 291 additions and 580 deletions

View File

@@ -5,18 +5,13 @@ import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.MenuProvider
import androidx.core.view.SoftwareKeyboardControllerCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children
import androidx.core.view.inputmethod.EditorInfoCompat
@@ -25,32 +20,37 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withResumed
import androidx.transition.TransitionManager
import androidx.recyclerview.widget.ItemTouchHelper
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
import com.google.android.material.search.SearchView
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.parser.MangaLinkResolver
import org.koitharu.kotatsu.core.os.VoiceInputContract
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.OptionsMenuBadgeHelper
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.end
@@ -66,35 +66,35 @@ import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.MangaSearchMenuProvider
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionItemCallback
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListenerImpl
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionMenuProvider
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
import javax.inject.Inject
import androidx.appcompat.R as appcompatR
private const val TAG_SEARCH = "search"
import com.google.android.material.R as materialR
@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner,
View.OnClickListener,
View.OnFocusChangeListener,
SearchSuggestionListener,
SearchSuggestionItemCallback.SuggestionItemListener,
MainNavigationDelegate.OnFragmentChangedListener,
View.OnLayoutChangeListener {
View.OnLayoutChangeListener,
SearchView.TransitionListener {
@Inject
lateinit var settings: AppSettings
private val viewModel by viewModels<MainViewModel>()
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
private val closeSearchCallback = CloseSearchCallback()
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract()) { result ->
if (result != null) {
viewBinding.searchView.setText(result)
}
}
private lateinit var navigationDelegate: MainNavigationDelegate
private lateinit var appUpdateBadge: OptionsMenuBadgeHelper
private lateinit var fadingAppbarMediator: FadingAppbarMediator
override val appBar: AppBarLayout
@@ -107,14 +107,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
super.onCreate(savedInstanceState)
setContentView(ActivityMainBinding.inflate(layoutInflater))
with(viewBinding.searchView) {
onFocusChangeListener = this@MainActivity
searchSuggestionListener = this@MainActivity
}
viewBinding.fab?.setOnClickListener(this)
viewBinding.navRail?.headerView?.setOnClickListener(this)
fadingAppbarMediator = FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbarCard)
fadingAppbarMediator =
FadingAppbarMediator(viewBinding.appbar, viewBinding.layoutSearch ?: viewBinding.searchBar)
navigationDelegate = MainNavigationDelegate(
navBar = checkNotNull(bottomNav ?: viewBinding.navRail),
@@ -124,11 +120,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(this, savedInstanceState)
appUpdateBadge = OptionsMenuBadgeHelper(viewBinding.toolbar, R.id.action_app_update)
viewBinding.searchBar.addMenuProvider(MainMenuProvider(router, viewModel))
onBackPressedDispatcher.addCallback(ExitCallback(this, viewBinding.container))
onBackPressedDispatcher.addCallback(navigationDelegate)
onBackPressedDispatcher.addCallback(closeSearchCallback)
if (savedInstanceState == null) {
onFirstStart()
@@ -139,16 +134,18 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.feedCounter.observe(this, ::onFeedCounterChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this))
viewModel.appUpdate.observe(this, MenuInvalidator(viewBinding.searchBar))
viewModel.onFirstStart.observeEvent(this) { router.showWelcomeSheet() }
viewModel.isBottomNavPinned.observe(this, ::setNavbarPinned)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
viewBinding.bottomNav?.addOnLayoutChangeListener(this)
viewBinding.searchView.addTransitionListener(this)
initSearchSuggestions()
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
adjustSearchUI(isSearchOpened(), animate = false)
adjustSearchUI(viewBinding.searchView.isShowing)
navigationDelegate.syncSelectedItem()
}
@@ -157,7 +154,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
adjustAppbar(topFragment = fragment)
if (fromUser) {
actionModeDelegate.finishActionMode()
closeSearchCallback.handleOnBackPressed()
viewBinding.appbar.setExpanded(true)
}
}
@@ -168,51 +164,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_main, menu)
return true
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
if (menu == null) {
return false
}
menu.findItem(R.id.action_incognito)?.isChecked =
searchSuggestionViewModel.isIncognitoModeEnabled.value
val hasAppUpdate = viewModel.appUpdate.value != null
menu.findItem(R.id.action_app_update)?.isVisible = hasAppUpdate
appUpdateBadge.setBadgeVisible(hasAppUpdate)
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> if (isSearchOpened()) {
closeSearchCallback.handleOnBackPressed()
true
} else {
viewBinding.searchView.requestFocus()
true
}
R.id.action_settings -> {
router.openSettings()
true
}
R.id.action_incognito -> {
viewModel.setIncognitoMode(!item.isChecked)
true
}
R.id.action_app_update -> {
router.openAppUpdate()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onClick(v: View) {
when (v.id) {
R.id.fab, R.id.railFab -> viewModel.openLastReader()
@@ -222,12 +173,13 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeMask)
viewBinding.toolbarCard.updateLayoutParams<MarginLayoutParams> {
marginEnd = barsInsets.end(v)
val searchBarDefaultMargin = resources.getDimensionPixelOffset(materialR.dimen.m3_searchbar_margin_horizontal)
viewBinding.searchBar.updateLayoutParams<MarginLayoutParams> {
marginEnd = searchBarDefaultMargin + barsInsets.end(v)
marginStart = if (viewBinding.navRail != null) {
0
searchBarDefaultMargin
} else {
barsInsets.start(v)
searchBarDefaultMargin + barsInsets.start(v)
}
}
viewBinding.bottomNav?.updatePadding(
@@ -241,7 +193,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
bottomMargin = barsInsets.bottom
}
updateContainerBottomMargin()
return insets.consume(v, typeMask, start = viewBinding.navRail != null)
return insets.consume(v, typeMask, start = viewBinding.navRail != null).also {
handleSearchSuggestionsInsets(it)
}
}
override fun onLayoutChange(
@@ -260,65 +214,27 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
if (v?.id == R.id.searchView && hasFocus) {
if (fragment == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.container, SearchSuggestionFragment(), TAG_SEARCH)
navigationDelegate.primaryFragment?.let {
setMaxLifecycle(it, Lifecycle.State.STARTED)
}
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchOpened() }
}
}
override fun onStateChanged(
searchView: SearchView,
previousState: SearchView.TransitionState,
newState: SearchView.TransitionState,
) {
val wasOpened = previousState >= SearchView.TransitionState.SHOWING
val isOpened = newState >= SearchView.TransitionState.SHOWING
if (isOpened != wasOpened) {
adjustSearchUI(isOpened)
}
}
override fun onMangaClick(manga: Manga) {
router.openDetails(manga)
}
override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) {
viewBinding.searchView.query = query
if (submit && query.isNotEmpty()) {
if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) {
router.openDetails(query.toUri())
} else {
router.openSearch(query, kind)
if (kind != SearchKind.TAG) {
searchSuggestionViewModel.saveQuery(query)
}
}
viewBinding.searchView.post {
closeSearchCallback.handleOnBackPressed()
}
}
}
override fun onTagClick(tag: MangaTag) {
router.openSearch(tag.title, SearchKind.TAG)
}
override fun onQueryChanged(query: String) {
searchSuggestionViewModel.onQueryChanged(query)
}
override fun onSourceToggle(source: MangaSource, isEnabled: Boolean) {
searchSuggestionViewModel.onSourceToggle(source, isEnabled)
}
override fun onSourceClick(source: MangaSource) {
router.openList(source, null, null)
override fun onRemoveQuery(query: String) {
searchSuggestionViewModel.deleteQuery(query)
}
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
adjustFabVisibility()
bottomNav?.hide()
viewBinding.toolbarCard.isInvisible = true
(viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = true
updateContainerBottomMargin()
}
@@ -326,7 +242,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
super.onSupportActionModeFinished(mode)
adjustFabVisibility()
bottomNav?.show()
viewBinding.toolbarCard.isInvisible = false
(viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = false
updateContainerBottomMargin()
}
@@ -340,14 +256,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = viewBinding.searchView.imeOptions
var options = viewBinding.searchView.getEditText().imeOptions
options = if (isIncognito) {
options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
viewBinding.searchView.imeOptions = options
invalidateMenu()
viewBinding.searchView.getEditText().imeOptions = options
viewBinding.searchBar.invalidateMenu()
}
private fun onLoadingStateChanged(isLoading: Boolean) {
@@ -359,19 +275,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
adjustFabVisibility(isResumeEnabled = isEnabled)
}
private fun onSearchOpened() {
adjustSearchUI(isOpened = true, animate = true)
}
private fun onSearchClosed() {
SoftwareKeyboardControllerCompat(viewBinding.searchView).hide()
adjustSearchUI(isOpened = false, animate = true)
}
private fun isSearchOpened(): Boolean {
return supportFragmentManager.findFragmentByTag(TAG_SEARCH) != null
}
private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
withContext(Dispatchers.Default) {
@@ -399,7 +302,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
private fun adjustFabVisibility(
isResumeEnabled: Boolean = viewModel.isResumeEnabled.value,
topFragment: Fragment? = navigationDelegate.primaryFragment,
isSearchOpened: Boolean = isSearchOpened(),
isSearchOpened: Boolean = viewBinding.searchView.isShowing,
) {
val fab = viewBinding.fab ?: return
if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) {
@@ -413,46 +316,17 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
}
private fun adjustSearchUI(isOpened: Boolean, animate: Boolean) {
if (animate) {
TransitionManager.beginDelayedTransition(viewBinding.appbar)
}
private fun adjustSearchUI(isOpened: Boolean) {
val appBarScrollFlags = if (isOpened) {
SCROLL_FLAG_NO_SCROLL
} else {
SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP
}
viewBinding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags
}
viewBinding.insetsHolder.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags
}
viewBinding.toolbarCard.background = if (isOpened) {
null
} else {
ContextCompat.getDrawable(this, R.drawable.search_bar_background)
}
val padding = if (isOpened) 0 else resources.getDimensionPixelOffset(R.dimen.margin_normal)
viewBinding.appbar.updatePadding(left = padding, right = padding)
adjustFabVisibility(isSearchOpened = isOpened)
supportActionBar?.apply {
setHomeAsUpIndicator(
if (isOpened) {
appcompatR.drawable.abc_ic_ab_back_material
} else {
appcompatR.drawable.abc_ic_search_api_material
},
)
setHomeActionContentDescription(
if (isOpened) R.string.back else R.string.search,
)
}
viewBinding.searchView.setHintCompat(
if (isOpened) R.string.search_hint else R.string.search_manga,
)
bottomNav?.showOrHide(!isOpened)
closeSearchCallback.isEnabled = isOpened
updateContainerBottomMargin()
}
@@ -470,6 +344,39 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
}
private fun handleSearchSuggestionsInsets(insets: WindowInsetsCompat) {
val typeMask = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeMask)
viewBinding.recyclerViewSearch.setPadding(barsInsets.left, 0, barsInsets.right, barsInsets.bottom)
}
private fun initSearchSuggestions() {
val listener = SearchSuggestionListenerImpl(router, viewBinding.searchView, searchSuggestionViewModel)
val adapter = SearchSuggestionAdapter(listener)
viewBinding.searchView.toolbar.addMenuProvider(
SearchSuggestionMenuProvider(this, voiceInputLauncher, searchSuggestionViewModel),
)
viewBinding.searchView.editText.addTextChangedListener(listener)
viewBinding.recyclerViewSearch.adapter = adapter
viewBinding.searchView.observeState()
.map { it >= SearchView.TransitionState.SHOWING }
.distinctUntilChanged()
.flatMapLatest { isShowing ->
if (isShowing) {
searchSuggestionViewModel.suggestion
} else {
emptyFlow()
}
}.observe(this, adapter)
searchSuggestionViewModel.onError.observeEvent(
this,
SnackbarErrorObserver(viewBinding.recyclerViewSearch, null),
)
ItemTouchHelper(SearchSuggestionItemCallback(this))
.attachToRecyclerView(viewBinding.recyclerViewSearch)
}
private fun setNavbarPinned(isPinned: Boolean) {
val bottomNavBar = viewBinding.bottomNav
bottomNavBar?.isPinned = isPinned
@@ -500,26 +407,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
}
private inner class CloseSearchCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val fm = supportFragmentManager
val fragment = fm.findFragmentByTag(TAG_SEARCH)
viewBinding.searchView.clearFocus()
if (fragment == null) {
// this should not happen but who knows
isEnabled = false
return
}
fm.commit {
setReorderingAllowed(true)
remove(fragment)
navigationDelegate.primaryFragment?.let {
setMaxLifecycle(it, Lifecycle.State.RESUMED)
}
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchClosed() }
}
private fun SearchView.observeState() = callbackFlow<SearchView.TransitionState> {
val listener = SearchView.TransitionListener { _, _, state ->
trySendBlocking(state)
}
addTransitionListener(listener)
awaitClose { removeTransitionListener(listener) }
}
}

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.main.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
class MainMenuProvider(
private val router: AppRouter,
private val viewModel: MainViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_main, menu)
}
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_incognito)?.isChecked =
viewModel.isIncognitoModeEnabled.value
val hasAppUpdate = viewModel.appUpdate.value != null
menu.findItem(R.id.action_app_update)?.isVisible = hasAppUpdate
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_settings -> {
router.openSettings()
true
}
R.id.action_incognito -> {
viewModel.setIncognitoMode(!menuItem.isChecked)
true
}
R.id.action_app_update -> {
router.openAppUpdate()
true
}
else -> false
}
}

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
@@ -54,6 +55,12 @@ class MainViewModel @Inject constructor(
isNavBarPinned
}.flowOn(Dispatchers.Default)
val isIncognitoModeEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_INCOGNITO_MODE,
valueProducer = { isIncognitoModeEnabled },
)
init {
launchJob {
appUpdateRepository.fetchUpdate()

View File

@@ -10,7 +10,6 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
@@ -35,18 +34,16 @@ class MangaSearchRepository @Inject constructor(
private val settings: AppSettings,
) {
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
return when {
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, emptyList()) }
source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit)
else -> db.getMangaDao().searchByTitle("%$query%", limit)
}.let {
if (settings.isNsfwContentDisabled) it.filterNot { x -> x.manga.isNsfw } else it
}.map {
it.toManga()
}.sortedBy { x ->
x.title.levenshteinDistance(query)
}
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> = when {
query.isEmpty() -> db.getSuggestionDao().getTopManga(limit)
source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit)
else -> db.getMangaDao().searchByTitle("%$query%", limit)
}.let {
if (settings.isNsfwContentDisabled) it.filterNot { x -> x.manga.isNsfw } else it
}.map {
it.toManga()
}.sortedBy { x ->
x.title.levenshteinDistance(query)
}
suspend fun getQuerySuggestion(

View File

@@ -1,67 +0,0 @@
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.view.WindowInsetsCompat
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.ItemTouchHelper
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.VoiceInputContract
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
@AndroidEntryPoint
class SearchSuggestionFragment :
BaseFragment<FragmentSearchSuggestionBinding>(),
SearchSuggestionItemCallback.SuggestionItemListener {
private val viewModel by activityViewModels<SearchSuggestionViewModel>()
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract()) { result ->
if (result != null) {
viewModel.onQueryChanged(result)
}
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentSearchSuggestionBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentSearchSuggestionBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = SearchSuggestionAdapter(
listener = requireActivity() as SearchSuggestionListener,
)
addMenuProvider(SearchSuggestionMenuProvider(binding.root.context, voiceInputLauncher, viewModel))
binding.root.adapter = adapter
binding.root.setHasFixedSize(true)
viewModel.suggestion.observe(viewLifecycleOwner, adapter)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.root, this))
ItemTouchHelper(SearchSuggestionItemCallback(this))
.attachToRecyclerView(binding.root)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeMask)
v.setPadding(barsInsets.left, 0, barsInsets.right, barsInsets.bottom)
return insets.consumeAll(typeMask)
}
override fun onRemoveQuery(query: String) {
viewModel.deleteQuery(query)
}
override fun onResume() {
super.onResume()
viewModel.onResume()
}
}

View File

@@ -1,18 +1,17 @@
package org.koitharu.kotatsu.search.ui.suggestion
import android.text.TextWatcher
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.SearchKind
interface SearchSuggestionListener {
interface SearchSuggestionListener : TextWatcher {
fun onMangaClick(manga: Manga)
fun onQueryClick(query: String, kind: SearchKind, submit: Boolean)
fun onQueryChanged(query: String)
fun onSourceToggle(source: MangaSource, isEnabled: Boolean)
fun onSourceClick(source: MangaSource)

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.search.ui.suggestion
import android.text.Editable
import androidx.core.net.toUri
import com.google.android.material.search.SearchView
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaLinkResolver
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.SearchKind
class SearchSuggestionListenerImpl(
private val router: AppRouter,
private val searchView: SearchView,
private val viewModel: SearchSuggestionViewModel,
) : SearchSuggestionListener {
override fun onMangaClick(manga: Manga) {
router.openDetails(manga)
}
override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) {
if (submit && query.isNotEmpty()) {
if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) {
router.openDetails(query.toUri())
} else {
router.openSearch(query, kind)
if (kind != SearchKind.TAG) {
viewModel.saveQuery(query)
}
}
} else {
searchView.setText(query)
}
}
override fun onTagClick(tag: MangaTag) {
router.openSearch(tag.title, SearchKind.TAG)
}
override fun onSourceToggle(source: MangaSource, isEnabled: Boolean) {
viewModel.onSourceToggle(source, isEnabled)
}
override fun onSourceClick(source: MangaSource) {
router.openList(source, null, null)
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
viewModel.onQueryChanged(s?.toString().orEmpty())
}
}

View File

@@ -3,17 +3,16 @@ package org.koitharu.kotatsu.search.ui.suggestion
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.SearchSuggestionType
@@ -48,8 +47,7 @@ class SearchSuggestionViewModel @Inject constructor(
) : BaseViewModel() {
private val query = MutableStateFlow("")
private var suggestionJob: Job? = null
private var invalidateOnResume = false
private val invalidationTrigger = MutableStateFlow(0)
val isIncognitoModeEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
@@ -57,11 +55,19 @@ class SearchSuggestionViewModel @Inject constructor(
valueProducer = { isIncognitoModeEnabled },
)
val suggestion = MutableStateFlow<List<SearchSuggestionItem>>(emptyList())
init {
setupSuggestion()
}
val suggestion: Flow<List<SearchSuggestionItem>> = combine(
query.debounce(DEBOUNCE_TIMEOUT),
sourcesRepository.observeEnabledSources().map { it.mapToSet { x -> x.name } },
settings.observeAsFlow(AppSettings.KEY_SEARCH_SUGGESTION_TYPES) { searchSuggestionTypes },
invalidationTrigger,
)
{ a, b, c, _ ->
Triple(a, b, c)
}.mapLatest { (searchQuery, enabledSources, types) ->
buildSearchSuggestion(searchQuery, enabledSources, types)
}.distinctUntilChanged()
.withErrorHandling()
.flowOn(Dispatchers.Default)
fun onQueryChanged(newQuery: String) {
query.value = newQuery
@@ -70,14 +76,14 @@ class SearchSuggestionViewModel @Inject constructor(
fun saveQuery(query: String) {
if (!settings.isIncognitoModeEnabled) {
repository.saveSearchQuery(query)
invalidationTrigger.value++
}
invalidateOnResume = true
}
fun clearSearchHistory() {
launchJob(Dispatchers.Default) {
repository.clearSearchHistory()
setupSuggestion()
invalidationTrigger.value++
}
}
@@ -87,35 +93,13 @@ class SearchSuggestionViewModel @Inject constructor(
}
}
fun onResume() {
if (invalidateOnResume || suggestionJob?.isActive != true) {
invalidateOnResume = false
setupSuggestion()
}
}
fun deleteQuery(query: String) {
launchJob(Dispatchers.Default) {
repository.deleteSearchQuery(query)
setupSuggestion()
invalidationTrigger.value++
}
}
private fun setupSuggestion() {
suggestionJob?.cancel()
suggestionJob = combine(
query.debounce(DEBOUNCE_TIMEOUT),
sourcesRepository.observeEnabledSources().map { it.mapToSet { x -> x.name } },
settings.observeAsFlow(AppSettings.KEY_SEARCH_SUGGESTION_TYPES) { searchSuggestionTypes },
::Triple,
).mapLatest { (searchQuery, enabledSources, types) ->
buildSearchSuggestion(searchQuery, enabledSources, types)
}.distinctUntilChanged()
.onEach {
suggestion.value = it
}.withErrorHandling().launchIn(viewModelScope + Dispatchers.Default)
}
private suspend fun buildSearchSuggestion(
searchQuery: String,
enabledSources: Set<String>,
@@ -210,7 +194,7 @@ class SearchSuggestionViewModel @Inject constructor(
listOf(SearchSuggestionItem.Text(0, e))
}
private suspend fun getSources(searchQuery: String, enabledSources: Set<String>): List<SearchSuggestionItem> =
private fun getSources(searchQuery: String, enabledSources: Set<String>): List<SearchSuggestionItem> =
runCatchingCancellable {
repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
.map { SearchSuggestionItem.Source(it, it.name in enabledSources) }

View File

@@ -55,7 +55,7 @@ sealed interface SearchSuggestionItem : ListModel {
get() = source.isNsfw()
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Source && other.source == source
return other is Source && other.source.name == source.name
}
override fun getChangePayload(previousState: ListModel): Any? {
@@ -78,7 +78,7 @@ sealed interface SearchSuggestionItem : ListModel {
get() = source.isNsfw()
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Source && other.source == source
return other is SourceTip && other.source.name == source.name
}
}

View File

@@ -1,161 +0,0 @@
package org.koitharu.kotatsu.search.ui.widget
import android.annotation.SuppressLint
import android.content.Context
import android.os.Parcelable
import android.text.Spannable
import android.text.SpannableString
import android.text.style.TextAppearanceSpan
import android.util.AttributeSet
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SoundEffectConstants
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.EditorInfo
import androidx.annotation.AttrRes
import androidx.annotation.CheckResult
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import androidx.appcompat.R as appcompatR
private const val DRAWABLE_END = 2
class SearchEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = appcompatR.attr.editTextStyle,
) : AppCompatEditText(context, attrs, defStyleAttr) {
var searchSuggestionListener: SearchSuggestionListener? = null
private val clearIcon =
ContextCompat.getDrawable(context, appcompatR.drawable.abc_ic_clear_material)
private var isEmpty = text.isNullOrEmpty()
init {
hint = wrapHint()
}
var query: String
get() = text?.trim()?.toString().orEmpty()
set(value) {
if (value != text?.toString()) {
setText(value)
setSelection(value.length)
}
}
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
if (hasFocus()) {
clearFocus()
}
}
return super.onKeyPreIme(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (event.isFromSource(InputDevice.SOURCE_KEYBOARD)
&& (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)
&& event.hasNoModifiers()
&& query.isNotEmpty()
) {
cancelLongPress()
searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true)
clearFocus()
return true
}
return super.onKeyUp(keyCode, event)
}
override fun onEditorAction(actionCode: Int) {
super.onEditorAction(actionCode)
if (actionCode == EditorInfo.IME_ACTION_SEARCH) {
searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true)
}
}
override fun onTextChanged(
text: CharSequence?,
start: Int,
lengthBefore: Int,
lengthAfter: Int,
) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
val empty = text.isNullOrEmpty()
if (isEmpty != empty) {
isEmpty = empty
updateActionIcon()
}
searchSuggestionListener?.onQueryChanged(query)
}
override fun onRestoreInstanceState(state: Parcelable?) {
super.onRestoreInstanceState(state)
updateActionIcon()
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
val drawable =
compoundDrawablesRelative[DRAWABLE_END] ?: return super.onTouchEvent(event)
val isOnDrawable = drawable.isVisible && if (layoutDirection == LAYOUT_DIRECTION_RTL) {
event.x.toInt() in paddingLeft..(drawable.bounds.width() + paddingLeft)
} else {
event.x.toInt() in (width - drawable.bounds.width() - paddingRight)..(width - paddingRight)
}
if (isOnDrawable) {
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED)
playSoundEffect(SoundEffectConstants.CLICK)
onActionIconClick()
return true
}
}
return super.onTouchEvent(event)
}
override fun clearFocus() {
super.clearFocus()
text?.clear()
}
fun setHintCompat(@StringRes resId: Int) {
hint = wrapHint(context.getString(resId))
}
private fun onActionIconClick() {
when {
!text.isNullOrEmpty() -> text?.clear()
}
}
private fun updateActionIcon() {
val icon = when {
!text.isNullOrEmpty() -> clearIcon
else -> null
}
if (icon !== drawableEnd) {
setCompoundDrawablesRelativeWithIntrinsicBounds(drawableStart, null, icon, null)
}
}
@CheckResult
private fun wrapHint(raw: CharSequence? = hint): SpannableString? {
val rawHint = raw?.toString() ?: return null
val formatted = SpannableString(rawHint)
formatted.setSpan(
TextAppearanceSpan(context, R.style.TextAppearance_Kotatsu_SearchView),
0,
formatted.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
)
return formatted
}
}

View File

@@ -1,24 +0,0 @@
package org.koitharu.kotatsu.search.ui.widget
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import androidx.annotation.AttrRes
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.shape.MaterialShapeDrawable
import androidx.appcompat.R as appcompatR
class SearchToolbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = appcompatR.attr.toolbarStyle,
) : MaterialToolbar(context, attrs, defStyleAttr) {
private val bgDrawable = MaterialShapeDrawable(context, attrs, defStyleAttr, 0)
init {
bgDrawable.initializeElevationOverlay(context)
bgDrawable.setShadowColor(Color.DKGRAY)
background = bgDrawable
}
}

View File

@@ -33,6 +33,10 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
.build(),
)
@Transaction
@Query("SELECT manga.* FROM suggestions LEFT JOIN manga ON manga.manga_id = suggestions.manga_id ORDER BY relevance DESC LIMIT :limit")
abstract suspend fun getTopManga(limit: Int): List<MangaWithTags>
@Transaction
open suspend fun getRandom(limit: Int): List<MangaWithTags> {
val ids = getRandomIds(limit)

View File

@@ -19,7 +19,8 @@
app:layout_constraintStart_toStartOf="parent"
app:paddingBottomSystemWindowInsets="false"
app:paddingStartSystemWindowInsets="false"
app:paddingTopSystemWindowInsets="false" />
app:paddingTopSystemWindowInsets="false"
app:scrollingEnabled="true" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
@@ -40,7 +41,6 @@
android:clipToPadding="false"
android:elevation="0dp"
android:fitsSystemWindows="false"
android:paddingHorizontal="@dimen/margin_normal"
android:stateListAnimator="@null"
app:elevation="0dp"
app:liftOnScroll="false"
@@ -55,46 +55,42 @@
app:layout_scrollFlags="scroll|enterAlways|snap" />
<FrameLayout
android:id="@+id/toolbar_card"
android:id="@+id/layout_search"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginVertical="8dp"
android:background="@drawable/search_bar_background"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways|snap">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
<com.google.android.material.search.SearchBar
android:id="@+id/search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:background="@null"
android:focusable="true"
android:focusableInTouchMode="true"
app:contentInsetStartWithNavigation="0dp"
app:navigationContentDescription="@string/search"
app:navigationIcon="?attr/actionModeWebSearchDrawable">
<org.koitharu.kotatsu.search.ui.widget.SearchEditText
android:id="@+id/searchView"
style="@style/Widget.Kotatsu.SearchView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="8dp"
android:background="@null"
android:gravity="center_vertical"
android:hint="@string/search_manga"
android:imeOptions="actionSearch|flagNoFullscreen"
android:importantForAutofill="no"
android:singleLine="true"
tools:drawableEnd="@drawable/abc_ic_clear_material" />
</com.google.android.material.appbar.MaterialToolbar>
android:layout_gravity="center_vertical|end"
android:hint="@string/search_manga"
app:adaptiveMaxWidthEnabled="true" />
</FrameLayout>
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.search.SearchView
android:id="@+id/search_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:hint="@string/search_hint"
app:layout_anchor="@id/search_bar">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_search"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</com.google.android.material.search.SearchView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout>

View File

@@ -22,7 +22,6 @@
android:clipToPadding="false"
android:elevation="0dp"
android:fitsSystemWindows="false"
android:paddingHorizontal="@dimen/margin_normal"
android:stateListAnimator="@null"
app:elevation="0dp"
app:liftOnScroll="false"
@@ -36,49 +35,33 @@
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|enterAlways|snap" />
<FrameLayout
android:id="@+id/toolbar_card"
<com.google.android.material.search.SearchBar
android:id="@+id/search_bar"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginBottom="@dimen/margin_small"
android:background="@drawable/search_bar_background"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
app:layout_scrollFlags="scroll|enterAlways|snap">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:background="@null"
android:focusable="true"
android:focusableInTouchMode="true"
app:collapseIcon="@null"
app:contentInsetStartWithNavigation="0dp"
app:navigationContentDescription="@string/search"
app:navigationIcon="?attr/actionModeWebSearchDrawable">
<org.koitharu.kotatsu.search.ui.widget.SearchEditText
android:id="@+id/searchView"
style="@style/Widget.Kotatsu.SearchView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="8dp"
android:background="@null"
android:gravity="center_vertical"
android:hint="@string/search_manga"
android:imeOptions="actionSearch"
android:importantForAutofill="no"
android:singleLine="true"
tools:drawableEnd="@drawable/abc_ic_clear_material" />
</com.google.android.material.appbar.MaterialToolbar>
</FrameLayout>
android:layout_height="wrap_content"
android:hint="@string/search_manga"
app:layout_scrollFlags="scroll|enterAlways|snap" />
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.search.SearchView
android:id="@+id/search_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:hint="@string/search_hint"
app:layout_anchor="@id/search_bar">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_search"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</com.google.android.material.search.SearchView>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"