feat(#194): add Speech-to-Text voice dictation support in chat (#413)

* 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:
M57 authored and GitHub committed 2026-06-28 00:47:24 +03:00
1 parent a091d63987
commit 42a67bffa9
4 files changed
+190 -18

No files matched your search

@@ -1,8 +1,15 @@
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.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
@@ -17,6 +24,7 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
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.KeyboardArrowDown
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.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -132,11 +143,71 @@ fun ChatScreen(
}
val scrollScope = rememberCoroutineScope()
var inputText by rememberSaveable { mutableStateOf("") }
var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(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),
)
}
}
}
}
}
}