From 878df24a642a8e9ff2aacdcad482b2c6814b26ff Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 6 May 2022 10:52:51 +0300 Subject: [PATCH] Add voice search --- .../koitharu/kotatsu/main/ui/MainActivity.kt | 44 +++++++++++++- .../ui/suggestion/SearchSuggestionListener.kt | 2 + .../search/ui/widget/SearchEditText.kt | 57 +++++++++++++++---- .../kotatsu/utils/VoiceInputContract.kt | 26 +++++++++ .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 19 ++++++- app/src/main/res/drawable/ic_voice_input.xml | 9 +++ .../res/layout-w720dp-land/activity_main.xml | 3 +- app/src/main/res/layout/activity_main.xml | 2 +- app/src/main/res/values/styles.xml | 1 + 9 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt create mode 100644 app/src/main/res/drawable/ic_voice_input.xml diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 24805aa84..1d635173d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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 { + 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 { + 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 { + + override fun onActivityResult(result: String?) { + if (result != null) { + binding.searchView.query = result + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt index 9a942009b..ea9dfd6f2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt @@ -14,4 +14,6 @@ interface SearchSuggestionListener { fun onClearSearchHistory() fun onTagClick(tag: MangaTag) + + fun onVoiceSearchClick() } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt index 07e24ca16..261648fce 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt new file mode 100644 index 000000000..e95e0fb96 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt @@ -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() { + + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 733bf17d4..6f15f7cd3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -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 \ No newline at end of file +}.isSuccess + +fun ActivityResultLauncher.resolve(context: Context, input: I): ResolveInfo? { + val pm = context.packageManager + val intent = contract.createIntent(context, input) + return pm.resolveActivity(intent, 0) +} + +fun ActivityResultLauncher.tryLaunch(input: I, options: ActivityOptionsCompat? = null): Boolean { + return runCatching { + launch(input, options) + }.isSuccess +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_voice_input.xml b/app/src/main/res/drawable/ic_voice_input.xml new file mode 100644 index 000000000..ab46188aa --- /dev/null +++ b/app/src/main/res/drawable/ic_voice_input.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w720dp-land/activity_main.xml b/app/src/main/res/layout-w720dp-land/activity_main.xml index 80a34cfb5..ee362d397 100644 --- a/app/src/main/res/layout-w720dp-land/activity_main.xml +++ b/app/src/main/res/layout-w720dp-land/activity_main.xml @@ -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"> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1366af2c4..43361b15b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -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" /> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2333f5d68..a1efd039d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -39,6 +39,7 @@