Add voice search
This commit is contained in:
@@ -7,8 +7,10 @@ import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.*
|
||||
@@ -17,7 +19,10 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.R as materialR
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -55,8 +60,8 @@ import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||
import org.koitharu.kotatsu.tracker.ui.FeedFragment
|
||||
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
||||
import org.koitharu.kotatsu.utils.VoiceInputContract
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val TAG_PRIMARY = "primary"
|
||||
private const val TAG_SEARCH = "search"
|
||||
@@ -75,6 +80,7 @@ class MainActivity :
|
||||
private lateinit var navHeaderBinding: NavigationHeaderBinding
|
||||
private var drawerToggle: ActionBarDrawerToggle? = null
|
||||
private var drawer: DrawerLayout? = null
|
||||
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = binding.appbar
|
||||
@@ -119,6 +125,7 @@ class MainActivity :
|
||||
}
|
||||
|
||||
binding.fab.setOnClickListener(this@MainActivity)
|
||||
binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null
|
||||
|
||||
supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
|
||||
if (it is HistoryListFragment) binding.fab.show() else binding.fab.hide()
|
||||
@@ -277,6 +284,19 @@ class MainActivity :
|
||||
searchSuggestionViewModel.onQueryChanged(query)
|
||||
}
|
||||
|
||||
override fun onVoiceSearchClick() {
|
||||
val options = binding.searchView.drawableEnd?.bounds?.let { bounds ->
|
||||
ActivityOptionsCompat.makeScaleUpAnimation(
|
||||
binding.searchView,
|
||||
bounds.centerX(),
|
||||
bounds.centerY(),
|
||||
bounds.width(),
|
||||
bounds.height(),
|
||||
)
|
||||
}
|
||||
voiceInputLauncher.tryLaunch(binding.searchView.hint?.toString(), options)
|
||||
}
|
||||
|
||||
override fun onClearSearchHistory() {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.clear_search_history)
|
||||
@@ -373,13 +393,26 @@ class MainActivity :
|
||||
}
|
||||
|
||||
private fun onSearchOpened() {
|
||||
TransitionManager.beginDelayedTransition(binding.appbar)
|
||||
drawerToggle?.isDrawerIndicatorEnabled = false
|
||||
binding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
|
||||
scrollFlags = SCROLL_FLAG_NO_SCROLL
|
||||
}
|
||||
binding.appbar.setBackgroundColor(getThemeColor(materialR.attr.colorSurfaceVariant))
|
||||
binding.appbar.updatePadding(left = 0, right = 0)
|
||||
adjustDrawerLock()
|
||||
adjustFabVisibility(isSearchOpened = true)
|
||||
}
|
||||
|
||||
private fun onSearchClosed() {
|
||||
TransitionManager.beginDelayedTransition(binding.appbar)
|
||||
drawerToggle?.isDrawerIndicatorEnabled = true
|
||||
binding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
|
||||
scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS
|
||||
}
|
||||
binding.appbar.background = null
|
||||
val padding = resources.getDimensionPixelOffset(R.dimen.margin_normal)
|
||||
binding.appbar.updatePadding(left = padding, right = padding)
|
||||
adjustDrawerLock()
|
||||
adjustFabVisibility(isSearchOpened = false)
|
||||
}
|
||||
@@ -427,4 +460,13 @@ class MainActivity :
|
||||
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED
|
||||
)
|
||||
}
|
||||
|
||||
private inner class VoiceInputCallback : ActivityResultCallback<String?> {
|
||||
|
||||
override fun onActivityResult(result: String?) {
|
||||
if (result != null) {
|
||||
binding.searchView.query = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,6 @@ interface SearchSuggestionListener {
|
||||
fun onClearSearchHistory()
|
||||
|
||||
fun onTagClick(tag: MangaTag)
|
||||
|
||||
fun onVoiceSearchClick()
|
||||
}
|
||||
@@ -2,15 +2,20 @@ package org.koitharu.kotatsu.search.ui.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
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.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.R as materialR
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.utils.ext.drawableEnd
|
||||
import org.koitharu.kotatsu.utils.ext.drawableStart
|
||||
|
||||
private const val DRAWABLE_END = 2
|
||||
@@ -18,11 +23,19 @@ private const val DRAWABLE_END = 2
|
||||
class SearchEditText @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = R.attr.editTextStyle,
|
||||
@AttrRes defStyleAttr: Int = materialR.attr.editTextStyle,
|
||||
) : AppCompatEditText(context, attrs, defStyleAttr) {
|
||||
|
||||
var searchSuggestionListener: SearchSuggestionListener? = null
|
||||
private val clearIcon = ContextCompat.getDrawable(context, R.drawable.abc_ic_clear_material)
|
||||
private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material)
|
||||
private val voiceIcon = ContextCompat.getDrawable(context, R.drawable.ic_voice_input)
|
||||
private var isEmpty = text.isNullOrEmpty()
|
||||
|
||||
var isVoiceSearchEnabled: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
updateActionIcon()
|
||||
}
|
||||
|
||||
var query: String
|
||||
get() = text?.trim()?.toString().orEmpty()
|
||||
@@ -57,15 +70,19 @@ class SearchEditText @JvmOverloads constructor(
|
||||
lengthAfter: Int,
|
||||
) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
drawableStart,
|
||||
null,
|
||||
if (text.isNullOrEmpty()) null else clearIcon,
|
||||
null,
|
||||
)
|
||||
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) {
|
||||
@@ -76,7 +93,9 @@ class SearchEditText @JvmOverloads constructor(
|
||||
event.x.toInt() in (width - drawable.bounds.width() - paddingRight)..(width - paddingRight)
|
||||
}
|
||||
if (isOnDrawable) {
|
||||
text?.clear()
|
||||
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED)
|
||||
playSoundEffect(SoundEffectConstants.CLICK)
|
||||
onActionIconClick()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -87,4 +106,22 @@ class SearchEditText @JvmOverloads constructor(
|
||||
super.clearFocus()
|
||||
text?.clear()
|
||||
}
|
||||
|
||||
private fun onActionIconClick() {
|
||||
when {
|
||||
!text.isNullOrEmpty() -> text?.clear()
|
||||
isVoiceSearchEnabled -> searchSuggestionListener?.onVoiceSearchClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateActionIcon() {
|
||||
val icon = when {
|
||||
!text.isNullOrEmpty() -> clearIcon
|
||||
isVoiceSearchEnabled -> voiceIcon
|
||||
else -> null
|
||||
}
|
||||
if (icon !== drawableEnd) {
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(drawableStart, null, icon, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.speech.RecognizerIntent
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
|
||||
class VoiceInputContract : ActivityResultContract<String?, String?>() {
|
||||
|
||||
override fun createIntent(context: Context, input: String?): Intent {
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
|
||||
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
intent.putExtra(RecognizerIntent.EXTRA_PROMPT, input)
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): String? {
|
||||
return if (resultCode == Activity.RESULT_OK && intent != null) {
|
||||
val matches = intent.getStringArrayExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
matches?.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkRequest
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
@@ -40,4 +43,16 @@ fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
|
||||
val info = getForegroundInfo()
|
||||
setForeground(info)
|
||||
}.isSuccess
|
||||
}.isSuccess
|
||||
|
||||
fun <I> ActivityResultLauncher<I>.resolve(context: Context, input: I): ResolveInfo? {
|
||||
val pm = context.packageManager
|
||||
val intent = contract.createIntent(context, input)
|
||||
return pm.resolveActivity(intent, 0)
|
||||
}
|
||||
|
||||
fun <I> ActivityResultLauncher<I>.tryLaunch(input: I, options: ActivityOptionsCompat? = null): Boolean {
|
||||
return runCatching {
|
||||
launch(input, options)
|
||||
}.isSuccess
|
||||
}
|
||||
9
app/src/main/res/drawable/ic_voice_input.xml
Normal file
9
app/src/main/res/drawable/ic_voice_input.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#000" android:pathData="M17.3,11C17.3,14 14.76,16.1 12,16.1C9.24,16.1 6.7,14 6.7,11H5C5,14.41 7.72,17.23 11,17.72V21H13V17.72C16.28,17.23 19,14.41 19,11M10.8,4.9C10.8,4.24 11.34,3.7 12,3.7C12.66,3.7 13.2,4.24 13.2,4.9L13.19,11.1C13.19,11.76 12.66,12.3 12,12.3C11.34,12.3 10.8,11.76 10.8,11.1M12,14A3,3 0 0,0 15,11V5A3,3 0 0,0 12,2A3,3 0 0,0 9,5V11A3,3 0 0,0 12,14Z" />
|
||||
</vector>
|
||||
@@ -31,8 +31,9 @@
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingLeft="16dp"
|
||||
android:background="@null"
|
||||
android:clipToPadding="false"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
app:elevation="0dp"
|
||||
app:liftOnScroll="false">
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@null"
|
||||
android:clipToPadding="false"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:stateListAnimator="@null">
|
||||
@@ -56,7 +57,6 @@
|
||||
android:hint="@string/search_manga"
|
||||
android:imeOptions="actionSearch"
|
||||
android:importantForAutofill="no"
|
||||
android:paddingBottom="1dp"
|
||||
android:singleLine="true"
|
||||
tools:drawableEnd="@drawable/abc_ic_clear_material" />
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<style name="Widget.Kotatsu.SearchView" parent="@style/Widget.AppCompat.SearchView">
|
||||
<item name="iconifiedByDefault">false</item>
|
||||
<item name="searchIcon">@null</item>
|
||||
<item name="hintTextAppearance">?textAppearanceBodyMedium</item>
|
||||
<item name="queryBackground">@null</item>
|
||||
<item name="android:textColorHint">?attr/colorControlNormal</item>
|
||||
<item name="android:textSize">18sp</item>
|
||||
|
||||
Reference in New Issue
Block a user