diff --git a/apps/giga-wear/client/src/main/AndroidManifest.xml b/apps/giga-wear/client/src/main/AndroidManifest.xml index 6e6921e..7a39263 100644 --- a/apps/giga-wear/client/src/main/AndroidManifest.xml +++ b/apps/giga-wear/client/src/main/AndroidManifest.xml @@ -36,6 +36,9 @@ + diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatScreen.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatScreen.kt index 4a720c2..cb29b0c 100644 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatScreen.kt +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatScreen.kt @@ -1,10 +1,6 @@ package ru.shadowsparky.gigawear.presentation.chat import android.app.Activity -import android.content.Intent -import android.speech.RecognizerIntent -import android.speech.tts.TextToSpeech -import android.speech.tts.UtteranceProgressListener import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -17,9 +13,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -41,64 +35,23 @@ import kotlinx.coroutines.flow.collectLatest import ru.shadowsparky.gigawear.R import ru.shadowsparky.gigawear.presentation.WearErrorContent import ru.shadowsparky.updater.presentation.UpdateDialog -import java.util.Locale @Composable fun ChatScreen(component: ChatComponent) { val context = LocalContext.current val state by component.state.collectAsState() - - var tts by remember { - mutableStateOf(null) + val speechManager = remember { SpeechManager(context) } + val isSpeaking by speechManager.isSpeaking.collectAsState() + DisposableEffect(speechManager) { + onDispose { speechManager.release() } } - - var isSpeaking by remember { - mutableStateOf(false) - } - - DisposableEffect(context) { - val ttsInstance = TextToSpeech(context) { status -> - if (status == TextToSpeech.SUCCESS) { - tts?.language = Locale.getDefault() - tts?.setOnUtteranceProgressListener( - object : UtteranceProgressListener() { - override fun onStart(utteranceId: String?) { isSpeaking = true } - override fun onDone(utteranceId: String?) { isSpeaking = false } - @Deprecated("Deprecated in Java") - override fun onError(utteranceId: String?) { isSpeaking = false } - override fun onError(utteranceId: String?, errorCode: Int) { - super.onError(utteranceId, errorCode) - isSpeaking = false - } - override fun onStop( - utteranceId: String?, - interrupted: Boolean - ) { isSpeaking = false } - } - ) - } - } - tts = ttsInstance - onDispose { - ttsInstance.stop() - ttsInstance.shutdown() - tts = null - isSpeaking = false - } - } - val speechLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode != Activity.RESULT_OK) { return@rememberLauncherForActivityResult } - - val spokenText = result.data - ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) - ?.firstOrNull() - .orEmpty() - + val spokenText = speechManager.extractRecognizedText(result.data) if (spokenText.isNotEmpty()) { component.onSpeechRecognized(spokenText) } @@ -107,30 +60,12 @@ fun ChatScreen(component: ChatComponent) { LaunchedEffect(component) { component.events.collectLatest { event -> when (event) { - is ChatComponent.ComponentEvent.RequestSpeechRecognition -> { - val intent = Intent( - RecognizerIntent.ACTION_RECOGNIZE_SPEECH - ).apply { - putExtra( - RecognizerIntent.EXTRA_LANGUAGE_MODEL, - RecognizerIntent.LANGUAGE_MODEL_FREE_FORM - ) - putExtra( - RecognizerIntent.EXTRA_LANGUAGE, - Locale.getDefault() - ) - } - - speechLauncher.launch(intent) + ChatComponent.ComponentEvent.RequestSpeechRecognition -> { + speechLauncher.launch(speechManager.createRecognitionIntent()) } is ChatComponent.ComponentEvent.RequestSpeak -> { - tts?.speak( - event.text, - TextToSpeech.QUEUE_FLUSH, - null, - null - ) + speechManager.speak(event.text) } } } @@ -150,8 +85,7 @@ fun ChatScreen(component: ChatComponent) { EdgeButton( onClick = { if (isSpeaking) { - tts?.stop() - isSpeaking = false + speechManager.stop() } else { component.onMicClicked() } @@ -191,10 +125,10 @@ fun ChatScreen(component: ChatComponent) { ) } - state.assistantText?.let { + state.assistantText?.let { text -> item { Text( - text = it, + text = text, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Start, modifier = Modifier.padding(horizontal = 20.dp) @@ -203,14 +137,12 @@ fun ChatScreen(component: ChatComponent) { } if (state.isLoading) { - item { - CircularProgressIndicator() - } + item { CircularProgressIndicator() } } - state.error?.let { + state.error?.let { error -> item { - WearErrorContent(it.message ?: it.toString()) + WearErrorContent(error.message ?: error.toString()) } } } diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/SpeechManager.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/SpeechManager.kt new file mode 100644 index 0000000..1f40157 --- /dev/null +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/SpeechManager.kt @@ -0,0 +1,93 @@ +package ru.shadowsparky.gigawear.presentation.chat + +import android.content.Context +import android.content.Intent +import android.speech.RecognizerIntent +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale + +class SpeechManager(context: Context) { + private val _isSpeaking = MutableStateFlow(false) + val isSpeaking = _isSpeaking.asStateFlow() + + private var tts: TextToSpeech? = null + + init { + tts = TextToSpeech(context.applicationContext) { status -> + if (status == TextToSpeech.SUCCESS) { configureTts() } + } + } + + fun createRecognitionIntent(): Intent { + return Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM + ) + putExtra( + RecognizerIntent.EXTRA_LANGUAGE, + Locale.getDefault() + ) + } + } + + fun extractRecognizedText(intent: Intent?): String { + return intent + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + .orEmpty() + } + + fun speak(text: String) { + tts?.speak( + text, + TextToSpeech.QUEUE_FLUSH, + null, + UTTERANCE_ID + ) + } + + fun stop() { + tts?.stop() + resetSpeaking() + } + + fun release() { + stop() + + tts?.shutdown() + tts = null + } + + private fun configureTts() { + tts?.apply { + language = Locale.getDefault() + setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) = setSpeaking(true) + override fun onDone(utteranceId: String?) = resetSpeaking() + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) = resetSpeaking() + override fun onError(utteranceId: String?, errorCode: Int) = resetSpeaking() + override fun onStop(utteranceId: String?, interrupted: Boolean) = resetSpeaking() + } + ) + } + } + + private fun resetSpeaking() = setSpeaking(false) + + private fun setSpeaking(value: Boolean) { + _isSpeaking.value = value + Log.d(TAG, "setSpeaking $value") + } + + private companion object { + const val TAG = "SpeechManager" + const val UTTERANCE_ID = "chat_speech" + } +} diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/routing/RoutingComponent.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/routing/RoutingComponent.kt index 47a9493..a7eb7f8 100644 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/routing/RoutingComponent.kt +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/routing/RoutingComponent.kt @@ -2,6 +2,7 @@ package ru.shadowsparky.gigawear.presentation.routing import com.arkivanov.decompose.ComponentContext import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import org.koin.core.annotation.Factory @@ -28,6 +29,7 @@ class RoutingComponent( healthCheck.healthCheck() navigateTo(Route.ChatScreen()) }.onFailure { + ensureActive() tokenStorage.clear() navigateTo(Route.AuthScreen()) } diff --git a/apps/giga-wear/client/src/main/res/raw/watchface.xml b/apps/giga-wear/client/src/main/res/raw/watchface.xml index 9f7656a..062f7dd 100644 --- a/apps/giga-wear/client/src/main/res/raw/watchface.xml +++ b/apps/giga-wear/client/src/main/res/raw/watchface.xml @@ -423,6 +423,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +