Migrate to mdc search view

master
Koitharu 11 months ago
parent a52730fff0
commit 1b5720f2a5
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -5,18 +5,13 @@ import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.SoftwareKeyboardControllerCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
@ -25,32 +20,37 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withResumed 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
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS 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_NO_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_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.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
import com.google.android.material.search.SearchView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.router 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.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator 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.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.end 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.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga 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.remotelist.ui.MangaSearchMenuProvider
import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionItemCallback
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListenerImpl
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionMenuProvider
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel 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 org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
import javax.inject.Inject import javax.inject.Inject
import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR
private const val TAG_SEARCH = "search"
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner, class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner,
View.OnClickListener, View.OnClickListener,
View.OnFocusChangeListener, SearchSuggestionItemCallback.SuggestionItemListener,
SearchSuggestionListener,
MainNavigationDelegate.OnFragmentChangedListener, MainNavigationDelegate.OnFragmentChangedListener,
View.OnLayoutChangeListener { View.OnLayoutChangeListener,
SearchView.TransitionListener {
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
private val viewModel by viewModels<MainViewModel>() private val viewModel by viewModels<MainViewModel>()
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>() 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 navigationDelegate: MainNavigationDelegate
private lateinit var appUpdateBadge: OptionsMenuBadgeHelper
private lateinit var fadingAppbarMediator: FadingAppbarMediator private lateinit var fadingAppbarMediator: FadingAppbarMediator
override val appBar: AppBarLayout override val appBar: AppBarLayout
@ -107,14 +107,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityMainBinding.inflate(layoutInflater)) setContentView(ActivityMainBinding.inflate(layoutInflater))
with(viewBinding.searchView) {
onFocusChangeListener = this@MainActivity
searchSuggestionListener = this@MainActivity
}
viewBinding.fab?.setOnClickListener(this) viewBinding.fab?.setOnClickListener(this)
viewBinding.navRail?.headerView?.setOnClickListener(this) viewBinding.navRail?.headerView?.setOnClickListener(this)
fadingAppbarMediator = FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbarCard) fadingAppbarMediator =
FadingAppbarMediator(viewBinding.appbar, viewBinding.layoutSearch ?: viewBinding.searchBar)
navigationDelegate = MainNavigationDelegate( navigationDelegate = MainNavigationDelegate(
navBar = checkNotNull(bottomNav ?: viewBinding.navRail), navBar = checkNotNull(bottomNav ?: viewBinding.navRail),
@ -124,11 +120,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
navigationDelegate.addOnFragmentChangedListener(this) navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(this, savedInstanceState) 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(ExitCallback(this, viewBinding.container))
onBackPressedDispatcher.addCallback(navigationDelegate) onBackPressedDispatcher.addCallback(navigationDelegate)
onBackPressedDispatcher.addCallback(closeSearchCallback)
if (savedInstanceState == null) { if (savedInstanceState == null) {
onFirstStart() onFirstStart()
@ -139,16 +134,18 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.feedCounter.observe(this, ::onFeedCounterChanged) 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.onFirstStart.observeEvent(this) { router.showWelcomeSheet() }
viewModel.isBottomNavPinned.observe(this, ::setNavbarPinned) viewModel.isBottomNavPinned.observe(this, ::setNavbarPinned)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
viewBinding.bottomNav?.addOnLayoutChangeListener(this) viewBinding.bottomNav?.addOnLayoutChangeListener(this)
viewBinding.searchView.addTransitionListener(this)
initSearchSuggestions()
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState) super.onRestoreInstanceState(savedInstanceState)
adjustSearchUI(isSearchOpened(), animate = false) adjustSearchUI(viewBinding.searchView.isShowing)
navigationDelegate.syncSelectedItem() navigationDelegate.syncSelectedItem()
} }
@ -157,7 +154,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
adjustAppbar(topFragment = fragment) adjustAppbar(topFragment = fragment)
if (fromUser) { if (fromUser) {
actionModeDelegate.finishActionMode() actionModeDelegate.finishActionMode()
closeSearchCallback.handleOnBackPressed()
viewBinding.appbar.setExpanded(true) 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) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.fab, R.id.railFab -> viewModel.openLastReader() 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 { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars() val typeMask = WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeMask) val barsInsets = insets.getInsets(typeMask)
viewBinding.toolbarCard.updateLayoutParams<MarginLayoutParams> { val searchBarDefaultMargin = resources.getDimensionPixelOffset(materialR.dimen.m3_searchbar_margin_horizontal)
marginEnd = barsInsets.end(v) viewBinding.searchBar.updateLayoutParams<MarginLayoutParams> {
marginEnd = searchBarDefaultMargin + barsInsets.end(v)
marginStart = if (viewBinding.navRail != null) { marginStart = if (viewBinding.navRail != null) {
0 searchBarDefaultMargin
} else { } else {
barsInsets.start(v) searchBarDefaultMargin + barsInsets.start(v)
} }
} }
viewBinding.bottomNav?.updatePadding( viewBinding.bottomNav?.updatePadding(
@ -241,7 +193,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
bottomMargin = barsInsets.bottom bottomMargin = barsInsets.bottom
} }
updateContainerBottomMargin() 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( override fun onLayoutChange(
@ -260,65 +214,27 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
} }
override fun onFocusChange(v: View?, hasFocus: Boolean) { override fun onStateChanged(
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH) searchView: SearchView,
if (v?.id == R.id.searchView && hasFocus) { previousState: SearchView.TransitionState,
if (fragment == null) { newState: SearchView.TransitionState,
supportFragmentManager.commit { ) {
setReorderingAllowed(true) val wasOpened = previousState >= SearchView.TransitionState.SHOWING
add(R.id.container, SearchSuggestionFragment(), TAG_SEARCH) val isOpened = newState >= SearchView.TransitionState.SHOWING
navigationDelegate.primaryFragment?.let { if (isOpened != wasOpened) {
setMaxLifecycle(it, Lifecycle.State.STARTED) adjustSearchUI(isOpened)
}
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchOpened() }
}
}
}
}
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) { override fun onRemoveQuery(query: String) {
router.openSearch(tag.title, SearchKind.TAG) searchSuggestionViewModel.deleteQuery(query)
}
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 onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode) super.onSupportActionModeStarted(mode)
adjustFabVisibility() adjustFabVisibility()
bottomNav?.hide() bottomNav?.hide()
viewBinding.toolbarCard.isInvisible = true (viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = true
updateContainerBottomMargin() updateContainerBottomMargin()
} }
@ -326,7 +242,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
super.onSupportActionModeFinished(mode) super.onSupportActionModeFinished(mode)
adjustFabVisibility() adjustFabVisibility()
bottomNav?.show() bottomNav?.show()
viewBinding.toolbarCard.isInvisible = false (viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = false
updateContainerBottomMargin() updateContainerBottomMargin()
} }
@ -340,14 +256,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
private fun onIncognitoModeChanged(isIncognito: Boolean) { private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = viewBinding.searchView.imeOptions var options = viewBinding.searchView.getEditText().imeOptions
options = if (isIncognito) { options = if (isIncognito) {
options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else { } else {
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
} }
viewBinding.searchView.imeOptions = options viewBinding.searchView.getEditText().imeOptions = options
invalidateMenu() viewBinding.searchBar.invalidateMenu()
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
@ -359,19 +275,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
adjustFabVisibility(isResumeEnabled = isEnabled) 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() { private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -399,7 +302,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
private fun adjustFabVisibility( private fun adjustFabVisibility(
isResumeEnabled: Boolean = viewModel.isResumeEnabled.value, isResumeEnabled: Boolean = viewModel.isResumeEnabled.value,
topFragment: Fragment? = navigationDelegate.primaryFragment, topFragment: Fragment? = navigationDelegate.primaryFragment,
isSearchOpened: Boolean = isSearchOpened(), isSearchOpened: Boolean = viewBinding.searchView.isShowing,
) { ) {
val fab = viewBinding.fab ?: return val fab = viewBinding.fab ?: return
if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) { 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) { private fun adjustSearchUI(isOpened: Boolean) {
if (animate) {
TransitionManager.beginDelayedTransition(viewBinding.appbar)
}
val appBarScrollFlags = if (isOpened) { val appBarScrollFlags = if (isOpened) {
SCROLL_FLAG_NO_SCROLL SCROLL_FLAG_NO_SCROLL
} else { } else {
SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP
} }
viewBinding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags
}
viewBinding.insetsHolder.updateLayoutParams<AppBarLayout.LayoutParams> { viewBinding.insetsHolder.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags 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) 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) bottomNav?.showOrHide(!isOpened)
closeSearchCallback.isEnabled = isOpened
updateContainerBottomMargin() 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) { private fun setNavbarPinned(isPinned: Boolean) {
val bottomNavBar = viewBinding.bottomNav val bottomNavBar = viewBinding.bottomNav
bottomNavBar?.isPinned = isPinned bottomNavBar?.isPinned = isPinned
@ -500,26 +407,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
} }
private inner class CloseSearchCallback : OnBackPressedCallback(false) { private fun SearchView.observeState() = callbackFlow<SearchView.TransitionState> {
val listener = SearchView.TransitionListener { _, _, state ->
override fun handleOnBackPressed() { trySendBlocking(state)
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() }
}
} }
addTransitionListener(listener)
awaitClose { removeTransitionListener(listener) }
} }
} }

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

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

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

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

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

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

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

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

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

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

@ -33,6 +33,10 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
.build(), .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 @Transaction
open suspend fun getRandom(limit: Int): List<MangaWithTags> { open suspend fun getRandom(limit: Int): List<MangaWithTags> {
val ids = getRandomIds(limit) val ids = getRandomIds(limit)

@ -19,7 +19,8 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:paddingBottomSystemWindowInsets="false" app:paddingBottomSystemWindowInsets="false"
app:paddingStartSystemWindowInsets="false" app:paddingStartSystemWindowInsets="false"
app:paddingTopSystemWindowInsets="false" /> app:paddingTopSystemWindowInsets="false"
app:scrollingEnabled="true" />
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -40,7 +41,6 @@
android:clipToPadding="false" android:clipToPadding="false"
android:elevation="0dp" android:elevation="0dp"
android:fitsSystemWindows="false" android:fitsSystemWindows="false"
android:paddingHorizontal="@dimen/margin_normal"
android:stateListAnimator="@null" android:stateListAnimator="@null"
app:elevation="0dp" app:elevation="0dp"
app:liftOnScroll="false" app:liftOnScroll="false"
@ -55,46 +55,42 @@
app:layout_scrollFlags="scroll|enterAlways|snap" /> app:layout_scrollFlags="scroll|enterAlways|snap" />
<FrameLayout <FrameLayout
android:id="@+id/toolbar_card" android:id="@+id/layout_search"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:background="@drawable/search_bar_background"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
app:layout_scrollFlags="scroll|enterAlways|snap"> app:layout_scrollFlags="scroll|enterAlways|snap">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.search.SearchBar
android:id="@id/toolbar" android:id="@+id/search_bar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="6dp" android:layout_gravity="center_vertical|end"
android:background="@null" android:hint="@string/search_manga"
android:focusable="true" app:adaptiveMaxWidthEnabled="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>
</FrameLayout> </FrameLayout>
</com.google.android.material.appbar.AppBarLayout> </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> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout> </LinearLayout>

@ -22,7 +22,6 @@
android:clipToPadding="false" android:clipToPadding="false"
android:elevation="0dp" android:elevation="0dp"
android:fitsSystemWindows="false" android:fitsSystemWindows="false"
android:paddingHorizontal="@dimen/margin_normal"
android:stateListAnimator="@null" android:stateListAnimator="@null"
app:elevation="0dp" app:elevation="0dp"
app:liftOnScroll="false" app:liftOnScroll="false"
@ -36,48 +35,32 @@
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|enterAlways|snap" /> app:layout_scrollFlags="scroll|enterAlways|snap" />
<FrameLayout <com.google.android.material.search.SearchBar
android:id="@+id/toolbar_card" android:id="@+id/search_bar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small" android:hint="@string/search_manga"
android:layout_marginBottom="@dimen/margin_small" app:layout_scrollFlags="scroll|enterAlways|snap" />
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 </com.google.android.material.appbar.AppBarLayout>
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> <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">
</FrameLayout> <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.appbar.AppBarLayout> </com.google.android.material.search.SearchView>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab" android:id="@+id/fab"

Loading…
Cancel
Save