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="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>
|
||||||
|
|||||||
+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.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>()
|
||||||
}
|
}
|
||||||
+1
-2
@@ -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)
|
||||||
|
|||||||
+7
@@ -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),
|
||||||
|
|||||||
+2
-1
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
-6
@@ -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
|
||||||
|
|||||||
+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 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
|
||||||
|
|||||||
+123
-104
@@ -5,84 +5,93 @@ 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(
|
|
||||||
title = { Text(stringResource(Res.string.chat_title)) },
|
|
||||||
modifier = Modifier.applyBlurEffect(blur)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = modifier
|
|
||||||
) { paddingValues ->
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize()
|
||||||
|
.padding(
|
||||||
|
PaddingValues(
|
||||||
|
start = paddingValues.calculateStartPadding(LayoutDirection.Ltr),
|
||||||
|
top = 0.dp,
|
||||||
|
end = paddingValues.calculateEndPadding(LayoutDirection.Ltr),
|
||||||
|
bottom = paddingValues.calculateBottomPadding()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(4.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
when (val currentState = state) {
|
when (state) {
|
||||||
is ChatState.Loading -> {
|
is ChatState.Loading -> {
|
||||||
Loading(Modifier.padding(paddingValues).fillMaxSize())
|
Loading(Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChatState.Error -> {
|
is ChatState.Error -> {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.padding(paddingValues)
|
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(Res.string.chat_load_error), 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))
|
||||||
@@ -91,46 +100,32 @@ fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
is ChatState.Data -> {
|
is ChatState.Data -> {
|
||||||
ChatContent(
|
ChatContent(
|
||||||
paddingValues,
|
contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()),
|
||||||
blur,
|
LocalBlurState.current,
|
||||||
state = currentState,
|
state = state,
|
||||||
onLoadMore = { component.loadMoreMessages() },
|
onParseMessage = { component.parseMessage(it) },
|
||||||
onSendMessage = { text -> component.sendMessage(text) },
|
onClickLink = { component.onLinkClicked(it) },
|
||||||
|
onSendMessage = { component.sendMessage(it) },
|
||||||
onDismissError = { component.clearSendError() }
|
onDismissError = { component.clearSendError() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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,
|
||||||
|
onClickLink = onClickLink
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
item { Spacer(Modifier.height(4.dp)) }
|
||||||
if (state.isSubsequentLoading) {
|
|
||||||
item {
|
|
||||||
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.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)
|
|
||||||
}
|
}
|
||||||
|
if (state.isSending) {
|
||||||
|
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
|
OutlinedTextField(
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = textInput,
|
value = textInput,
|
||||||
onValueChange = { textInput = it },
|
onValueChange = { textInput = it },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.focusRequester(requester).fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
|
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
|
||||||
enabled = !state.isSending
|
enabled = !state.isSending,
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
if (textInput.isNotBlank()) onSendMessage(textInput)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
|
||||||
)
|
)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user