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.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.result.ActivityResultCallback
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.*
@@ -17,7 +19,10 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope 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
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar 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.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary" private const val TAG_PRIMARY = "primary"
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@@ -75,6 +80,7 @@ class MainActivity :
private lateinit var navHeaderBinding: NavigationHeaderBinding private lateinit var navHeaderBinding: NavigationHeaderBinding
private var drawerToggle: ActionBarDrawerToggle? = null private var drawerToggle: ActionBarDrawerToggle? = null
private var drawer: DrawerLayout? = null private var drawer: DrawerLayout? = null
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())
override val appBar: AppBarLayout override val appBar: AppBarLayout
get() = binding.appbar get() = binding.appbar
@@ -119,6 +125,7 @@ class MainActivity :
} }
binding.fab.setOnClickListener(this@MainActivity) binding.fab.setOnClickListener(this@MainActivity)
binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null
supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let { supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
if (it is HistoryListFragment) binding.fab.show() else binding.fab.hide() if (it is HistoryListFragment) binding.fab.show() else binding.fab.hide()
@@ -277,6 +284,19 @@ class MainActivity :
searchSuggestionViewModel.onQueryChanged(query) 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() { override fun onClearSearchHistory() {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.clear_search_history) .setTitle(R.string.clear_search_history)
@@ -373,13 +393,26 @@ class MainActivity :
} }
private fun onSearchOpened() { private fun onSearchOpened() {
TransitionManager.beginDelayedTransition(binding.appbar)
drawerToggle?.isDrawerIndicatorEnabled = false 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() adjustDrawerLock()
adjustFabVisibility(isSearchOpened = true) adjustFabVisibility(isSearchOpened = true)
} }
private fun onSearchClosed() { private fun onSearchClosed() {
TransitionManager.beginDelayedTransition(binding.appbar)
drawerToggle?.isDrawerIndicatorEnabled = true 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() adjustDrawerLock()
adjustFabVisibility(isSearchOpened = false) adjustFabVisibility(isSearchOpened = false)
} }
@@ -427,4 +460,13 @@ class MainActivity :
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED 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 onClearSearchHistory()
fun onTagClick(tag: MangaTag) 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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.os.Parcelable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.SoundEffectConstants
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat 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.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.utils.ext.drawableEnd
import org.koitharu.kotatsu.utils.ext.drawableStart import org.koitharu.kotatsu.utils.ext.drawableStart
private const val DRAWABLE_END = 2 private const val DRAWABLE_END = 2
@@ -18,11 +23,19 @@ private const val DRAWABLE_END = 2
class SearchEditText @JvmOverloads constructor( class SearchEditText @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = R.attr.editTextStyle, @AttrRes defStyleAttr: Int = materialR.attr.editTextStyle,
) : AppCompatEditText(context, attrs, defStyleAttr) { ) : AppCompatEditText(context, attrs, defStyleAttr) {
var searchSuggestionListener: SearchSuggestionListener? = null 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 var query: String
get() = text?.trim()?.toString().orEmpty() get() = text?.trim()?.toString().orEmpty()
@@ -57,15 +70,19 @@ class SearchEditText @JvmOverloads constructor(
lengthAfter: Int, lengthAfter: Int,
) { ) {
super.onTextChanged(text, start, lengthBefore, lengthAfter) super.onTextChanged(text, start, lengthBefore, lengthAfter)
setCompoundDrawablesRelativeWithIntrinsicBounds( val empty = text.isNullOrEmpty()
drawableStart, if (isEmpty != empty) {
null, isEmpty = empty
if (text.isNullOrEmpty()) null else clearIcon, updateActionIcon()
null, }
)
searchSuggestionListener?.onQueryChanged(query) searchSuggestionListener?.onQueryChanged(query)
} }
override fun onRestoreInstanceState(state: Parcelable?) {
super.onRestoreInstanceState(state)
updateActionIcon()
}
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean { override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) { 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) event.x.toInt() in (width - drawable.bounds.width() - paddingRight)..(width - paddingRight)
} }
if (isOnDrawable) { if (isOnDrawable) {
text?.clear() sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED)
playSoundEffect(SoundEffectConstants.CLICK)
onActionIconClick()
return true return true
} }
} }
@@ -87,4 +106,22 @@ class SearchEditText @JvmOverloads constructor(
super.clearFocus() super.clearFocus()
text?.clear() 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 package org.koitharu.kotatsu.utils.ext
import android.content.Context import android.content.Context
import android.content.pm.ResolveInfo
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityOptionsCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
val Context.connectivityManager: ConnectivityManager val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as 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 { suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
val info = getForegroundInfo() val info = getForegroundInfo()
setForeground(info) 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:id="@+id/appbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:background="@null" android:background="@null"
android:clipToPadding="false"
android:paddingLeft="16dp"
android:paddingRight="16dp" android:paddingRight="16dp"
app:elevation="0dp" app:elevation="0dp"
app:liftOnScroll="false"> app:liftOnScroll="false">

View File

@@ -23,6 +23,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@null" android:background="@null"
android:clipToPadding="false"
android:paddingLeft="16dp" android:paddingLeft="16dp"
android:paddingRight="16dp" android:paddingRight="16dp"
android:stateListAnimator="@null"> android:stateListAnimator="@null">
@@ -56,7 +57,6 @@
android:hint="@string/search_manga" android:hint="@string/search_manga"
android:imeOptions="actionSearch" android:imeOptions="actionSearch"
android:importantForAutofill="no" android:importantForAutofill="no"
android:paddingBottom="1dp"
android:singleLine="true" android:singleLine="true"
tools:drawableEnd="@drawable/abc_ic_clear_material" /> tools:drawableEnd="@drawable/abc_ic_clear_material" />

View File

@@ -39,6 +39,7 @@
<style name="Widget.Kotatsu.SearchView" parent="@style/Widget.AppCompat.SearchView"> <style name="Widget.Kotatsu.SearchView" parent="@style/Widget.AppCompat.SearchView">
<item name="iconifiedByDefault">false</item> <item name="iconifiedByDefault">false</item>
<item name="searchIcon">@null</item> <item name="searchIcon">@null</item>
<item name="hintTextAppearance">?textAppearanceBodyMedium</item>
<item name="queryBackground">@null</item> <item name="queryBackground">@null</item>
<item name="android:textColorHint">?attr/colorControlNormal</item> <item name="android:textColorHint">?attr/colorControlNormal</item>
<item name="android:textSize">18sp</item> <item name="android:textSize">18sp</item>