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_file">Выбрать файл</string>
<string name="cd_send">Отправить</string>
<string name="cd_emoji">Эмодзи</string>
<string name="profile_title">Профиль</string>
<string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string>
@@ -77,6 +77,7 @@
<string name="cd_pick_image">Choose photo</string>
<string name="cd_pick_file">Choose file</string>
<string name="cd_send">Send</string>
<string name="cd_emoji">Emoji</string>
<!-- Profile -->
<string name="profile_title">Profile</string>
@@ -2,13 +2,15 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
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.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
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.text.BasicTextField
import androidx.compose.material.icons.Icons
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.Close
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -51,16 +58,19 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
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.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
@@ -74,15 +84,32 @@ import ru.fromchat.Res
import ru.fromchat.api.Message
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
private fun <T> AnimatedPreviewBar(
state: T?,
content: @Composable (T) -> Unit
content: @Composable (T) -> Unit,
) {
AnimatedVisibility(
visible = state != null,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
exit = fadeOut() + shrinkVertically(),
) {
var lastState by remember { mutableStateOf(state) }
@@ -104,19 +131,19 @@ private fun PreviewBar(
title: String,
subtitle: String,
closeContentDescription: String,
onClose: () -> Unit
onClose: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 6.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
@@ -124,7 +151,7 @@ private fun PreviewBar(
text = title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(2.dp))
@@ -133,15 +160,15 @@ private fun PreviewBar(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
maxLines = 1,
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
imageVector = Icons.Filled.Close,
contentDescription = closeContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -152,7 +179,7 @@ private fun AttachmentChip(
attachment: SelectedAttachment,
onRemove: () -> Unit,
removeContentDescription: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
@@ -160,7 +187,7 @@ private fun AttachmentChip(
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (attachment.isImage) {
AsyncImage(
@@ -169,14 +196,14 @@ private fun AttachmentChip(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
)
} else {
Icon(
imageVector = Icons.Default.AttachFile,
imageVector = Icons.Filled.AttachFile,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
@@ -185,14 +212,14 @@ private fun AttachmentChip(
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 120.dp)
modifier = Modifier.widthIn(max = 120.dp),
)
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.Default.Close,
imageVector = Icons.Filled.Close,
contentDescription = removeContentDescription,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -213,7 +240,7 @@ fun ChatInput(
recipientId: Int? = null,
currentUserId: Int? = null,
isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {}
onReadOnlyMessageClick: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
@@ -226,7 +253,7 @@ fun ChatInput(
uri = uri,
filename = getFilenameFromUri(uri),
sizeBytes = null,
isImage = true
isImage = true,
)
}
}
@@ -237,7 +264,7 @@ fun ChatInput(
uri = uri,
filename = getFilenameFromUri(uri),
sizeBytes = null,
isImage = false
isImage = false,
)
}
}
@@ -263,6 +290,7 @@ fun ChatInput(
val cdPickImage = stringResource(Res.string.cd_pick_image)
val cdPickFile = stringResource(Res.string.cd_pick_file)
val cdSend = stringResource(Res.string.cd_send)
val cdEmoji = stringResource(Res.string.cd_emoji)
val corruptedShort = stringResource(Res.string.message_corrupted_short)
val editingTitle = stringResource(Res.string.message_editing_title)
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
@@ -272,30 +300,23 @@ fun ChatInput(
.fillMaxWidth()
.background(Color.Transparent)
.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)
if (isReadOnly) {
Column(
modifier = Modifier
.fillMaxWidth()
.border(
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
pillShape,
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
)
.clickable(enabled = isReadOnly) {
if (isReadOnly) {
onReadOnlyMessageClick()
}
}
.clip(pillShape)
.hazeEffect(state = hazeState, style = HazeMaterials.thin())
.clickable(enabled = true) { onReadOnlyMessageClick() },
) {
if (isReadOnly) {
Text(
text = blockedMessage,
style = MaterialTheme.typography.bodyLarge,
@@ -303,22 +324,61 @@ fun ChatInput(
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
} else {
AnimatedPreviewBar(replyTo) { replyTo ->
val replySubtitle = if (replyTo.isContentCorrupted) {
val sendTransition = updateTransition(
targetState = canSend,
label = "chat_input_send_transition",
)
val progress by sendTransition.animateFloat(
transitionSpec = { ChatInputSendSpringFloat },
label = "chat_input_send_progress",
) { state -> if (state) 1f else 0f }
val slotMax = ChatInputChromeHeight + 8.dp
val slotPadding by sendTransition.animateDp(
transitionSpec = { ChatInputSendSpringDp },
label = "chat_input_slot_padding",
) { state -> if (state) slotMax else 0.dp }
Box(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = slotPadding),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier
.weight(1f)
.animateContentSize()
.border(
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
pillShape,
)
.clip(pillShape)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()),
) {
AnimatedPreviewBar(replyTo) { reply ->
val replySubtitle = if (reply.isContentCorrupted) {
corruptedShort
} else {
replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else ""
reply.content.take(50) + if (reply.content.length > 50) "..." else ""
}
val replyName = messageDisplayUsername(replyTo, currentUserId)
val replyName = messageDisplayUsername(reply, currentUserId)
PreviewBar(
icon = Icons.AutoMirrored.Filled.Reply,
title = stringResource(Res.string.message_replying_to, replyName),
subtitle = replySubtitle,
closeContentDescription = cdClose,
onClose = { onClearReply() }
onClose = { onClearReply() },
)
}
@@ -333,120 +393,170 @@ fun ChatInput(
title = editingTitle,
subtitle = subtitle,
closeContentDescription = cdClose,
onClose = { onClearEdit() }
onClose = { onClearEdit() },
)
}
AnimatedVisibility(
visible = attachments.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
exit = fadeOut() + shrinkVertically(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
attachments.forEach { attachment ->
AttachmentChip(
attachment = attachment,
onRemove = { attachments = attachments.filter { it.id != attachment.id } },
removeContentDescription = cdRemove
removeContentDescription = cdRemove,
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom
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,
) {
if (recipientId != null && !isReadOnly) {
IconButton(onClick = { launchImagePicker() }) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = cdPickImage,
tint = MaterialTheme.colorScheme.onSurfaceVariant
imageVector = Icons.Outlined.SentimentSatisfied,
contentDescription = cdEmoji,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { launchFilePicker() }) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = cdPickFile,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
OutlinedTextField(
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(),
placeholder = {
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),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
style = inputTextStyle.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
softWrap = false,
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
},
shape = shape,
maxLines = 5,
singleLine = false,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
AnimatedVisibility(
visible = canSend,
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
),
exit = slideOutHorizontally(
targetOffsetX = { it + offset },
animationSpec = tween(durationMillis = 200)
)
if (recipientId != null) {
Box(
modifier = Modifier
.padding(vertical = ChatInputIconSlotVerticalInset)
.size(ChatInputIconSlotSize)
.clip(CircleShape)
.clickable { launchImagePicker() },
contentAlignment = Alignment.Center,
) {
Box(Modifier.padding(end = 5.dp)) {
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 = {
if (isReadOnly) {
return@FilledIconButton
}
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
modifier = Modifier
.size(ChatInputChromeHeight)
.offset(x = offsetX)
.alpha(alpha),
shape = CircleShape,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
imageVector = Icons.Filled.ArrowUpward,
contentDescription = cdSend,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
}
}
}
)
}
}
}
}
}
}
}
@@ -1,44 +1,29 @@
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.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -53,11 +38,7 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -88,12 +69,10 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.chat.getImageAspectRatio
import ru.fromchat.ui.scaleOnPress
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.rememberLastSeenFormatStrings
@@ -132,8 +111,10 @@ fun ChatScreen(
onTitleAvatarChange?.invoke(panelState.titleAvatar)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
val panelId = panelState.id
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
LazyListState(0, 0)
}
val scope = rememberCoroutineScope()
val haptic = rememberHapticFeedback()
val navController = LocalNavController.current
@@ -158,6 +139,32 @@ fun ChatScreen(
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) {
if (!isReadOnly) {
showSuspendedSupportSheet = false
@@ -289,29 +296,39 @@ fun ChatScreen(
// 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.
// - 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) {
if (panelState.messages.isEmpty()) return@LaunchedEffect
val lastMessage = panelState.messages.lastOrNull()
val lastIsOurs = lastMessage?.user_id == currentUserId
val fingerprint = "${panelState.messages.size}|${lastMessage?.id}|${lastMessage?.pendingFileAspectRatio}"
if (!didInitialScroll) {
didInitialScroll = true
withFrameNanos { }
listState.scrollToItem(0)
previousMessageFingerprint = fingerprint
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 isNearBottom = minVisibleIndex <= 2
if (lastIsOurs || isNearBottom) {
if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) {
delay(100)
listState.animateScrollToItem(0)
delay(150)
listState.animateScrollToItem(0)
}
}
}
LaunchedEffect(panel) {
if (panel.getRecipientId() != null) {
@@ -339,240 +356,19 @@ fun ChatScreen(
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
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
)
)
},
modifier = modifier.fillMaxSize(),
// Do not apply safeDrawing top (or other sides) to content — we handle status bars on the
// floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
contentWindowInsets = WindowInsets.navigationBars,
bottomBar = {
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
) {
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f
endIntensity = 1f,
)
}
) {
@@ -668,6 +464,10 @@ fun ChatScreen(
}
}
) { innerPadding ->
val density = LocalDensity.current
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val floatingHeaderClearance = statusBarTopDp + 64.dp + 12.dp
Box(
modifier = Modifier
.fillMaxSize()
@@ -687,10 +487,12 @@ fun ChatScreen(
CircularProgressIndicator()
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
userScrollEnabled = !contextMenuState.isOpen,
reverseLayout = true,
@@ -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))
}
}
}
}