impl chat user interface
This commit is contained in:
@@ -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="user_tags_filter_disable">Не фильтровать</string>
|
||||
<string name="check_for_updates">Поиск обновлений</string>
|
||||
<string name="chat_title_app_bar">Братан СвинGPT</string>
|
||||
<string name="chat_title">СвинGPT</string>
|
||||
</resources>
|
||||
|
||||
+27
@@ -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)) }
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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.call.body
|
||||
@@ -8,6 +8,8 @@ import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.chat.domain.ChatRepository
|
||||
import ru.shadowsparky.chat.domain.Message
|
||||
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.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
@@ -25,7 +27,7 @@ class RemoteChatRepository(
|
||||
mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString())
|
||||
}
|
||||
return httpClient.get<MessagesResponse>(
|
||||
"$DYNAMIC_PREFIX/chat/query",
|
||||
"${DYNAMIC_PREFIX}/chat/query",
|
||||
args,
|
||||
serverConfigurationRepository.getServerConfiguration()
|
||||
).content
|
||||
@@ -36,7 +38,7 @@ class RemoteChatRepository(
|
||||
setBody(message)
|
||||
serverConfigurationRepository.getServerConfiguration().apply(
|
||||
this,
|
||||
"$DYNAMIC_PREFIX/chat/send"
|
||||
"${DYNAMIC_PREFIX}/chat/send"
|
||||
)
|
||||
}.body<Message>()
|
||||
}
|
||||
+1
-2
@@ -12,7 +12,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.seiko.imageloader.LocalImageLoader
|
||||
import ru.shadowsparky.chat.presentation.ChatScreen
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.ui.components.BackButton
|
||||
import ru.shadowsparky.ui.nav.Nav
|
||||
@@ -64,10 +63,10 @@ private fun NavInternal(root: VBoxRootComponent) {
|
||||
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
|
||||
is Child.LoadingChild -> LoadingScreen(child.component)
|
||||
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
|
||||
is Child.ChatChild -> ChatScreen(child.component)
|
||||
is Child.VideosChild,
|
||||
is Child.SearchChild,
|
||||
is Child.SettingsChild,
|
||||
is Child.ChatChild,
|
||||
is Child.SavedMovieChild -> {
|
||||
if (child is Child.VideosChild && child.component.search != null) {
|
||||
VideoScreen(child.component)
|
||||
|
||||
+7
@@ -1,12 +1,14 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
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.recommendations
|
||||
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.settings
|
||||
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 org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -31,6 +33,11 @@ fun createNavigationList(): List<NavigationEntry<Route>> {
|
||||
stringResource(Res.string.search),
|
||||
painterResource(Res.drawable.search)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.Chat(),
|
||||
stringResource(Res.string.chat_title),
|
||||
painterResource(Res.drawable.svingpt)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.Settings(),
|
||||
stringResource(Res.string.settings),
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import ru.shadowsparky.ui.LocalBlurState
|
||||
import ru.shadowsparky.ui.applyBlurEffect
|
||||
import ru.shadowsparky.ui.rememberBlurState
|
||||
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.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
@@ -29,7 +30,7 @@ fun MainScreen(route: Route, child: Child, root: VBoxRootComponent) {
|
||||
child.component,
|
||||
route
|
||||
) { root.navReplace(it) }
|
||||
|
||||
is Child.ChatChild -> ChatScreen(child.component, route) { root.navReplace(it) }
|
||||
else -> MainScreenInternal(route, child) { root.navReplace(it) }
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.chat.domain.ChatRepository
|
||||
import ru.shadowsparky.chat.domain.ChatTextParser
|
||||
import ru.shadowsparky.chat.presentation.ChatComponent
|
||||
import ru.shadowsparky.ui.nav.ComponentFactory
|
||||
import ru.shadowsparky.ui.nav.RouteNavigator
|
||||
@@ -14,7 +15,8 @@ import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
|
||||
@Factory
|
||||
@Named(RouteIds.CHAT)
|
||||
class ChatComponentFactory(
|
||||
private val chatRepository: ChatRepository
|
||||
private val chatRepository: ChatRepository,
|
||||
private val textParser: ChatTextParser
|
||||
) : ComponentFactory<Child.ChatChild, Route.Chat, Route> {
|
||||
override fun create(
|
||||
route: Route.Chat,
|
||||
@@ -23,7 +25,9 @@ class ChatComponentFactory(
|
||||
): Child.ChatChild {
|
||||
val comp = ChatComponent(
|
||||
child,
|
||||
chatRepository
|
||||
chatRepository,
|
||||
textParser,
|
||||
onLinkClicked = { root.nav(Route.Videos(it)) }
|
||||
)
|
||||
return Child.ChatChild(comp)
|
||||
}
|
||||
|
||||
+39
@@ -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) }
|
||||
)
|
||||
}
|
||||
+2
-4
@@ -37,8 +37,7 @@ class SettingsComponent(
|
||||
val doRegister: () -> Unit,
|
||||
val editPassword: () -> Unit,
|
||||
val showOffline: () -> Unit,
|
||||
val showTags: () -> Unit,
|
||||
val showChat: () -> Unit
|
||||
val showTags: () -> Unit
|
||||
) : ComponentContext by context {
|
||||
val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
|
||||
|
||||
@@ -100,8 +99,7 @@ class SettingsComponentFactory(
|
||||
tokenStorage = tokenStorage,
|
||||
showOffline = { root.nav(Route.OfflineVideo()) },
|
||||
offlineSupport = offlineStorage != null,
|
||||
showTags = { root.nav(Route.Settings.TagOverview()) },
|
||||
showChat = { root.nav(Route.Chat())}
|
||||
showTags = { root.nav(Route.Settings.TagOverview()) }
|
||||
)
|
||||
return Child.SettingsChild(comp)
|
||||
}
|
||||
|
||||
-6
@@ -82,12 +82,6 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
TextPreference(
|
||||
text = "chat",
|
||||
modifier = Modifier.clickable { comp.showChat() }
|
||||
)
|
||||
}
|
||||
}
|
||||
section {
|
||||
val needAuth = tokenInfo == null
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.chat.domain
|
||||
|
||||
interface ChatTextParser {
|
||||
fun parse(content: String): List<TextToken>
|
||||
}
|
||||
+6
@@ -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
|
||||
}
|
||||
+9
-24
@@ -11,7 +11,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.shadowsparky.chat.domain.ChatRepository
|
||||
import ru.shadowsparky.chat.domain.ChatRoles
|
||||
import ru.shadowsparky.chat.domain.ChatTextParser
|
||||
import ru.shadowsparky.chat.domain.Message
|
||||
import ru.shadowsparky.chat.domain.TextToken
|
||||
|
||||
sealed interface ChatState {
|
||||
object Loading : ChatState
|
||||
@@ -21,14 +23,15 @@ sealed interface ChatState {
|
||||
data class Data(
|
||||
val messages: List<Message>,
|
||||
val isSending: Boolean = false,
|
||||
val isSubsequentLoading: Boolean = false,
|
||||
val sendError: String? = null
|
||||
) : ChatState
|
||||
}
|
||||
|
||||
class ChatComponent(
|
||||
componentContext: ComponentContext,
|
||||
private val repository: ChatRepository
|
||||
private val repository: ChatRepository,
|
||||
private val textParser: ChatTextParser?,
|
||||
val onLinkClicked: (String) -> Unit
|
||||
) : ComponentContext by componentContext {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx ->
|
||||
@@ -42,6 +45,10 @@ class ChatComponent(
|
||||
loadInitialMessages()
|
||||
}
|
||||
|
||||
fun parseMessage(content: String): List<TextToken> {
|
||||
return textParser?.parse(content) ?: listOf(TextToken.Plain(content))
|
||||
}
|
||||
|
||||
fun loadInitialMessages() {
|
||||
_state.value = ChatState.Loading
|
||||
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) {
|
||||
val currentState = _state.value as? ChatState.Data ?: return
|
||||
if (content.isBlank() || currentState.isSending) return
|
||||
|
||||
+144
-125
@@ -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_ok
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.lazy.items
|
||||
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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
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 com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.chat.domain.ChatRoles
|
||||
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.applyBlurEffect
|
||||
import ru.shadowsparky.ui.LocalBlurState
|
||||
import ru.shadowsparky.ui.applyBlurSource
|
||||
import ru.shadowsparky.ui.components.AlertContent
|
||||
import ru.shadowsparky.ui.components.LazyColumn
|
||||
import ru.shadowsparky.ui.components.Loading
|
||||
import ru.shadowsparky.ui.rememberBlurState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) {
|
||||
val state by component.state.subscribeAsState()
|
||||
val blur = rememberBlurState()
|
||||
AppScaffold(
|
||||
appBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.chat_title)) },
|
||||
modifier = Modifier.applyBlurEffect(blur)
|
||||
fun ChatContent(
|
||||
component: ChatComponent,
|
||||
paddingValues: PaddingValues,
|
||||
state: ChatState
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.padding(
|
||||
PaddingValues(
|
||||
start = paddingValues.calculateStartPadding(LayoutDirection.Ltr),
|
||||
top = 0.dp,
|
||||
end = paddingValues.calculateEndPadding(LayoutDirection.Ltr),
|
||||
bottom = paddingValues.calculateBottomPadding()
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (val currentState = state) {
|
||||
is ChatState.Loading -> {
|
||||
Loading(Modifier.padding(paddingValues).fillMaxSize())
|
||||
}
|
||||
is ChatState.Error -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
Text(text = stringResource(Res.string.chat_load_error), color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { component.loadInitialMessages() }) {
|
||||
Text(stringResource(Res.string.chat_retry_button))
|
||||
}
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (state) {
|
||||
is ChatState.Loading -> {
|
||||
Loading(Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
is ChatState.Error -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.chat_load_error),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { component.loadInitialMessages() }) {
|
||||
Text(stringResource(Res.string.chat_retry_button))
|
||||
}
|
||||
}
|
||||
is ChatState.Data -> {
|
||||
ChatContent(
|
||||
paddingValues,
|
||||
blur,
|
||||
state = currentState,
|
||||
onLoadMore = { component.loadMoreMessages() },
|
||||
onSendMessage = { text -> component.sendMessage(text) },
|
||||
onDismissError = { component.clearSendError() }
|
||||
)
|
||||
}
|
||||
}
|
||||
is ChatState.Data -> {
|
||||
ChatContent(
|
||||
contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()),
|
||||
LocalBlurState.current,
|
||||
state = state,
|
||||
onParseMessage = { component.parseMessage(it) },
|
||||
onClickLink = { component.onLinkClicked(it) },
|
||||
onSendMessage = { component.sendMessage(it) },
|
||||
onDismissError = { component.clearSendError() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,31 +115,17 @@ fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun ChatContent(
|
||||
paddingValues: PaddingValues,
|
||||
contentPadding: PaddingValues,
|
||||
blurState: BlurState?,
|
||||
state: ChatState.Data,
|
||||
onLoadMore: () -> Unit,
|
||||
onParseMessage: (String) -> List<TextToken>,
|
||||
onClickLink: (String) -> Unit,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onDismissError: () -> Unit
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
var textInput by remember { mutableStateOf("") }
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
val requester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(state.messages.size) {
|
||||
if (state.messages.isNotEmpty()) {
|
||||
@@ -138,85 +133,109 @@ private fun ChatContent(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.messages) { textInput = "" }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.applyBlurSource(blurState)
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = paddingValues,
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(items = state.messages, key = { it.id ?: it.hashCode() }) { message ->
|
||||
MessageBubble(message = message)
|
||||
}
|
||||
|
||||
if (state.isSubsequentLoading) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
MessageBubble(
|
||||
message = message,
|
||||
onParseMessage = onParseMessage,
|
||||
onClickLink = onClickLink
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
}
|
||||
|
||||
state.sendError?.let { error ->
|
||||
Snackbar(
|
||||
action = {
|
||||
TextButton(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) }
|
||||
AlertContent(
|
||||
onDismissRequest = onDismissError,
|
||||
content = {
|
||||
Button(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) }
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
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
|
||||
title = error
|
||||
)
|
||||
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
|
||||
private fun MessageBubble(message: Message) {
|
||||
private fun MessageBubble(
|
||||
message: Message,
|
||||
onParseMessage: (String) -> List<TextToken>,
|
||||
onClickLink: (String) -> Unit
|
||||
) {
|
||||
val isUser = message.role == ChatRoles.USER
|
||||
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
|
||||
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) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor),
|
||||
modifier = Modifier.widthIn(max = 280.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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user