chat android tv adaptation
This commit is contained in:
+190
-54
@@ -7,6 +7,7 @@ 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.send
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -20,12 +21,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -37,20 +40,25 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
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.rememberCoroutineScope
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
@@ -61,6 +69,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.chat.domain.ChatRoles
|
||||
@@ -69,6 +78,9 @@ import ru.shadowsparky.chat.domain.TextToken
|
||||
import ru.shadowsparky.ui.components.AlertContent
|
||||
import ru.shadowsparky.ui.components.LazyColumn
|
||||
import ru.shadowsparky.ui.components.Loading
|
||||
import ru.shadowsparky.ui.components.TextPreference
|
||||
import ru.shadowsparky.ui.components.isTv
|
||||
import ru.shadowsparky.ui.theme.focusBorder
|
||||
|
||||
@Composable
|
||||
fun ChatContent(
|
||||
@@ -132,7 +144,6 @@ private fun ChatContent(
|
||||
val listState = rememberLazyListState()
|
||||
var textInput by remember { mutableStateOf("") }
|
||||
val textFieldFocusRequester = remember { FocusRequester() }
|
||||
val listFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(state.messages.size) {
|
||||
if (state.messages.isNotEmpty()) {
|
||||
@@ -145,18 +156,25 @@ private fun ChatContent(
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.focusRequester(listFocusRequester)
|
||||
.weight(1f)
|
||||
modifier = Modifier.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
reverseLayout = true
|
||||
) {
|
||||
items(items = state.messages, key = { it.id ?: it.hashCode() }) { message ->
|
||||
itemsIndexed(
|
||||
items = state.messages,
|
||||
key = { _, item -> item.id ?: item.hashCode() }
|
||||
) { index, message ->
|
||||
MessageBubble(
|
||||
message = message,
|
||||
onParseMessage = onParseMessage,
|
||||
onClickLink = onClickLink
|
||||
onClickLink = onClickLink,
|
||||
onKeyEvent = {
|
||||
if (index == 0) {
|
||||
if (it.key == Key.DirectionDown) textFieldFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
@@ -178,7 +196,7 @@ private fun ChatContent(
|
||||
OutlinedTextField(
|
||||
value = textInput,
|
||||
onValueChange = { textInput = it },
|
||||
modifier = Modifier.focusRequester(textFieldFocusRequester).resetFocusOnUpEvent(listFocusRequester).weight(1f),
|
||||
modifier = Modifier.focusRequester(textFieldFocusRequester).weight(1f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
|
||||
enabled = !state.isSending,
|
||||
@@ -207,73 +225,191 @@ private fun ChatContent(
|
||||
private fun MessageBubble(
|
||||
message: Message,
|
||||
onParseMessage: (String) -> List<TextToken>,
|
||||
onClickLink: (String) -> Unit
|
||||
onClickLink: (String) -> Unit,
|
||||
onKeyEvent: (KeyEvent) -> 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 linkTokens by derivedStateOf { tokens.filterIsInstance<TextToken.Link>() }
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val focusBackgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
val annotatedString = remember(tokens, primaryColor, focusBackgroundColor) {
|
||||
buildAnnotatedString {
|
||||
tokens.forEach { token ->
|
||||
when (token) {
|
||||
is TextToken.Plain -> append(token.text)
|
||||
is TextToken.Link -> {
|
||||
val linkAnnotation = LinkAnnotation.Clickable(
|
||||
tag = "LINK",
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = primaryColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
focusedStyle = SpanStyle(
|
||||
background = focusBackgroundColor,
|
||||
color = primaryColor
|
||||
)
|
||||
),
|
||||
linkInteractionListener = { onClickLink(token.text) }
|
||||
)
|
||||
withLink(linkAnnotation) { append(token.text) }
|
||||
}
|
||||
val focusedColor = MaterialTheme.colorScheme.error
|
||||
val isTv = isTv()
|
||||
val annotatedString = remember(tokens) {
|
||||
if (isTv) {
|
||||
buildAnnotatedString { append(message.content) }
|
||||
} else {
|
||||
buildText(tokens, primaryColor, focusedColor, onClickLink)
|
||||
}
|
||||
}
|
||||
var dialog by remember { mutableStateOf(false) }
|
||||
var boxModifier = Modifier.widthIn(max = 280.dp)
|
||||
if (isTv) {
|
||||
boxModifier = boxModifier.focusBorder(false).onKeyEvent {
|
||||
if (it.type == KeyEventType.KeyDown) {
|
||||
when (it.key) {
|
||||
Key.Enter -> dialog = true
|
||||
else -> onKeyEvent(it)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().focusable(),
|
||||
contentAlignment = alignment
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
containerColor = if (isUser) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (isUser) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
),
|
||||
modifier = Modifier.widthIn(max = 280.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = annotatedString,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
modifier = boxModifier
|
||||
) { ChatText(annotatedString) }
|
||||
}
|
||||
if (dialog && isTv) {
|
||||
TextDialog(message.content, linkTokens, onClickLink) { dialog = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatText(annotatedString: AnnotatedString) {
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text(
|
||||
text = annotatedString,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextDialog(
|
||||
text: String,
|
||||
links: List<TextToken.Link>,
|
||||
onClickLink: (String) -> Unit,
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
var showLinks by remember { mutableStateOf(false) }
|
||||
AlertContent(
|
||||
onDismissRequest = dismiss,
|
||||
content = {
|
||||
Column {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
if (!showLinks) {
|
||||
ScrollableMessage(focusRequester, text) {
|
||||
if (links.isNotEmpty()) {
|
||||
showLinks = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
itemsIndexed(links) { index, item ->
|
||||
val mod = Modifier.focusBorder(false)
|
||||
TextPreference(
|
||||
text = item.text,
|
||||
onClick = { onClickLink(item.text) },
|
||||
modifier = if (index == 0) {
|
||||
mod.focusRequester(focusRequester)
|
||||
} else {
|
||||
mod
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(showLinks) {
|
||||
withFrameNanos {}
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollableMessage(
|
||||
focusRequester: FocusRequester,
|
||||
text: String,
|
||||
onEnter: () -> Unit
|
||||
) {
|
||||
val scrollStep = 250f
|
||||
val scrollState = rememberScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Box(
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown) {
|
||||
when (keyEvent.key) {
|
||||
Key.DirectionDown -> {
|
||||
coroutineScope.launch { scrollState.animateScrollBy(scrollStep) }
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
coroutineScope.launch { scrollState.animateScrollBy(-scrollStep) }
|
||||
true
|
||||
}
|
||||
|
||||
Key.Enter -> {
|
||||
onEnter()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.resetFocusOnUpEvent(focusRequester: FocusRequester): Modifier {
|
||||
return onKeyEvent {
|
||||
if (it.type == KeyEventType.KeyUp) {
|
||||
if (it.key == Key.DirectionUp) {
|
||||
focusRequester.requestFocus()
|
||||
private fun buildText(
|
||||
tokens: List<TextToken>,
|
||||
primaryColor: Color,
|
||||
focusedColor: Color,
|
||||
onClickLink: (String) -> Unit
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
tokens.forEach { token ->
|
||||
when (token) {
|
||||
is TextToken.Plain -> append(token.text)
|
||||
is TextToken.Link -> {
|
||||
val linkAnnotation = LinkAnnotation.Clickable(
|
||||
tag = "LINK",
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = primaryColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
focusedStyle = SpanStyle(
|
||||
color = focusedColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
),
|
||||
linkInteractionListener = { onClickLink(token.text) }
|
||||
)
|
||||
withLink(linkAnnotation) { append(token.text) }
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user