Completely redesign the chat UI

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-16 00:06:54 +03:00
Unverified
parent ecfb97907a
commit ef840fbffe
5 changed files with 804 additions and 451 deletions
@@ -60,6 +60,7 @@
<string name="cd_pick_image">Выбрать фото</string> <string name="cd_pick_image">Выбрать фото</string>
<string name="cd_pick_file">Выбрать файл</string> <string name="cd_pick_file">Выбрать файл</string>
<string name="cd_send">Отправить</string> <string name="cd_send">Отправить</string>
<string name="cd_emoji">Эмодзи</string>
<string name="profile_title">Профиль</string> <string name="profile_title">Профиль</string>
<string name="profile_load_failed">Не получилось загрузить профиль</string> <string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string> <string name="profile_not_found">Профиль не найден</string>
@@ -77,6 +77,7 @@
<string name="cd_pick_image">Choose photo</string> <string name="cd_pick_image">Choose photo</string>
<string name="cd_pick_file">Choose file</string> <string name="cd_pick_file">Choose file</string>
<string name="cd_send">Send</string> <string name="cd_send">Send</string>
<string name="cd_emoji">Emoji</string>
<!-- Profile --> <!-- Profile -->
<string name="profile_title">Profile</string> <string name="profile_title">Profile</string>
@@ -2,13 +2,15 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -19,28 +21,33 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.SentimentSatisfied
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -51,16 +58,19 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
@@ -74,15 +84,32 @@ import ru.fromchat.Res
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.* import ru.fromchat.*
private val ChatInputChromeHeight = 46.dp
private val ChatInputLineVerticalPadding = 12.dp
private val ChatInputTextLineHeight = 22.sp
private val ChatInputIconSlotSize = 36.dp
private val ChatInputIconSlotVerticalInset =
(ChatInputChromeHeight - ChatInputIconSlotSize) / 2f
private val ChatInputSendSpringFloat = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
private val ChatInputSendSpringDp = spring<Dp>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
@Composable @Composable
private fun <T> AnimatedPreviewBar( private fun <T> AnimatedPreviewBar(
state: T?, state: T?,
content: @Composable (T) -> Unit content: @Composable (T) -> Unit,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = state != null, visible = state != null,
enter = fadeIn() + expandVertically(), enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically() exit = fadeOut() + shrinkVertically(),
) { ) {
var lastState by remember { mutableStateOf(state) } var lastState by remember { mutableStateOf(state) }
@@ -104,19 +131,19 @@ private fun PreviewBar(
title: String, title: String,
subtitle: String, subtitle: String,
closeContentDescription: String, closeContentDescription: String,
onClose: () -> Unit onClose: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 6.dp, top = 8.dp), .padding(start = 12.dp, end = 6.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary,
) )
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -124,7 +151,7 @@ private fun PreviewBar(
text = title, text = title,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary,
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
@@ -133,15 +160,15 @@ private fun PreviewBar(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1 maxLines = 1,
) )
} }
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Filled.Close,
contentDescription = closeContentDescription, contentDescription = closeContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -152,7 +179,7 @@ private fun AttachmentChip(
attachment: SelectedAttachment, attachment: SelectedAttachment,
onRemove: () -> Unit, onRemove: () -> Unit,
removeContentDescription: String, removeContentDescription: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -160,7 +187,7 @@ private fun AttachmentChip(
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
if (attachment.isImage) { if (attachment.isImage) {
AsyncImage( AsyncImage(
@@ -169,14 +196,14 @@ private fun AttachmentChip(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
) )
} else { } else {
Icon( Icon(
imageVector = Icons.Default.AttachFile, imageVector = Icons.Filled.AttachFile,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Text( Text(
@@ -185,14 +212,14 @@ private fun AttachmentChip(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 120.dp) modifier = Modifier.widthIn(max = 120.dp),
) )
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Filled.Close,
contentDescription = removeContentDescription, contentDescription = removeContentDescription,
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -213,7 +240,7 @@ fun ChatInput(
recipientId: Int? = null, recipientId: Int? = null,
currentUserId: Int? = null, currentUserId: Int? = null,
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {} onReadOnlyMessageClick: () -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
@@ -226,7 +253,7 @@ fun ChatInput(
uri = uri, uri = uri,
filename = getFilenameFromUri(uri), filename = getFilenameFromUri(uri),
sizeBytes = null, sizeBytes = null,
isImage = true isImage = true,
) )
} }
} }
@@ -237,7 +264,7 @@ fun ChatInput(
uri = uri, uri = uri,
filename = getFilenameFromUri(uri), filename = getFilenameFromUri(uri),
sizeBytes = null, sizeBytes = null,
isImage = false isImage = false,
) )
} }
} }
@@ -263,6 +290,7 @@ fun ChatInput(
val cdPickImage = stringResource(Res.string.cd_pick_image) val cdPickImage = stringResource(Res.string.cd_pick_image)
val cdPickFile = stringResource(Res.string.cd_pick_file) val cdPickFile = stringResource(Res.string.cd_pick_file)
val cdSend = stringResource(Res.string.cd_send) val cdSend = stringResource(Res.string.cd_send)
val cdEmoji = stringResource(Res.string.cd_emoji)
val corruptedShort = stringResource(Res.string.message_corrupted_short) val corruptedShort = stringResource(Res.string.message_corrupted_short)
val editingTitle = stringResource(Res.string.message_editing_title) val editingTitle = stringResource(Res.string.message_editing_title)
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message) val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
@@ -272,30 +300,23 @@ fun ChatInput(
.fillMaxWidth() .fillMaxWidth()
.background(Color.Transparent) .background(Color.Transparent)
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) .padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
) { ) {
val shape = RoundedCornerShape(24.dp) val pillShape = RoundedCornerShape(28.dp)
Column( if (isReadOnly) {
modifier = Modifier Column(
.fillMaxWidth() modifier = Modifier
.border( .fillMaxWidth()
Dp.Hairline, .border(
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), Dp.Hairline,
shape MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
) pillShape,
.clip(shape) )
.hazeEffect( .clip(pillShape)
state = hazeState, .hazeEffect(state = hazeState, style = HazeMaterials.thin())
style = HazeMaterials.thin() .clickable(enabled = true) { onReadOnlyMessageClick() },
) ) {
.clickable(enabled = isReadOnly) {
if (isReadOnly) {
onReadOnlyMessageClick()
}
}
) {
if (isReadOnly) {
Text( Text(
text = blockedMessage, text = blockedMessage,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -303,150 +324,239 @@ fun ChatInput(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp),
) )
} else { }
AnimatedPreviewBar(replyTo) { replyTo -> } else {
val replySubtitle = if (replyTo.isContentCorrupted) { val sendTransition = updateTransition(
corruptedShort targetState = canSend,
} else { label = "chat_input_send_transition",
replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" )
}
val replyName = messageDisplayUsername(replyTo, currentUserId)
PreviewBar(
icon = Icons.AutoMirrored.Filled.Reply,
title = stringResource(Res.string.message_replying_to, replyName),
subtitle = replySubtitle,
closeContentDescription = cdClose,
onClose = { onClearReply() }
)
}
AnimatedPreviewBar(editingMessage) { message -> val progress by sendTransition.animateFloat(
val subtitle = if (message.isContentCorrupted) { transitionSpec = { ChatInputSendSpringFloat },
corruptedShort label = "chat_input_send_progress",
} else { ) { state -> if (state) 1f else 0f }
message.content.take(50) + if (message.content.length > 50) "..." else ""
}
PreviewBar(
icon = Icons.Filled.Edit,
title = editingTitle,
subtitle = subtitle,
closeContentDescription = cdClose,
onClose = { onClearEdit() }
)
}
AnimatedVisibility( val slotMax = ChatInputChromeHeight + 8.dp
visible = attachments.isNotEmpty(), val slotPadding by sendTransition.animateDp(
enter = fadeIn() + expandVertically(), transitionSpec = { ChatInputSendSpringDp },
exit = fadeOut() + shrinkVertically() label = "chat_input_slot_padding",
) { ) { state -> if (state) slotMax else 0.dp }
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
attachments.forEach { attachment ->
AttachmentChip(
attachment = attachment,
onRemove = { attachments = attachments.filter { it.id != attachment.id } },
removeContentDescription = cdRemove
)
}
}
}
Box(
modifier = Modifier.fillMaxWidth(),
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
verticalAlignment = Alignment.Bottom .fillMaxWidth()
.padding(end = slotPadding),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (recipientId != null && !isReadOnly) { Column(
IconButton(onClick = { launchImagePicker() }) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = cdPickImage,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { launchFilePicker() }) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = cdPickFile,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
OutlinedTextField(
value = text,
onValueChange = onTextChange,
enabled = !isReadOnly,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.animateContentSize(), .animateContentSize()
placeholder = { .border(
Text( Dp.Hairline,
text = stringResource(Res.string.message_placeholder), MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) pillShape,
) )
}, .clip(pillShape)
shape = shape, .hazeEffect(state = hazeState, style = HazeMaterials.thin()),
maxLines = 5, ) {
singleLine = false, AnimatedPreviewBar(replyTo) { reply ->
colors = OutlinedTextFieldDefaults.colors( val replySubtitle = if (reply.isContentCorrupted) {
focusedContainerColor = Color.Transparent, corruptedShort
unfocusedContainerColor = Color.Transparent, } else {
errorContainerColor = Color.Transparent, reply.content.take(50) + if (reply.content.length > 50) "..." else ""
disabledContainerColor = Color.Transparent, }
focusedBorderColor = Color.Transparent, val replyName = messageDisplayUsername(reply, currentUserId)
errorBorderColor = Color.Transparent, PreviewBar(
disabledBorderColor = Color.Transparent, icon = Icons.AutoMirrored.Filled.Reply,
unfocusedBorderColor = Color.Transparent title = stringResource(Res.string.message_replying_to, replyName),
), subtitle = replySubtitle,
trailingIcon = { closeContentDescription = cdClose,
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() } onClose = { onClearReply() },
)
}
AnimatedVisibility( AnimatedPreviewBar(editingMessage) { message ->
visible = canSend, val subtitle = if (message.isContentCorrupted) {
enter = slideInHorizontally( corruptedShort
initialOffsetX = { it + offset }, } else {
animationSpec = tween(durationMillis = 300) message.content.take(50) + if (message.content.length > 50) "..." else ""
), }
exit = slideOutHorizontally( PreviewBar(
targetOffsetX = { it + offset }, icon = Icons.Filled.Edit,
animationSpec = tween(durationMillis = 200) title = editingTitle,
) subtitle = subtitle,
closeContentDescription = cdClose,
onClose = { onClearEdit() },
)
}
AnimatedVisibility(
visible = attachments.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Box(Modifier.padding(end = 5.dp)) { attachments.forEach { attachment ->
FilledIconButton( AttachmentChip(
onClick = { attachment = attachment,
if (isReadOnly) { onRemove = { attachments = attachments.filter { it.id != attachment.id } },
return@FilledIconButton removeContentDescription = cdRemove,
} )
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = cdSend,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
} }
} }
} }
)
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = ChatInputChromeHeight)
.wrapContentHeight()
.padding(horizontal = 6.dp, vertical = 0.dp),
verticalAlignment = Alignment.Bottom,
) {
Box(
modifier = Modifier
.padding(vertical = ChatInputIconSlotVerticalInset)
.size(ChatInputIconSlotSize)
.clip(CircleShape)
.clickable(onClick = { /* emoji picker to be wired */ }),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.SentimentSatisfied,
contentDescription = cdEmoji,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
TextStyle(
color = MaterialTheme.colorScheme.onSurface,
lineHeight = ChatInputTextLineHeight,
),
)
BasicTextField(
value = text,
onValueChange = onTextChange,
enabled = !isReadOnly,
modifier = Modifier
.weight(1f)
.wrapContentHeight()
.animateContentSize(),
textStyle = inputTextStyle,
singleLine = false,
maxLines = 5,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = ChatInputLineVerticalPadding / 2,
vertical = ChatInputLineVerticalPadding,
),
contentAlignment = Alignment.CenterStart,
) {
if (text.isEmpty()) {
Text(
text = stringResource(Res.string.message_placeholder),
style = inputTextStyle.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
softWrap = false,
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
},
)
if (recipientId != null) {
Box(
modifier = Modifier
.padding(vertical = ChatInputIconSlotVerticalInset)
.size(ChatInputIconSlotSize)
.clip(CircleShape)
.clickable { launchImagePicker() },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Image,
contentDescription = cdPickImage,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier
.padding(vertical = ChatInputIconSlotVerticalInset)
.size(ChatInputIconSlotSize)
.clip(CircleShape)
.clickable { launchFilePicker() },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.AttachFile,
contentDescription = cdPickFile,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
Box(
modifier = Modifier.align(Alignment.BottomEnd),
) {
if (progress > 0.01f) {
val alpha by sendTransition.animateFloat(
transitionSpec = { ChatInputSendSpringFloat },
label = "chat_input_send_alpha",
) { state -> if (state) 1f else 0f }
val travel = ChatInputChromeHeight * 1.2f
val offsetX = travel * (1f - progress)
FilledIconButton(
onClick = {
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
},
modifier = Modifier
.size(ChatInputChromeHeight)
.offset(x = offsetX)
.alpha(alpha),
shape = CircleShape,
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = cdSend,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
} }
} }
} }
} }
} }
@@ -1,44 +1,29 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
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.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
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.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -53,11 +38,7 @@ 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.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -88,12 +69,10 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.chat.getImageAspectRatio import ru.fromchat.ui.chat.getImageAspectRatio
import ru.fromchat.ui.scaleOnPress
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.rememberLastSeenFormatStrings import ru.fromchat.utils.rememberLastSeenFormatStrings
@@ -132,8 +111,10 @@ fun ChatScreen(
onTitleAvatarChange?.invoke(panelState.titleAvatar) onTitleAvatarChange?.invoke(panelState.titleAvatar)
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val panelId = panelState.id
val listState = rememberLazyListState() val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
LazyListState(0, 0)
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val navController = LocalNavController.current val navController = LocalNavController.current
@@ -158,6 +139,32 @@ fun ChatScreen(
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
} }
val subtitleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
currentTypingUsers.isNotEmpty() -> "typing"
panel.usesPublicGroupSubtitle -> {
if (panelState.publicGroupMetaLoading || panelState.publicGroupMemberCount == null) {
"group"
} else {
"members:${panelState.publicGroupMemberCount}"
}
}
panelState.profileUserId != null -> {
val userStatus = statusMap[panelState.profileUserId]
val statusText = userStatus?.let {
formatLastSeen(it.online, it.lastSeen, lastSeenFormat)
}.orEmpty()
if (statusText.isNotEmpty()) {
"presence:$statusText"
} else {
""
}
}
else -> ""
}
LaunchedEffect(isReadOnly) { LaunchedEffect(isReadOnly) {
if (!isReadOnly) { if (!isReadOnly) {
showSuspendedSupportSheet = false showSuspendedSupportSheet = false
@@ -289,27 +296,37 @@ fun ChatScreen(
// LazyColumn uses reverseLayout + chronological messages asReversed(): index 0 is bottom inset, 1..n newest→oldest. // LazyColumn uses reverseLayout + chronological messages asReversed(): index 0 is bottom inset, 1..n newest→oldest.
// - Initial: after one frame, scrollToItem(0) so the first list composition/layout is not merged with scroll in one VSYNC. // - Initial: after one frame, scrollToItem(0) so the first list composition/layout is not merged with scroll in one VSYNC.
// - Later: same anchor; "near bottom" = smallest visible index is near 0. // - Later: same anchor; "near bottom" = smallest visible index is near 0.
var didInitialScroll by remember(panel) { mutableStateOf(false) } // rememberSaveable(panelId): survives navigation (e.g. profile) so we do not re-run initial scrollToItem(0) and discard scroll.
var didInitialScroll by rememberSaveable(panelId) { mutableStateOf(false) }
// LaunchedEffect restarts when returning from profile even if message keys are unchanged; skip auto-scroll unless the list actually changed.
var previousMessageFingerprint by rememberSaveable(panelId) { mutableStateOf("") }
LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) { LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) {
if (panelState.messages.isEmpty()) return@LaunchedEffect if (panelState.messages.isEmpty()) return@LaunchedEffect
val lastMessage = panelState.messages.lastOrNull() val lastMessage = panelState.messages.lastOrNull()
val lastIsOurs = lastMessage?.user_id == currentUserId val fingerprint = "${panelState.messages.size}|${lastMessage?.id}|${lastMessage?.pendingFileAspectRatio}"
if (!didInitialScroll) { if (!didInitialScroll) {
didInitialScroll = true didInitialScroll = true
withFrameNanos { } withFrameNanos { }
listState.scrollToItem(0) listState.scrollToItem(0)
previousMessageFingerprint = fingerprint
return@LaunchedEffect return@LaunchedEffect
} }
if (fingerprint == previousMessageFingerprint) return@LaunchedEffect
previousMessageFingerprint = fingerprint
val lastIsOurs = lastMessage?.user_id == currentUserId
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE
val isNearBottom = minVisibleIndex <= 2 val isNearBottom = minVisibleIndex <= 2
if (lastIsOurs || isNearBottom) { if (lastIsOurs || isNearBottom) {
delay(100) if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) {
listState.animateScrollToItem(0) delay(100)
delay(150) listState.animateScrollToItem(0)
listState.animateScrollToItem(0) delay(150)
listState.animateScrollToItem(0)
}
} }
} }
@@ -339,240 +356,19 @@ fun ChatScreen(
) )
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.fillMaxSize(),
topBar = { // Do not apply safeDrawing top (or other sides) to content — we handle status bars on the
TopAppBar( // floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
title = { contentWindowInsets = WindowInsets.navigationBars,
Row(
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(
scale = 0.96f,
onClick = if (profileUserId != null && onTitleClick != null) {
{ onTitleClick() }
} else null
),
verticalAlignment = Alignment.CenterVertically
) {
when {
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
val avatar = panelState.titleAvatar
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() }
?: ""
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = avatar?.profilePictureUrl,
displayName = displayName,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope
)
.size(36.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
}
!hideTitleBarAvatar -> {
panelState.titleAvatar?.let { avatar ->
Avatar(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
modifier = Modifier.size(36.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
onAvatarSlotBounds != null -> {
Box(
modifier = Modifier
.size(36.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat()
)
)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
else -> {
panelState.titleAvatar?.let {
Spacer(modifier = Modifier.width(36.dp))
Spacer(modifier = Modifier.width(8.dp))
}
}
}
Column(Modifier.fillMaxWidth()) {
Text(
text = panelState.title,
style = MaterialTheme.typography.titleLarge
)
val subtitleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
currentTypingUsers.isNotEmpty() -> "typing"
panel.usesPublicGroupSubtitle -> {
if (panelState.publicGroupMetaLoading ||
panelState.publicGroupMemberCount == null
) {
"group"
} else {
"members:${panelState.publicGroupMemberCount}"
}
}
panelState.profileUserId != null -> {
val userStatus = statusMap[panelState.profileUserId]
val statusText = userStatus?.let {
formatLastSeen(it.online, it.lastSeen, lastSeenFormat)
}.orEmpty()
if (statusText.isNotEmpty()) {
"presence:$statusText"
} else {
""
}
}
else -> ""
}
AnimatedContent(
targetState = subtitleKey,
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "chat_subtitle"
) { key ->
when {
key == "updating" -> {
val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusUpdating,
style = st,
color = col
)
ConnectingEllipsis(
fontSize = st.fontSize,
color = col,
baseStyle = st
)
}
}
key == "connecting" -> {
val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusConnecting,
style = st,
color = col
)
ConnectingEllipsis(
fontSize = st.fontSize,
color = col,
baseStyle = st
)
}
}
key == "typing" -> {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
}
key.startsWith("presence:") -> {
val text = key.removePrefix("presence:")
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
key == "group" -> {
Text(
text = chatGroupLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
key.startsWith("members:") -> {
val n = key.removePrefix("members:").toIntOrNull() ?: 0
Text(
text = stringResource(Res.string.chat_members_count, n),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
else -> {
// No subtitle for this state
}
}
}
}
}
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
actions = {
if (panel.showCallButton() && !isReadOnly) {
IconButton(onClick = { /* TODO: Handle call */ }) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = cdCall
)
}
}
},
scrollBehavior = scrollBehavior,
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
)
},
bottomBar = { bottomBar = {
Column( Column(
modifier = Modifier modifier = Modifier
.windowInsetsPadding(WindowInsets.ime) .windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth() .fillMaxWidth()
.hazeEffect( .hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
state = hazeState,
style = HazeMaterials.thin()
) {
progressive = HazeProgressive.verticalGradient( progressive = HazeProgressive.verticalGradient(
startIntensity = 0f, startIntensity = 0f,
endIntensity = 1f endIntensity = 1f,
) )
} }
) { ) {
@@ -668,6 +464,10 @@ fun ChatScreen(
} }
} }
) { innerPadding -> ) { innerPadding ->
val density = LocalDensity.current
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val floatingHeaderClearance = statusBarTopDp + 64.dp + 12.dp
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -687,15 +487,17 @@ fun ChatScreen(
CircularProgressIndicator() CircularProgressIndicator()
} }
} else { } else {
LazyColumn( Box(modifier = Modifier.fillMaxSize()) {
state = listState, LazyColumn(
modifier = Modifier state = listState,
.fillMaxSize() modifier = Modifier
.hazeSource(hazeState), .fillMaxSize()
userScrollEnabled = !contextMenuState.isOpen, .background(MaterialTheme.colorScheme.background)
reverseLayout = true, .hazeSource(hazeState),
verticalArrangement = Arrangement.spacedBy(4.dp) userScrollEnabled = !contextMenuState.isOpen,
) { reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
items( items(
@@ -768,7 +570,37 @@ fun ChatScreen(
) )
} }
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } item { Spacer(Modifier.height(floatingHeaderClearance)) }
}
ChatFloatingHeaderBox(
hazeState = hazeState,
onBack = { navController.navigateUp() },
backContentDescription = stringResource(Res.string.back),
showCallButton = panel.showCallButton() && !isReadOnly,
onCallClick = { /* TODO: Handle call */ },
callContentDescription = cdCall,
titleChrome = {
ChatFloatingTitleChrome(
hazeState = hazeState,
title = panelState.title,
titleAvatar = panelState.titleAvatar,
profileUserId = profileUserId,
onTitleClick = onTitleClick,
hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey,
subtitleKey = subtitleKey,
currentTypingUsers = currentTypingUsers,
statusConnecting = statusConnecting,
statusUpdating = statusUpdating,
chatGroupLabel = chatGroupLabel,
)
},
modifier = Modifier.align(Alignment.TopCenter),
)
} }
} }
@@ -0,0 +1,409 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBackIos
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.scaleOnPress
/**
* Telegram-style floating chrome: iOS-like frosted pill (Haze "thin" material + translucent fill),
* circular back control, and a full-width progressive blur plate behind the header row.
* [ChatFloatingHeaderBox] uses a plain [Box] (not a toolbar / TopAppBar) with side rails so the pill stays centered.
*/
@Composable
fun ChatFloatingBackButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = CircleShape
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
Box(
modifier = modifier
.scaleOnPress(
scale = 0.88f,
onClick = onClick,
indication = null,
clipShape = shape,
),
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(shape)
.background(fill),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBackIos,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(28.dp),
)
}
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun ChatFloatingTitleChrome(
hazeState: HazeState,
title: String,
titleAvatar: AvatarInfo?,
profileUserId: Int?,
onTitleClick: (() -> Unit)?,
hideTitleBarAvatar: Boolean,
onAvatarSlotBounds: ((Rect) -> Unit)?,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
sharedAvatarKey: Any?,
subtitleKey: String,
currentTypingUsers: List<TypingUser>,
statusConnecting: String,
statusUpdating: String,
chatGroupLabel: String,
modifier: Modifier = Modifier,
) {
val pillShape = RoundedCornerShape(percent = 50)
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
val pillClickable = profileUserId != null && onTitleClick != null
Box(
modifier = modifier
.wrapContentWidth()
.then(
if (pillClickable) {
Modifier.scaleOnPress(
scale = 0.96f,
onClick = onTitleClick,
clipShape = pillShape,
)
} else {
Modifier
},
),
) {
Row(
modifier = Modifier
.wrapContentWidth()
.wrapContentHeight()
.heightIn(min = 44.dp)
.clip(pillShape)
.background(fill)
.hazeEffect(state = hazeState, style = HazeMaterials.thin())
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
when {
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
val avatar = titleAvatar
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: title.takeIf { it.isNotBlank() }
?: ""
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = avatar?.profilePictureUrl,
displayName = displayName,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope,
)
.size(40.dp),
)
}
Spacer(modifier = Modifier.width(6.dp))
}
!hideTitleBarAvatar -> {
titleAvatar?.let { avatar ->
Avatar(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.width(6.dp))
}
}
onAvatarSlotBounds != null -> {
Box(
modifier = Modifier
.size(40.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat(),
),
)
},
)
Spacer(modifier = Modifier.width(8.dp))
}
else -> {
titleAvatar?.let {
Spacer(modifier = Modifier.width(40.dp))
Spacer(modifier = Modifier.width(6.dp))
}
}
}
Column(
modifier = Modifier
.wrapContentWidth()
.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalAlignment = Alignment.Start,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AnimatedContent(
targetState = subtitleKey,
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "chat_subtitle",
) { key ->
when {
key == "updating" -> {
val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = statusUpdating,
style = st,
color = col,
)
ConnectingEllipsis(
fontSize = st.fontSize,
color = col,
baseStyle = st,
)
}
}
key == "connecting" -> {
val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = statusConnecting,
style = st,
color = col,
)
ConnectingEllipsis(
fontSize = st.fontSize,
color = col,
baseStyle = st,
)
}
}
key == "typing" -> {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp),
)
}
key.startsWith("presence:") -> {
val text = key.removePrefix("presence:")
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
key == "group" -> {
Text(
text = chatGroupLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
key.startsWith("members:") -> {
val n = key.removePrefix("members:").toIntOrNull() ?: 0
Text(
text = stringResource(Res.string.chat_members_count, n),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
}
}
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun ChatFloatingHeaderBox(
hazeState: HazeState,
onBack: () -> Unit,
backContentDescription: String,
showCallButton: Boolean,
onCallClick: () -> Unit,
callContentDescription: String,
titleChrome: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val sideSlot = 56.dp
val density = LocalDensity.current
val statusBarDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val blurPlateHeight = statusBarDp + 64.dp + 12.dp
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(blurPlateHeight)
.align(Alignment.TopCenter)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
},
)
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.height(IntrinsicSize.Min)
.zIndex(1f)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 8.dp, end = 8.dp, top = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(sideSlot)
.fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
Box(Modifier.fillMaxSize().padding(6.dp)) {
ChatFloatingBackButton(
contentDescription = backContentDescription,
onClick = onBack,
modifier = Modifier
.fillMaxSize()
.aspectRatio(1f, matchHeightConstraintsFirst = true),
)
}
}
Box(
modifier = Modifier
.weight(1f)
.wrapContentHeight(),
contentAlignment = Alignment.Center,
) {
titleChrome()
}
if (showCallButton) {
val shape = CircleShape
val outline = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
Box(
modifier = Modifier
.width(sideSlot)
.fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f, matchHeightConstraintsFirst = true)
.clip(shape)
.border(Dp.Hairline, outline, shape)
.background(fill)
.clickable(onClick = onCallClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = callContentDescription,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(28.dp),
)
}
}
} else {
Spacer(modifier = Modifier.width(sideSlot))
}
}
}
}