Migrate to mdc search view
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user