impl chat user interface

This commit is contained in:
2026-07-29 18:52:20 +03:00
parent 725c78aa2d
commit ab14a86305
15 changed files with 265 additions and 167 deletions
@@ -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="#fff"
android:pathData="M668.5,428.5Q680,417 680,400Q680,383 668.5,371.5Q657,360 640,360Q623,360 611.5,371.5Q600,383 600,400Q600,417 611.5,428.5Q623,440 640,440Q657,440 668.5,428.5ZM320,360L520,360L520,280L320,280L320,360ZM180,840Q146,726 113,612.5Q80,499 80,380Q80,288 144,224Q208,160 300,160L500,160Q529,122 570.5,101Q612,80 660,80Q685,80 702.5,97.5Q720,115 720,140Q720,146 718.5,152Q717,158 715,163Q711,174 707.5,185.5Q704,197 702,209L793,300L880,300L880,579L767,616L700,840L480,840L480,760L400,760L400,840L180,840ZM240,760L320,760L320,680L560,680L560,760L640,760L702,554L800,521L800,380L760,380L620,240Q620,220 622.5,201.5Q625,183 630,164Q601,172 579,191.5Q557,211 547,240L300,240Q242,240 201,281Q160,322 160,380Q160,478 187,571.5Q214,665 240,760ZM480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462L480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462L480,462L480,462L480,462L480,462L480,462L480,462L480,462L480,462L480,462Z"/>
</vector>
@@ -68,4 +68,6 @@
<string name="movie_tags_empty">Настроить теги</string> <string name="movie_tags_empty">Настроить теги</string>
<string name="user_tags_filter_disable">Не фильтровать</string> <string name="user_tags_filter_disable">Не фильтровать</string>
<string name="check_for_updates">Поиск обновлений</string> <string name="check_for_updates">Поиск обновлений</string>
<string name="chat_title_app_bar">Братан СвинGPT</string>
<string name="chat_title">СвинGPT</string>
</resources> </resources>
@@ -0,0 +1,27 @@
package ru.shadowsparky.vbox.shared.data.chat
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatTextParser
import ru.shadowsparky.chat.domain.TextToken
@Factory
class MovieTextParser : ChatTextParser {
private val regex = Regex("\\[MOVIE\\](.*?)\\[/MOVIE\\]")
override fun parse(content: String): List<TextToken> {
val tokens = mutableListOf<TextToken>()
var lastIndex = 0
regex.findAll(content).forEach { matchResult ->
if (matchResult.range.first > lastIndex) {
tokens.add(TextToken.Plain(content.substring(lastIndex, matchResult.range.first)))
}
val movieTitle = matchResult.groupValues[1]
tokens.add(TextToken.Link(text = movieTitle))
lastIndex = matchResult.range.last + 1
}
if (lastIndex < content.length) {
tokens.add(TextToken.Plain(content.substring(lastIndex)))
}
return tokens.ifEmpty { listOf(TextToken.Plain(content)) }
}
}
@@ -1,4 +1,4 @@
package ru.shadowsparky.vbox.shared.data package ru.shadowsparky.vbox.shared.data.chat
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
@@ -8,6 +8,8 @@ import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.Message import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.chat.domain.MessagesResponse import ru.shadowsparky.chat.domain.MessagesResponse
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@@ -25,7 +27,7 @@ class RemoteChatRepository(
mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString()) mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString())
} }
return httpClient.get<MessagesResponse>( return httpClient.get<MessagesResponse>(
"$DYNAMIC_PREFIX/chat/query", "${DYNAMIC_PREFIX}/chat/query",
args, args,
serverConfigurationRepository.getServerConfiguration() serverConfigurationRepository.getServerConfiguration()
).content ).content
@@ -36,7 +38,7 @@ class RemoteChatRepository(
setBody(message) setBody(message)
serverConfigurationRepository.getServerConfiguration().apply( serverConfigurationRepository.getServerConfiguration().apply(
this, this,
"$DYNAMIC_PREFIX/chat/send" "${DYNAMIC_PREFIX}/chat/send"
) )
}.body<Message>() }.body<Message>()
} }
@@ -12,7 +12,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.seiko.imageloader.LocalImageLoader import com.seiko.imageloader.LocalImageLoader
import ru.shadowsparky.chat.presentation.ChatScreen
import ru.shadowsparky.koin import ru.shadowsparky.koin
import ru.shadowsparky.ui.components.BackButton import ru.shadowsparky.ui.components.BackButton
import ru.shadowsparky.ui.nav.Nav import ru.shadowsparky.ui.nav.Nav
@@ -64,10 +63,10 @@ private fun NavInternal(root: VBoxRootComponent) {
is Child.EditPasswordChild -> EditPasswordScreen(child.component) is Child.EditPasswordChild -> EditPasswordScreen(child.component)
is Child.LoadingChild -> LoadingScreen(child.component) is Child.LoadingChild -> LoadingScreen(child.component)
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component) is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
is Child.ChatChild -> ChatScreen(child.component)
is Child.VideosChild, is Child.VideosChild,
is Child.SearchChild, is Child.SearchChild,
is Child.SettingsChild, is Child.SettingsChild,
is Child.ChatChild,
is Child.SavedMovieChild -> { is Child.SavedMovieChild -> {
if (child is Child.VideosChild && child.component.search != null) { if (child is Child.VideosChild && child.component.search != null) {
VideoScreen(child.component) VideoScreen(child.component)
@@ -1,12 +1,14 @@
package ru.shadowsparky.vbox.shared.presentation package ru.shadowsparky.vbox.shared.presentation
import androiddev.apps.vbox.client.shared.generated.resources.Res import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.chat_title
import androiddev.apps.vbox.client.shared.generated.resources.home import androiddev.apps.vbox.client.shared.generated.resources.home
import androiddev.apps.vbox.client.shared.generated.resources.recommendations import androiddev.apps.vbox.client.shared.generated.resources.recommendations
import androiddev.apps.vbox.client.shared.generated.resources.saved_movies import androiddev.apps.vbox.client.shared.generated.resources.saved_movies
import androiddev.apps.vbox.client.shared.generated.resources.search import androiddev.apps.vbox.client.shared.generated.resources.search
import androiddev.apps.vbox.client.shared.generated.resources.settings import androiddev.apps.vbox.client.shared.generated.resources.settings
import androiddev.apps.vbox.client.shared.generated.resources.star_unchecked import androiddev.apps.vbox.client.shared.generated.resources.star_unchecked
import androiddev.apps.vbox.client.shared.generated.resources.svingpt
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -31,6 +33,11 @@ fun createNavigationList(): List<NavigationEntry<Route>> {
stringResource(Res.string.search), stringResource(Res.string.search),
painterResource(Res.drawable.search) painterResource(Res.drawable.search)
), ),
NavigationEntry(
Route.Chat(),
stringResource(Res.string.chat_title),
painterResource(Res.drawable.svingpt)
),
NavigationEntry( NavigationEntry(
Route.Settings(), Route.Settings(),
stringResource(Res.string.settings), stringResource(Res.string.settings),
@@ -14,6 +14,7 @@ import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurEffect import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.rememberBlurState import ru.shadowsparky.ui.rememberBlurState
import ru.shadowsparky.ui.theme.ComponentDefaults import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.presentation.chat.ChatScreen
import ru.shadowsparky.vbox.shared.presentation.list.VideoContent import ru.shadowsparky.vbox.shared.presentation.list.VideoContent
import ru.shadowsparky.vbox.shared.presentation.nav.Child import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route import ru.shadowsparky.vbox.shared.presentation.nav.Route
@@ -29,7 +30,7 @@ fun MainScreen(route: Route, child: Child, root: VBoxRootComponent) {
child.component, child.component,
route route
) { root.navReplace(it) } ) { root.navReplace(it) }
is Child.ChatChild -> ChatScreen(child.component, route) { root.navReplace(it) }
else -> MainScreenInternal(route, child) { root.navReplace(it) } else -> MainScreenInternal(route, child) { root.navReplace(it) }
} }
} }
@@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named import org.koin.core.annotation.Named
import ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.ChatTextParser
import ru.shadowsparky.chat.presentation.ChatComponent import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.ui.nav.ComponentFactory import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator import ru.shadowsparky.ui.nav.RouteNavigator
@@ -14,7 +15,8 @@ import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
@Factory @Factory
@Named(RouteIds.CHAT) @Named(RouteIds.CHAT)
class ChatComponentFactory( class ChatComponentFactory(
private val chatRepository: ChatRepository private val chatRepository: ChatRepository,
private val textParser: ChatTextParser
) : ComponentFactory<Child.ChatChild, Route.Chat, Route> { ) : ComponentFactory<Child.ChatChild, Route.Chat, Route> {
override fun create( override fun create(
route: Route.Chat, route: Route.Chat,
@@ -23,7 +25,9 @@ class ChatComponentFactory(
): Child.ChatChild { ): Child.ChatChild {
val comp = ChatComponent( val comp = ChatComponent(
child, child,
chatRepository chatRepository,
textParser,
onLinkClicked = { root.nav(Route.Videos(it)) }
) )
return Child.ChatChild(comp) return Child.ChatChild(comp)
} }
@@ -0,0 +1,39 @@
package ru.shadowsparky.vbox.shared.presentation.chat
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.chat_title_app_bar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.chat.presentation.ChatContent
import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.presentation.BaseMainScreen
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(component: ChatComponent, route: Route, onNavigate: (Route) -> Unit) {
val state by component.state.subscribeAsState()
BaseMainScreen(
appBar = {
TopAppBar(
title = { Text(stringResource(Res.string.chat_title_app_bar)) },
colors = ComponentDefaults.topAppBarColors(),
modifier = Modifier.applyBlurEffect(LocalBlurState.current)
)
},
route,
onNavigate = onNavigate,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
content = { ChatContent(component, it, state) }
)
}
@@ -37,8 +37,7 @@ class SettingsComponent(
val doRegister: () -> Unit, val doRegister: () -> Unit,
val editPassword: () -> Unit, val editPassword: () -> Unit,
val showOffline: () -> Unit, val showOffline: () -> Unit,
val showTags: () -> Unit, val showTags: () -> Unit
val showChat: () -> Unit
) : ComponentContext by context { ) : ComponentContext by context {
val updateComponent = updateComponentFactory.create(childContext("UpdateComponent")) val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
@@ -100,8 +99,7 @@ class SettingsComponentFactory(
tokenStorage = tokenStorage, tokenStorage = tokenStorage,
showOffline = { root.nav(Route.OfflineVideo()) }, showOffline = { root.nav(Route.OfflineVideo()) },
offlineSupport = offlineStorage != null, offlineSupport = offlineStorage != null,
showTags = { root.nav(Route.Settings.TagOverview()) }, showTags = { root.nav(Route.Settings.TagOverview()) }
showChat = { root.nav(Route.Chat())}
) )
return Child.SettingsChild(comp) return Child.SettingsChild(comp)
} }
@@ -82,12 +82,6 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
) )
} }
} }
item {
TextPreference(
text = "chat",
modifier = Modifier.clickable { comp.showChat() }
)
}
} }
section { section {
val needAuth = tokenInfo == null val needAuth = tokenInfo == null
@@ -0,0 +1,5 @@
package ru.shadowsparky.chat.domain
interface ChatTextParser {
fun parse(content: String): List<TextToken>
}
@@ -0,0 +1,6 @@
package ru.shadowsparky.chat.domain
sealed interface TextToken {
data class Plain(val text: String) : TextToken
data class Link(val text: String) : TextToken
}
@@ -11,7 +11,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.ChatRoles import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.ChatTextParser
import ru.shadowsparky.chat.domain.Message import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.chat.domain.TextToken
sealed interface ChatState { sealed interface ChatState {
object Loading : ChatState object Loading : ChatState
@@ -21,14 +23,15 @@ sealed interface ChatState {
data class Data( data class Data(
val messages: List<Message>, val messages: List<Message>,
val isSending: Boolean = false, val isSending: Boolean = false,
val isSubsequentLoading: Boolean = false,
val sendError: String? = null val sendError: String? = null
) : ChatState ) : ChatState
} }
class ChatComponent( class ChatComponent(
componentContext: ComponentContext, componentContext: ComponentContext,
private val repository: ChatRepository private val repository: ChatRepository,
private val textParser: ChatTextParser?,
val onLinkClicked: (String) -> Unit
) : ComponentContext by componentContext { ) : ComponentContext by componentContext {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx -> private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx ->
@@ -42,6 +45,10 @@ class ChatComponent(
loadInitialMessages() loadInitialMessages()
} }
fun parseMessage(content: String): List<TextToken> {
return textParser?.parse(content) ?: listOf(TextToken.Plain(content))
}
fun loadInitialMessages() { fun loadInitialMessages() {
_state.value = ChatState.Loading _state.value = ChatState.Loading
scope.launch { scope.launch {
@@ -54,28 +61,6 @@ class ChatComponent(
} }
} }
fun loadMoreMessages() {
val currentState = _state.value as? ChatState.Data ?: return
if (currentState.isSubsequentLoading || currentState.messages.isEmpty()) return
val latestMessageId = currentState.messages.last().id ?: return
_state.value = currentState.copy(isSubsequentLoading = true)
scope.launch {
try {
val newMessages = repository.query(afterId = latestMessageId)
if (newMessages.isNotEmpty()) {
_state.value = currentState.copy(
messages = currentState.messages + newMessages,
isSubsequentLoading = false
)
} else {
_state.value = currentState.copy(isSubsequentLoading = false)
}
} catch (e: Exception) {
_state.value = currentState.copy(isSubsequentLoading = false)
}
}
}
fun sendMessage(content: String) { fun sendMessage(content: String) {
val currentState = _state.value as? ChatState.Data ?: return val currentState = _state.value as? ChatState.Data ?: return
if (content.isBlank() || currentState.isSending) return if (content.isBlank() || currentState.isSending) return
@@ -5,100 +5,109 @@ import androiddev.feature.chat.chat_client.generated.resources.chat_input_placeh
import androiddev.feature.chat.chat_client.generated.resources.chat_load_error import androiddev.feature.chat.chat_client.generated.resources.chat_load_error
import androiddev.feature.chat.chat_client.generated.resources.chat_ok import androiddev.feature.chat.chat_client.generated.resources.chat_ok
import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button
import androiddev.feature.chat.chat_client.generated.resources.chat_send_button
import androiddev.feature.chat.chat_client.generated.resources.chat_title
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.chat.domain.ChatRoles import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.Message import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.ui.AppScaffold import ru.shadowsparky.chat.domain.TextToken
import ru.shadowsparky.ui.BlurState import ru.shadowsparky.ui.BlurState
import ru.shadowsparky.ui.applyBlurEffect import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurSource import ru.shadowsparky.ui.applyBlurSource
import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.LazyColumn import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.components.Loading import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.ui.rememberBlurState
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) { fun ChatContent(
val state by component.state.subscribeAsState() component: ChatComponent,
val blur = rememberBlurState() paddingValues: PaddingValues,
AppScaffold( state: ChatState
appBar = { ) {
TopAppBar( Box(
title = { Text(stringResource(Res.string.chat_title)) }, modifier = Modifier.fillMaxSize()
modifier = Modifier.applyBlurEffect(blur) .padding(
PaddingValues(
start = paddingValues.calculateStartPadding(LayoutDirection.Ltr),
top = 0.dp,
end = paddingValues.calculateEndPadding(LayoutDirection.Ltr),
bottom = paddingValues.calculateBottomPadding()
)
) )
}, .padding(4.dp),
modifier = modifier contentAlignment = Alignment.Center
) { paddingValues -> ) {
Box( when (state) {
modifier = Modifier.fillMaxSize(), is ChatState.Loading -> {
contentAlignment = Alignment.Center Loading(Modifier.fillMaxSize())
) { }
when (val currentState = state) {
is ChatState.Loading -> { is ChatState.Error -> {
Loading(Modifier.padding(paddingValues).fillMaxSize()) Column(
} horizontalAlignment = Alignment.CenterHorizontally,
is ChatState.Error -> { ) {
Column( Text(
horizontalAlignment = Alignment.CenterHorizontally, text = stringResource(Res.string.chat_load_error),
modifier = Modifier.padding(paddingValues) color = MaterialTheme.colorScheme.error
) { )
Text(text = stringResource(Res.string.chat_load_error), color = MaterialTheme.colorScheme.error) Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp)) Button(onClick = { component.loadInitialMessages() }) {
Button(onClick = { component.loadInitialMessages() }) { Text(stringResource(Res.string.chat_retry_button))
Text(stringResource(Res.string.chat_retry_button))
}
} }
} }
is ChatState.Data -> { }
ChatContent( is ChatState.Data -> {
paddingValues, ChatContent(
blur, contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()),
state = currentState, LocalBlurState.current,
onLoadMore = { component.loadMoreMessages() }, state = state,
onSendMessage = { text -> component.sendMessage(text) }, onParseMessage = { component.parseMessage(it) },
onDismissError = { component.clearSendError() } onClickLink = { component.onLinkClicked(it) },
) onSendMessage = { component.sendMessage(it) },
} onDismissError = { component.clearSendError() }
)
} }
} }
} }
@@ -106,31 +115,17 @@ fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) {
@Composable @Composable
private fun ChatContent( private fun ChatContent(
paddingValues: PaddingValues, contentPadding: PaddingValues,
blurState: BlurState?, blurState: BlurState?,
state: ChatState.Data, state: ChatState.Data,
onLoadMore: () -> Unit, onParseMessage: (String) -> List<TextToken>,
onClickLink: (String) -> Unit,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onDismissError: () -> Unit onDismissError: () -> Unit
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
var textInput by remember { mutableStateOf("") } var textInput by remember { mutableStateOf("") }
val requester = remember { FocusRequester() }
val shouldLoadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
totalItems > 0 && lastVisibleItemIndex >= totalItems - 2
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) {
onLoadMore()
}
}
LaunchedEffect(state.messages.size) { LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) { if (state.messages.isNotEmpty()) {
@@ -138,85 +133,109 @@ private fun ChatContent(
} }
} }
LaunchedEffect(state.messages) { textInput = "" }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.applyBlurSource(blurState) modifier = Modifier.applyBlurSource(blurState)
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
contentPadding = paddingValues, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(items = state.messages, key = { it.id ?: it.hashCode() }) { message -> items(items = state.messages, key = { it.id ?: it.hashCode() }) { message ->
MessageBubble(message = message) MessageBubble(
} message = message,
onParseMessage = onParseMessage,
if (state.isSubsequentLoading) { onClickLink = onClickLink
item { )
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
} }
item { Spacer(Modifier.height(4.dp)) }
} }
state.sendError?.let { error -> state.sendError?.let { error ->
Snackbar( AlertContent(
action = { onDismissRequest = onDismissError,
TextButton(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) } content = {
Button(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) }
}, },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) title = error
) {
Text(error)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
enabled = !state.isSending
) )
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (textInput.isNotBlank()) {
onSendMessage(textInput)
textInput = ""
}
},
enabled = textInput.isNotBlank() && !state.isSending
) {
if (state.isSending) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(Res.string.chat_send_button))
}
}
} }
if (state.isSending) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
OutlinedTextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.focusRequester(requester).fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
enabled = !state.isSending,
keyboardActions = KeyboardActions(
onDone = {
if (textInput.isNotBlank()) onSendMessage(textInput)
}
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
}
LaunchedEffect(null) {
withFrameNanos {}
requester.requestFocus()
} }
} }
@Composable @Composable
private fun MessageBubble(message: Message) { private fun MessageBubble(
message: Message,
onParseMessage: (String) -> List<TextToken>,
onClickLink: (String) -> Unit
) {
val isUser = message.role == ChatRoles.USER val isUser = message.role == ChatRoles.USER
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val containerColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant val containerColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
val contentColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
val tokens = remember(message.content) { onParseMessage(message.content) }
val annotatedString = buildAnnotatedString {
tokens.forEachIndexed { index, token ->
when (token) {
is TextToken.Plain -> { append(token.text) }
is TextToken.Link -> {
pushStringAnnotation(tag = "LINK_INDEX", annotation = index.toString())
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline
)
) { append(token.text) }
pop()
}
}
}
}
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = containerColor), colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor),
modifier = Modifier.widthIn(max = 280.dp) modifier = Modifier.widthIn(max = 280.dp)
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Text(text = message.content, style = MaterialTheme.typography.bodyLarge) ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodyLarge.copy(color = contentColor),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "LINK_INDEX", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
val tokenIndex = annotation.item.toIntOrNull() ?: return@ClickableText
val clickedToken = tokens.getOrNull(tokenIndex) as? TextToken.Link ?: return@ClickableText
onClickLink(clickedToken.text)
}
}
)
} }
} }
} }
} }