mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Completely redesign the chat UI
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user