diff --git a/apps/vbox/client/shared/src/commonMain/composeResources/drawable/svingpt.xml b/apps/vbox/client/shared/src/commonMain/composeResources/drawable/svingpt.xml
new file mode 100644
index 0000000..24b2607
--- /dev/null
+++ b/apps/vbox/client/shared/src/commonMain/composeResources/drawable/svingpt.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml b/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml
index 463d264..7007660 100644
--- a/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml
+++ b/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml
@@ -68,4 +68,6 @@
Настроить теги
Не фильтровать
Поиск обновлений
+ Братан СвинGPT
+ СвинGPT
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/MovieTextParser.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/MovieTextParser.kt
new file mode 100644
index 0000000..559d59b
--- /dev/null
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/MovieTextParser.kt
@@ -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 {
+ val tokens = mutableListOf()
+ 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)) }
+ }
+}
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteChatRepository.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/RemoteChatRepository.kt
similarity index 85%
rename from apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteChatRepository.kt
rename to apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/RemoteChatRepository.kt
index 2569da7..84921e3 100644
--- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteChatRepository.kt
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/RemoteChatRepository.kt
@@ -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(
- "$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()
}
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt
index 3252037..f99972a 100644
--- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt
@@ -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)
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt
index 6599f45..d6a4917 100644
--- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt
@@ -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> {
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),
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt
index fcfbce0..2efceaf 100644
--- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt
@@ -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) }
}
}
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt
index cfbe741..f289cbf 100644
--- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt
@@ -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 {
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)
}
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt
new file mode 100644
index 0000000..008d7e7
--- /dev/null
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt
@@ -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) }
+ )
+}
\ No newline at end of file
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt
index 72467b9..011e40b 100644
--- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt
@@ -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)
}
diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt
index 4d9f100..97b48d6 100644
--- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt
+++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt
@@ -82,12 +82,6 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
)
}
}
- item {
- TextPreference(
- text = "chat",
- modifier = Modifier.clickable { comp.showChat() }
- )
- }
}
section {
val needAuth = tokenInfo == null
diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatTextParser.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatTextParser.kt
new file mode 100644
index 0000000..1d818c8
--- /dev/null
+++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatTextParser.kt
@@ -0,0 +1,5 @@
+package ru.shadowsparky.chat.domain
+
+interface ChatTextParser {
+ fun parse(content: String): List
+}
diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/TextToken.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/TextToken.kt
new file mode 100644
index 0000000..e8dfedd
--- /dev/null
+++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/TextToken.kt
@@ -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
+}
diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt
index cd6aac2..16b6618 100644
--- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt
+++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt
@@ -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,
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 {
+ 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
diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt
index 555e797..867e659 100644
--- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt
+++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt
@@ -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,
+ 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,
+ 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)
+ }
+ }
+ )
}
}
}
}
+