impl chat screen in giga wear

This commit is contained in:
2026-09-13 10:46:52 +03:00
parent 08a7c008a1
commit 688d687d4d
14 changed files with 385 additions and 55 deletions
@@ -25,6 +25,6 @@ fun Application.module() {
install(ContentNegotiation) { json(koin.get()) } install(ContentNegotiation) { json(koin.get()) }
routing { routing {
get("health") { this.call.respond(HttpStatusCode.OK) } get("health") { this.call.respond(HttpStatusCode.OK) }
setupChat(koin.get(), koin.get()) setupChat(koin.get())
} }
} }
@@ -29,7 +29,7 @@ class MessageStorageImpl : MessageStorage {
} else { } else {
userMessages userMessages
} }
return filteredList.take(limit).toList() return filteredList.reversed().take(limit).sortedBy { it.timestamp }
} }
} }
@@ -10,7 +10,7 @@ import ru.shadowsparky.ui.nav.RootComponent
class GigaWearRootComponent( class GigaWearRootComponent(
context: ComponentContext context: ComponentContext
) : RootComponent<Route>(context) { ) : RootComponent<Route>(context) {
override val initStack: List<Route> = listOf(Route.MainScreen()) override val initStack: List<Route> = listOf(Route.ChatScreen())
override val serializer: KSerializer<Route> = Route.serializer() override val serializer: KSerializer<Route> = Route.serializer()
override val koin: Koin = ru.shadowsparky.koin override val koin: Koin = ru.shadowsparky.koin
} }
@@ -5,7 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import com.arkivanov.decompose.retainedComponent import com.arkivanov.decompose.retainedComponent
import org.koin.android.ext.android.inject 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.gigawear.presentation.nav.Child
import ru.shadowsparky.ui.nav.Nav import ru.shadowsparky.ui.nav.Nav
@@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
Nav(root) { child, _ -> Nav(root) { child, _ ->
when (child) { when (child) {
is Child.MainChild -> MainScreen(child.component) is Child.MainChild -> ChatScreen(child.component)
} }
} }
} }
@@ -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
)
}
}
@@ -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<ChatUiState> = _state
private val _events = Channel<ComponentEvent>(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<Child, Route.ChatScreen, Route> {
override fun create(
route: Route.ChatScreen,
child: ComponentContext,
root: RouteNavigator<Route>
): Child {
val component = ChatComponent(child, chatRepository)
return Child.MainChild(component)
}
}
@@ -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<TextToSpeech?>(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())
}
}
}
}
}
}
@@ -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<Child, Route.MainScreen, Route> {
override fun create(
route: Route.MainScreen,
child: ComponentContext,
root: RouteNavigator<Route>
): Child {
val component = MainComponent(child)
return Child.MainChild(component)
}
}
@@ -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("Тут пока что ничего нет. Совсем ничего. Приложение в активной разработке")
}
}
@@ -1,7 +1,7 @@
package ru.shadowsparky.gigawear.presentation.nav 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 { sealed class Child : ru.shadowsparky.ui.nav.Child {
class MainChild(val component: MainComponent) : Child() class MainChild(val component: ChatComponent) : Child()
} }
@@ -6,9 +6,9 @@ import kotlinx.serialization.Serializable
sealed class Route : ru.shadowsparky.ui.nav.Route() { sealed class Route : ru.shadowsparky.ui.nav.Route() {
@Serializable @Serializable
data class MainScreen(override val routeId: String = MAIN) : Route() data class ChatScreen(override val routeId: String = CHAT) : Route()
companion object { companion object {
const val MAIN = "main" const val CHAT = "chat"
} }
} }
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M395,525Q360,490 360,440L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L600,440Q600,490 565,525Q530,560 480,560Q430,560 395,525ZM480,320Q480,320 480,320L480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320L480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM440,840L440,717Q336,703 268,624Q200,545 200,440L280,440Q280,523 338.5,581.5Q397,640 480,640Q563,640 621.5,581.5Q680,523 680,440L760,440Q760,545 692,624Q624,703 520,717L520,840L440,840ZM508.5,468.5Q520,457 520,440L520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200L440,440Q440,457 451.5,468.5Q463,480 480,480Q497,480 508.5,468.5Z"/>
</vector>
@@ -1,6 +1,5 @@
<resources> <resources>
<string name="app_name">GigaWear</string> <string name="app_name">Свин Wear</string>
<string name="hello_world">Hello, %1$s!</string> <string name="input_text_to_assist">Введите запрос к ассистенту</string>
<string name="tile_label">Example tile</string> <string name="error_title">Произошла ошибка</string>
<string name="complication_label">Example complication</string> </resources>
</resources>