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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+