chat android tv adaptation

This commit is contained in:
2026-08-09 11:59:33 +03:00
parent db47d8c2ce
commit 523b164964
8 changed files with 302 additions and 164 deletions
@@ -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
}
}