Add voice search

This commit is contained in:
Koitharu
2022-05-06 10:52:51 +03:00
parent 6969f40fa0
commit 878df24a64
9 changed files with 148 additions and 15 deletions

View File

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

View File

@@ -14,4 +14,6 @@ interface SearchSuggestionListener {
fun onClearSearchHistory()
fun onTagClick(tag: MangaTag)
fun onVoiceSearchClick()
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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