diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt
index c9d4655b..81b02d89 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt
@@ -64,10 +64,13 @@ class ChatScreenTest {
)
}
- // Verify the send button is displayed
- composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
+ // Verify the mic button is displayed initially and send button is not
+ 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("send_button").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist()
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8b996e97..bfade3cc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
(null) }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
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
ChatLifecycleEffects(
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,
isConnected = state.isConnected,
commandCatalog = state.commandCatalog,
@@ -365,6 +469,8 @@ private fun ChatInputBar(
inputText: String,
onInputChange: (String) -> Unit,
onSend: () -> Unit,
+ onMicTap: () -> Unit,
+ isListening: Boolean,
isAgentTyping: Boolean,
isConnected: Boolean,
commandCatalog: CommandCatalog,
@@ -495,21 +601,79 @@ private fun ChatInputBar(
Spacer(modifier = Modifier.width(6.dp))
- // Send button — always visible, enabled for slash commands mid-turn
- 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),
- )
+ // Mic / Stop / Send button — swaps based on input state
+ AnimatedContent(
+ targetState =
+ when {
+ isListening -> "listening"
+ inputText.isBlank() -> "mic"
+ else -> "send"
+ },
+ transitionSpec = {
+ (scaleIn(initialScale = 0.8f) + fadeIn())
+ .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
+ },
+ label = "mic_send_toggle",
+ ) { 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),
+ )
+ }
+ }
+ }
}
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 70180a00..cd29de45 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3,6 +3,10 @@
Send
+ Voice input
+ Listening…
+ Microphone permission is required for voice input
+ Speech recognition is not available on this device
Clarification Needed
Tap an option to copy to input, or long-press to send directly.
Response