From 688d687d4d35df4ffc1dd9608b4eab2a454e2c3b Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 13 Sep 2026 10:46:52 +0300 Subject: [PATCH] impl chat screen in giga wear --- .../ru/shadowsparky/gigawear/backend/Main.kt | 2 +- .../backend/data/MessageStorageImpl.kt | 2 +- .../presentation/GigaWearRootComponent.kt | 2 +- .../gigawear/presentation/MainActivity.kt | 4 +- .../gigawear/presentation/WearErrorContent.kt | 47 ++++ .../presentation/chat/ChatComponent.kt | 88 +++++++ .../gigawear/presentation/chat/ChatScreen.kt | 217 ++++++++++++++++++ .../presentation/main/MainComponent.kt | 27 --- .../gigawear/presentation/main/MainScreen.kt | 14 -- .../gigawear/presentation/nav/Child.kt | 4 +- .../gigawear/presentation/nav/Route.kt | 4 +- .../src/main/res/drawable/cancel_24px.xml | 10 + .../client/src/main/res/drawable/mic_24px.xml | 10 + .../client/src/main/res/values/strings.xml | 9 +- 14 files changed, 385 insertions(+), 55 deletions(-) create mode 100644 apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/WearErrorContent.kt create mode 100644 apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatComponent.kt create mode 100644 apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatScreen.kt delete mode 100644 apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainComponent.kt delete mode 100644 apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainScreen.kt create mode 100644 apps/giga-wear/client/src/main/res/drawable/cancel_24px.xml create mode 100644 apps/giga-wear/client/src/main/res/drawable/mic_24px.xml diff --git a/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/Main.kt b/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/Main.kt index d6fc4a5..dd96803 100644 --- a/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/Main.kt +++ b/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/Main.kt @@ -25,6 +25,6 @@ fun Application.module() { install(ContentNegotiation) { json(koin.get()) } routing { get("health") { this.call.respond(HttpStatusCode.OK) } - setupChat(koin.get(), koin.get()) + setupChat(koin.get()) } } diff --git a/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/data/MessageStorageImpl.kt b/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/data/MessageStorageImpl.kt index 37ca3d2..4f33746 100644 --- a/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/data/MessageStorageImpl.kt +++ b/apps/giga-wear/backend/src/main/kotlin/ru/shadowsparky/gigawear/backend/data/MessageStorageImpl.kt @@ -29,7 +29,7 @@ class MessageStorageImpl : MessageStorage { } else { userMessages } - return filteredList.take(limit).toList() + return filteredList.reversed().take(limit).sortedBy { it.timestamp } } } diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/GigaWearRootComponent.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/GigaWearRootComponent.kt index acd0465..b326531 100644 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/GigaWearRootComponent.kt +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/GigaWearRootComponent.kt @@ -10,7 +10,7 @@ import ru.shadowsparky.ui.nav.RootComponent class GigaWearRootComponent( context: ComponentContext ) : RootComponent(context) { - override val initStack: List = listOf(Route.MainScreen()) + override val initStack: List = listOf(Route.ChatScreen()) override val serializer: KSerializer = Route.serializer() override val koin: Koin = ru.shadowsparky.koin } diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/MainActivity.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/MainActivity.kt index 0a5ac91..71454fb 100644 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/MainActivity.kt +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/MainActivity.kt @@ -5,7 +5,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import com.arkivanov.decompose.retainedComponent import org.koin.android.ext.android.inject -import ru.shadowsparky.gigawear.presentation.main.MainScreen +import ru.shadowsparky.gigawear.presentation.chat.ChatScreen import ru.shadowsparky.gigawear.presentation.nav.Child import ru.shadowsparky.ui.nav.Nav @@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() { setContent { Nav(root) { child, _ -> when (child) { - is Child.MainChild -> MainScreen(child.component) + is Child.MainChild -> ChatScreen(child.component) } } } diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/WearErrorContent.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/WearErrorContent.kt new file mode 100644 index 0000000..5f9f165 --- /dev/null +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/WearErrorContent.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.gigawear.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import ru.shadowsparky.gigawear.R + +@Composable +fun WearErrorContent( + message: String, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Text( + text = stringResource(R.string.error_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + } +} diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatComponent.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatComponent.kt new file mode 100644 index 0000000..c9ea45b --- /dev/null +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatComponent.kt @@ -0,0 +1,88 @@ +package ru.shadowsparky.gigawear.presentation.chat + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update +import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.chat.domain.ChatRepository +import ru.shadowsparky.chat.domain.ChatRoles +import ru.shadowsparky.chat.domain.Message +import ru.shadowsparky.gigawear.presentation.nav.Child +import ru.shadowsparky.gigawear.presentation.nav.Route +import ru.shadowsparky.ui.nav.ComponentFactory +import ru.shadowsparky.ui.nav.RouteNavigator + +class ChatComponent( + private val context: ComponentContext, + private val repository: ChatRepository +) : ComponentContext by context { + private val scope = coroutineScope() + + private val _state = MutableValue(ChatUiState()) + val state: Value = _state + + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + fun onMicClicked() { + if (_state.value.isLoading) return + scope.launch { + _events.send(ComponentEvent.RequestSpeechRecognition) + } + } + + fun onSpeechRecognized(text: String) { + _state.value = _state.value.copy(userText = text, isLoading = true, error = null) + scope.launch { + try { + val userMessage = Message( + role = ChatRoles.USER, + content = text, + timestamp = System.currentTimeMillis() + ) + val responseMessage = repository.send(userMessage) + _state.value = _state.value.copy( + assistantText = responseMessage.content, + isLoading = false + ) + _events.send(ComponentEvent.RequestSpeak(responseMessage.content)) + } catch (e: Exception) { + _state.update { it.copy(error = e, isLoading = false) } + } + } + } + + sealed interface ComponentEvent { + object RequestSpeechRecognition : ComponentEvent + data class RequestSpeak(val text: String) : ComponentEvent + } + + data class ChatUiState( + val userText: String? = null, + val assistantText: String? = null, + val isLoading: Boolean = false, + val error: Throwable? = null + ) +} + + +@Factory +@Named(Route.CHAT) +class ChatComponentFactory( + private val chatRepository: ChatRepository +) : ComponentFactory { + override fun create( + route: Route.ChatScreen, + child: ComponentContext, + root: RouteNavigator + ): Child { + val component = ChatComponent(child, chatRepository) + return Child.MainChild(component) + } +} 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 new file mode 100644 index 0000000..d2eb0bf --- /dev/null +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/chat/ChatScreen.kt @@ -0,0 +1,217 @@ +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 +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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 +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.ScrollInfoProvider +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.EdgeButton +import androidx.wear.compose.material3.EdgeButtonSize +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.ScrollIndicator +import androidx.wear.compose.material3.Text +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import kotlinx.coroutines.flow.collectLatest +import ru.shadowsparky.gigawear.R +import ru.shadowsparky.gigawear.presentation.WearErrorContent +import java.util.Locale + +@Composable +fun ChatScreen(component: ChatComponent) { + val context = LocalContext.current + val state by component.state.subscribeAsState() + + var tts by remember { + mutableStateOf(null) + } + + 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() + + if (spokenText.isNotEmpty()) { + component.onSpeechRecognized(spokenText) + } + } + + 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) + } + + is ChatComponent.ComponentEvent.RequestSpeak -> { + tts?.speak( + event.text, + TextToSpeech.QUEUE_FLUSH, + null, + null + ) + } + } + } + } + + val listState = rememberLazyListState() + + val scrollInfoProvider = remember(listState) { + ScrollInfoProvider(listState) + } + + AppScaffold { + ScreenScaffold( + scrollInfoProvider = scrollInfoProvider, + scrollIndicator = { ScrollIndicator(listState) }, + edgeButton = { + EdgeButton( + onClick = { + if (isSpeaking) { + tts?.stop() + isSpeaking = false + } else { + component.onMicClicked() + } + }, + enabled = isSpeaking || !state.isLoading, + buttonSize = EdgeButtonSize.Small + ) { + Icon( + painter = painterResource( + if (isSpeaking) { + R.drawable.cancel_24px + } else { + R.drawable.mic_24px + } + ), + contentDescription = null + ) + } + } + ) { contentPadding -> + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + val text = state.userText ?: stringResource(R.string.input_text_to_assist) + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + + state.assistantText?.let { + item { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.padding(horizontal = 20.dp) + ) + } + } + + if (state.isLoading) { + item { + CircularProgressIndicator() + } + } + + state.error?.let { + item { + WearErrorContent(it.message ?: it.toString()) + } + } + } + } + } +} diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainComponent.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainComponent.kt deleted file mode 100644 index d295ae6..0000000 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainComponent.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ru.shadowsparky.gigawear.presentation.main - -import com.arkivanov.decompose.ComponentContext -import org.koin.core.annotation.Factory -import org.koin.core.annotation.Named -import ru.shadowsparky.gigawear.presentation.nav.Child -import ru.shadowsparky.gigawear.presentation.nav.Route -import ru.shadowsparky.ui.nav.ComponentFactory -import ru.shadowsparky.ui.nav.RouteNavigator - -class MainComponent( - private val context: ComponentContext -) : ComponentContext by context { -} - -@Factory -@Named(Route.MAIN) -class MainComponentFactory : ComponentFactory { - override fun create( - route: Route.MainScreen, - child: ComponentContext, - root: RouteNavigator - ): Child { - val component = MainComponent(child) - return Child.MainChild(component) - } -} diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainScreen.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainScreen.kt deleted file mode 100644 index 9727481..0000000 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/main/MainScreen.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ru.shadowsparky.gigawear.presentation.main - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun MainScreen(component: MainComponent) { - Box(modifier = Modifier.safeContentPadding()) { - Text("Тут пока что ничего нет. Совсем ничего. Приложение в активной разработке") - } -} diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Child.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Child.kt index 7a8132b..c565958 100644 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Child.kt +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Child.kt @@ -1,7 +1,7 @@ package ru.shadowsparky.gigawear.presentation.nav -import ru.shadowsparky.gigawear.presentation.main.MainComponent +import ru.shadowsparky.gigawear.presentation.chat.ChatComponent sealed class Child : ru.shadowsparky.ui.nav.Child { - class MainChild(val component: MainComponent) : Child() + class MainChild(val component: ChatComponent) : Child() } diff --git a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Route.kt b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Route.kt index 19c0f3e..c60183c 100644 --- a/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Route.kt +++ b/apps/giga-wear/client/src/main/kotlin/ru/shadowsparky/gigawear/presentation/nav/Route.kt @@ -6,9 +6,9 @@ import kotlinx.serialization.Serializable sealed class Route : ru.shadowsparky.ui.nav.Route() { @Serializable - data class MainScreen(override val routeId: String = MAIN) : Route() + data class ChatScreen(override val routeId: String = CHAT) : Route() companion object { - const val MAIN = "main" + const val CHAT = "chat" } } diff --git a/apps/giga-wear/client/src/main/res/drawable/cancel_24px.xml b/apps/giga-wear/client/src/main/res/drawable/cancel_24px.xml new file mode 100644 index 0000000..ea3a291 --- /dev/null +++ b/apps/giga-wear/client/src/main/res/drawable/cancel_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/giga-wear/client/src/main/res/drawable/mic_24px.xml b/apps/giga-wear/client/src/main/res/drawable/mic_24px.xml new file mode 100644 index 0000000..7d2e099 --- /dev/null +++ b/apps/giga-wear/client/src/main/res/drawable/mic_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/giga-wear/client/src/main/res/values/strings.xml b/apps/giga-wear/client/src/main/res/values/strings.xml index ca7359e..11e8683 100644 --- a/apps/giga-wear/client/src/main/res/values/strings.xml +++ b/apps/giga-wear/client/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ - GigaWear - Hello, %1$s! - Example tile - Example complication - \ No newline at end of file + Свин Wear + Введите запрос к ассистенту + Произошла ошибка +