* feat(#194): add Speech-to-Text voice dictation support in chat * fix(#194): resolve android lint warnings and update compose UI test * fix(#194): remove invalid assertDoesNotExist import
This commit is contained in:
4 files changed
+190
-18
No files matched your search
@@ -64,10 +64,13 @@ class ChatScreenTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the send button is displayed
|
// Verify the mic button is displayed initially and send button is not
|
||||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
|
||||||
|
|
||||||
// Verify the input field accepts text
|
// Verify entering text switches mic button to send button
|
||||||
composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes")
|
composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes")
|
||||||
|
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HermesControlApp"
|
android:name=".HermesControlApp"
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
@@ -17,6 +24,7 @@ import androidx.compose.animation.scaleIn
|
|||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -53,8 +61,11 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -132,11 +143,71 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
var inputText by rememberSaveable { mutableStateOf("") }
|
var inputText by rememberSaveable { mutableStateOf("") }
|
||||||
|
var isListening by rememberSaveable { mutableStateOf(false) }
|
||||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
||||||
|
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||||
|
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
||||||
|
|
||||||
|
// Speech-to-text recognition launcher (issue #194)
|
||||||
|
val speechLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
isListening = false
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
val spokenText =
|
||||||
|
result.data
|
||||||
|
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||||
|
?.firstOrNull()
|
||||||
|
.orEmpty()
|
||||||
|
if (spokenText.isNotBlank()) {
|
||||||
|
inputText =
|
||||||
|
if (inputText.isBlank()) {
|
||||||
|
spokenText
|
||||||
|
} else {
|
||||||
|
"$inputText $spokenText"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mic permission launcher
|
||||||
|
val micPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||||
|
val intent =
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
||||||
|
)
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_PROMPT,
|
||||||
|
micListeningPrompt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isListening = true
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
} else {
|
||||||
|
scrollScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(sttPermissionDeniedMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||||
ChatLifecycleEffects(
|
ChatLifecycleEffects(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -294,6 +365,39 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onMicTap = {
|
||||||
|
if (isListening) {
|
||||||
|
isListening = false
|
||||||
|
} else if (
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||||
|
val intent =
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
||||||
|
)
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_PROMPT,
|
||||||
|
micListeningPrompt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isListening = true
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
} else {
|
||||||
|
scrollScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isListening = isListening,
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
@@ -365,6 +469,8 @@ private fun ChatInputBar(
|
|||||||
inputText: String,
|
inputText: String,
|
||||||
onInputChange: (String) -> Unit,
|
onInputChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
|
onMicTap: () -> Unit,
|
||||||
|
isListening: Boolean,
|
||||||
isAgentTyping: Boolean,
|
isAgentTyping: Boolean,
|
||||||
isConnected: Boolean,
|
isConnected: Boolean,
|
||||||
commandCatalog: CommandCatalog,
|
commandCatalog: CommandCatalog,
|
||||||
@@ -495,21 +601,79 @@ private fun ChatInputBar(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
|
||||||
// Send button — always visible, enabled for slash commands mid-turn
|
// Mic / Stop / Send button — swaps based on input state
|
||||||
FilledTonalButton(
|
AnimatedContent(
|
||||||
onClick = onSend,
|
targetState =
|
||||||
enabled = canSend,
|
when {
|
||||||
modifier =
|
isListening -> "listening"
|
||||||
Modifier
|
inputText.isBlank() -> "mic"
|
||||||
.size(40.dp)
|
else -> "send"
|
||||||
.testTag("send_button"),
|
},
|
||||||
shape = CircleShape,
|
transitionSpec = {
|
||||||
contentPadding = PaddingValues(0.dp),
|
(scaleIn(initialScale = 0.8f) + fadeIn())
|
||||||
) {
|
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
|
||||||
Icon(
|
},
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
label = "mic_send_toggle",
|
||||||
contentDescription = stringResource(R.string.chat_send_desc),
|
) { buttonState ->
|
||||||
)
|
when (buttonState) {
|
||||||
|
"mic" -> {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = onMicTap,
|
||||||
|
enabled = isConnected,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.testTag("mic_button"),
|
||||||
|
shape = CircleShape,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Mic,
|
||||||
|
contentDescription = stringResource(R.string.chat_mic_desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"listening" -> {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = onMicTap,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.testTag("mic_stop_button"),
|
||||||
|
shape = CircleShape,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Stop,
|
||||||
|
contentDescription = "Stop listening",
|
||||||
|
tint = MaterialTheme.colorScheme.onError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = onSend,
|
||||||
|
enabled = canSend,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.testTag("send_button"),
|
||||||
|
shape = CircleShape,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = stringResource(R.string.chat_send_desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
<string name="chat_send_desc">Send</string>
|
<string name="chat_send_desc">Send</string>
|
||||||
|
<string name="chat_mic_desc">Voice input</string>
|
||||||
|
<string name="chat_mic_listening">Listening…</string>
|
||||||
|
<string name="stt_permission_denied">Microphone permission is required for voice input</string>
|
||||||
|
<string name="stt_not_available">Speech recognition is not available on this device</string>
|
||||||
<string name="chat_clarify_title">Clarification Needed</string>
|
<string name="chat_clarify_title">Clarification Needed</string>
|
||||||
<string name="chat_clarify_hint">Tap an option to copy to input, or long-press to send directly.</string>
|
<string name="chat_clarify_hint">Tap an option to copy to input, or long-press to send directly.</string>
|
||||||
<string name="chat_clarify_response">Response</string>
|
<string name="chat_clarify_response">Response</string>
|
||||||
|
|||||||
Reference in new issue
Block a user