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_image">Выбрать фото</string>
|
||||||
<string name="cd_pick_file">Выбрать файл</string>
|
<string name="cd_pick_file">Выбрать файл</string>
|
||||||
<string name="cd_send">Отправить</string>
|
<string name="cd_send">Отправить</string>
|
||||||
|
<string name="cd_emoji">Эмодзи</string>
|
||||||
<string name="profile_title">Профиль</string>
|
<string name="profile_title">Профиль</string>
|
||||||
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
||||||
<string name="profile_not_found">Профиль не найден</string>
|
<string name="profile_not_found">Профиль не найден</string>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
<string name="cd_pick_image">Choose photo</string>
|
<string name="cd_pick_image">Choose photo</string>
|
||||||
<string name="cd_pick_file">Choose file</string>
|
<string name="cd_pick_file">Choose file</string>
|
||||||
<string name="cd_send">Send</string>
|
<string name="cd_send">Send</string>
|
||||||
|
<string name="cd_emoji">Emoji</string>
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<string name="profile_title">Profile</string>
|
<string name="profile_title">Profile</string>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ package ru.fromchat.ui.chat
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateDp
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.updateTransition
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -19,28 +21,33 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Image
|
import androidx.compose.material.icons.outlined.AttachFile
|
||||||
|
import androidx.compose.material.icons.outlined.Image
|
||||||
|
import androidx.compose.material.icons.outlined.SentimentSatisfied
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -51,16 +58,19 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
@@ -74,15 +84,32 @@ import ru.fromchat.Res
|
|||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
|
|
||||||
|
private val ChatInputChromeHeight = 46.dp
|
||||||
|
private val ChatInputLineVerticalPadding = 12.dp
|
||||||
|
private val ChatInputTextLineHeight = 22.sp
|
||||||
|
|
||||||
|
private val ChatInputIconSlotSize = 36.dp
|
||||||
|
private val ChatInputIconSlotVerticalInset =
|
||||||
|
(ChatInputChromeHeight - ChatInputIconSlotSize) / 2f
|
||||||
|
|
||||||
|
private val ChatInputSendSpringFloat = spring<Float>(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
)
|
||||||
|
private val ChatInputSendSpringDp = spring<Dp>(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> AnimatedPreviewBar(
|
private fun <T> AnimatedPreviewBar(
|
||||||
state: T?,
|
state: T?,
|
||||||
content: @Composable (T) -> Unit
|
content: @Composable (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state != null,
|
visible = state != null,
|
||||||
enter = fadeIn() + expandVertically(),
|
enter = fadeIn() + expandVertically(),
|
||||||
exit = fadeOut() + shrinkVertically()
|
exit = fadeOut() + shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
var lastState by remember { mutableStateOf(state) }
|
var lastState by remember { mutableStateOf(state) }
|
||||||
|
|
||||||
@@ -104,19 +131,19 @@ private fun PreviewBar(
|
|||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
closeContentDescription: String,
|
closeContentDescription: String,
|
||||||
onClose: () -> Unit
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 12.dp, end = 6.dp, top = 8.dp),
|
.padding(start = 12.dp, end = 6.dp, top = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
@@ -124,7 +151,7 @@ private fun PreviewBar(
|
|||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
@@ -133,15 +160,15 @@ private fun PreviewBar(
|
|||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = onClose) {
|
IconButton(onClick = onClose) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Filled.Close,
|
||||||
contentDescription = closeContentDescription,
|
contentDescription = closeContentDescription,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +179,7 @@ private fun AttachmentChip(
|
|||||||
attachment: SelectedAttachment,
|
attachment: SelectedAttachment,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
removeContentDescription: String,
|
removeContentDescription: String,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -160,7 +187,7 @@ private fun AttachmentChip(
|
|||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
if (attachment.isImage) {
|
if (attachment.isImage) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
@@ -169,14 +196,14 @@ private fun AttachmentChip(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.AttachFile,
|
imageVector = Icons.Filled.AttachFile,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -185,14 +212,14 @@ private fun AttachmentChip(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.widthIn(max = 120.dp)
|
modifier = Modifier.widthIn(max = 120.dp),
|
||||||
)
|
)
|
||||||
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
|
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Filled.Close,
|
||||||
contentDescription = removeContentDescription,
|
contentDescription = removeContentDescription,
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +240,7 @@ fun ChatInput(
|
|||||||
recipientId: Int? = null,
|
recipientId: Int? = null,
|
||||||
currentUserId: Int? = null,
|
currentUserId: Int? = null,
|
||||||
isReadOnly: Boolean = false,
|
isReadOnly: Boolean = false,
|
||||||
onReadOnlyMessageClick: () -> Unit = {}
|
onReadOnlyMessageClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||||
@@ -226,7 +253,7 @@ fun ChatInput(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
filename = getFilenameFromUri(uri),
|
filename = getFilenameFromUri(uri),
|
||||||
sizeBytes = null,
|
sizeBytes = null,
|
||||||
isImage = true
|
isImage = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +264,7 @@ fun ChatInput(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
filename = getFilenameFromUri(uri),
|
filename = getFilenameFromUri(uri),
|
||||||
sizeBytes = null,
|
sizeBytes = null,
|
||||||
isImage = false
|
isImage = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +290,7 @@ fun ChatInput(
|
|||||||
val cdPickImage = stringResource(Res.string.cd_pick_image)
|
val cdPickImage = stringResource(Res.string.cd_pick_image)
|
||||||
val cdPickFile = stringResource(Res.string.cd_pick_file)
|
val cdPickFile = stringResource(Res.string.cd_pick_file)
|
||||||
val cdSend = stringResource(Res.string.cd_send)
|
val cdSend = stringResource(Res.string.cd_send)
|
||||||
|
val cdEmoji = stringResource(Res.string.cd_emoji)
|
||||||
val corruptedShort = stringResource(Res.string.message_corrupted_short)
|
val corruptedShort = stringResource(Res.string.message_corrupted_short)
|
||||||
val editingTitle = stringResource(Res.string.message_editing_title)
|
val editingTitle = stringResource(Res.string.message_editing_title)
|
||||||
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
|
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
|
||||||
@@ -272,30 +300,23 @@ fun ChatInput(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color.Transparent)
|
.background(Color.Transparent)
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
val shape = RoundedCornerShape(24.dp)
|
val pillShape = RoundedCornerShape(28.dp)
|
||||||
|
|
||||||
|
if (isReadOnly) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.border(
|
.border(
|
||||||
Dp.Hairline,
|
Dp.Hairline,
|
||||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||||
shape
|
pillShape,
|
||||||
)
|
)
|
||||||
.clip(shape)
|
.clip(pillShape)
|
||||||
.hazeEffect(
|
.hazeEffect(state = hazeState, style = HazeMaterials.thin())
|
||||||
state = hazeState,
|
.clickable(enabled = true) { onReadOnlyMessageClick() },
|
||||||
style = HazeMaterials.thin()
|
|
||||||
)
|
|
||||||
.clickable(enabled = isReadOnly) {
|
|
||||||
if (isReadOnly) {
|
|
||||||
onReadOnlyMessageClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
if (isReadOnly) {
|
|
||||||
Text(
|
Text(
|
||||||
text = blockedMessage,
|
text = blockedMessage,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
@@ -303,22 +324,61 @@ fun ChatInput(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
AnimatedPreviewBar(replyTo) { replyTo ->
|
val sendTransition = updateTransition(
|
||||||
val replySubtitle = if (replyTo.isContentCorrupted) {
|
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
|
corruptedShort
|
||||||
} else {
|
} 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(
|
PreviewBar(
|
||||||
icon = Icons.AutoMirrored.Filled.Reply,
|
icon = Icons.AutoMirrored.Filled.Reply,
|
||||||
title = stringResource(Res.string.message_replying_to, replyName),
|
title = stringResource(Res.string.message_replying_to, replyName),
|
||||||
subtitle = replySubtitle,
|
subtitle = replySubtitle,
|
||||||
closeContentDescription = cdClose,
|
closeContentDescription = cdClose,
|
||||||
onClose = { onClearReply() }
|
onClose = { onClearReply() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,120 +393,170 @@ fun ChatInput(
|
|||||||
title = editingTitle,
|
title = editingTitle,
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
closeContentDescription = cdClose,
|
closeContentDescription = cdClose,
|
||||||
onClose = { onClearEdit() }
|
onClose = { onClearEdit() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = attachments.isNotEmpty(),
|
visible = attachments.isNotEmpty(),
|
||||||
enter = fadeIn() + expandVertically(),
|
enter = fadeIn() + expandVertically(),
|
||||||
exit = fadeOut() + shrinkVertically()
|
exit = fadeOut() + shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.horizontalScroll(rememberScrollState())
|
.horizontalScroll(rememberScrollState())
|
||||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
attachments.forEach { attachment ->
|
attachments.forEach { attachment ->
|
||||||
AttachmentChip(
|
AttachmentChip(
|
||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
onRemove = { attachments = attachments.filter { it.id != attachment.id } },
|
onRemove = { attachments = attachments.filter { it.id != attachment.id } },
|
||||||
removeContentDescription = cdRemove
|
removeContentDescription = cdRemove,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
verticalAlignment = Alignment.Bottom
|
.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(
|
Icon(
|
||||||
imageVector = Icons.Default.Image,
|
imageVector = Icons.Outlined.SentimentSatisfied,
|
||||||
contentDescription = cdPickImage,
|
contentDescription = cdEmoji,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
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,
|
value = text,
|
||||||
onValueChange = onTextChange,
|
onValueChange = onTextChange,
|
||||||
enabled = !isReadOnly,
|
enabled = !isReadOnly,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.wrapContentHeight()
|
||||||
.animateContentSize(),
|
.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(
|
||||||
text = stringResource(Res.string.message_placeholder),
|
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(
|
FilledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isReadOnly) {
|
|
||||||
return@FilledIconButton
|
|
||||||
}
|
|
||||||
val plaintext = text.trim().ifBlank { "" }
|
val plaintext = text.trim().ifBlank { "" }
|
||||||
onSend(plaintext, attachments)
|
onSend(plaintext, attachments)
|
||||||
onTextChange("")
|
onTextChange("")
|
||||||
attachments = emptyList()
|
attachments = emptyList()
|
||||||
typingHandler.stopTyping()
|
typingHandler.stopTyping()
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier
|
||||||
|
.size(ChatInputChromeHeight)
|
||||||
|
.offset(x = offsetX)
|
||||||
|
.alpha(alpha),
|
||||||
|
shape = CircleShape,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
imageVector = Icons.Filled.ArrowUpward,
|
||||||
contentDescription = cdSend,
|
contentDescription = cdSend,
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,29 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Call
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -53,11 +38,7 @@ import androidx.compose.runtime.withFrameNanos
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -88,12 +69,10 @@ import ru.fromchat.api.WebSocketMessage
|
|||||||
import ru.fromchat.api.WebSocketUpdatesData
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.net.NetworkConnectivity
|
import ru.fromchat.net.NetworkConnectivity
|
||||||
import ru.fromchat.ui.ConnectingEllipsis
|
|
||||||
import ru.fromchat.ui.HapticFeedbackEvent
|
import ru.fromchat.ui.HapticFeedbackEvent
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.rememberHapticFeedback
|
import ru.fromchat.ui.rememberHapticFeedback
|
||||||
import ru.fromchat.ui.chat.getImageAspectRatio
|
import ru.fromchat.ui.chat.getImageAspectRatio
|
||||||
import ru.fromchat.ui.scaleOnPress
|
|
||||||
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
|
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
|
||||||
import ru.fromchat.utils.formatLastSeen
|
import ru.fromchat.utils.formatLastSeen
|
||||||
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
||||||
@@ -132,8 +111,10 @@ fun ChatScreen(
|
|||||||
onTitleAvatarChange?.invoke(panelState.titleAvatar)
|
onTitleAvatarChange?.invoke(panelState.titleAvatar)
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val panelId = panelState.id
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
|
||||||
|
LazyListState(0, 0)
|
||||||
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
@@ -158,6 +139,32 @@ fun ChatScreen(
|
|||||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val subtitleKey = when {
|
||||||
|
!online -> "connecting"
|
||||||
|
connectionStatus == ConnectionStatus.UPDATING -> "updating"
|
||||||
|
connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
|
||||||
|
currentTypingUsers.isNotEmpty() -> "typing"
|
||||||
|
panel.usesPublicGroupSubtitle -> {
|
||||||
|
if (panelState.publicGroupMetaLoading || panelState.publicGroupMemberCount == null) {
|
||||||
|
"group"
|
||||||
|
} else {
|
||||||
|
"members:${panelState.publicGroupMemberCount}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panelState.profileUserId != null -> {
|
||||||
|
val userStatus = statusMap[panelState.profileUserId]
|
||||||
|
val statusText = userStatus?.let {
|
||||||
|
formatLastSeen(it.online, it.lastSeen, lastSeenFormat)
|
||||||
|
}.orEmpty()
|
||||||
|
if (statusText.isNotEmpty()) {
|
||||||
|
"presence:$statusText"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(isReadOnly) {
|
LaunchedEffect(isReadOnly) {
|
||||||
if (!isReadOnly) {
|
if (!isReadOnly) {
|
||||||
showSuspendedSupportSheet = false
|
showSuspendedSupportSheet = false
|
||||||
@@ -289,29 +296,39 @@ fun ChatScreen(
|
|||||||
// LazyColumn uses reverseLayout + chronological messages asReversed(): index 0 is bottom inset, 1..n newest→oldest.
|
// LazyColumn uses reverseLayout + chronological messages asReversed(): index 0 is bottom inset, 1..n newest→oldest.
|
||||||
// - Initial: after one frame, scrollToItem(0) so the first list composition/layout is not merged with scroll in one VSYNC.
|
// - Initial: after one frame, scrollToItem(0) so the first list composition/layout is not merged with scroll in one VSYNC.
|
||||||
// - Later: same anchor; "near bottom" = smallest visible index is near 0.
|
// - Later: same anchor; "near bottom" = smallest visible index is near 0.
|
||||||
var didInitialScroll by remember(panel) { mutableStateOf(false) }
|
// rememberSaveable(panelId): survives navigation (e.g. profile) so we do not re-run initial scrollToItem(0) and discard scroll.
|
||||||
|
var didInitialScroll by rememberSaveable(panelId) { mutableStateOf(false) }
|
||||||
|
// LaunchedEffect restarts when returning from profile even if message keys are unchanged; skip auto-scroll unless the list actually changed.
|
||||||
|
var previousMessageFingerprint by rememberSaveable(panelId) { mutableStateOf("") }
|
||||||
LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) {
|
LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) {
|
||||||
if (panelState.messages.isEmpty()) return@LaunchedEffect
|
if (panelState.messages.isEmpty()) return@LaunchedEffect
|
||||||
|
|
||||||
val lastMessage = panelState.messages.lastOrNull()
|
val lastMessage = panelState.messages.lastOrNull()
|
||||||
val lastIsOurs = lastMessage?.user_id == currentUserId
|
val fingerprint = "${panelState.messages.size}|${lastMessage?.id}|${lastMessage?.pendingFileAspectRatio}"
|
||||||
|
|
||||||
if (!didInitialScroll) {
|
if (!didInitialScroll) {
|
||||||
didInitialScroll = true
|
didInitialScroll = true
|
||||||
withFrameNanos { }
|
withFrameNanos { }
|
||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
|
previousMessageFingerprint = fingerprint
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fingerprint == previousMessageFingerprint) return@LaunchedEffect
|
||||||
|
previousMessageFingerprint = fingerprint
|
||||||
|
|
||||||
|
val lastIsOurs = lastMessage?.user_id == currentUserId
|
||||||
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE
|
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE
|
||||||
val isNearBottom = minVisibleIndex <= 2
|
val isNearBottom = minVisibleIndex <= 2
|
||||||
if (lastIsOurs || isNearBottom) {
|
if (lastIsOurs || isNearBottom) {
|
||||||
|
if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) {
|
||||||
delay(100)
|
delay(100)
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
delay(150)
|
delay(150)
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(panel) {
|
LaunchedEffect(panel) {
|
||||||
if (panel.getRecipientId() != null) {
|
if (panel.getRecipientId() != null) {
|
||||||
@@ -339,240 +356,19 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
// Do not apply safeDrawing top (or other sides) to content — we handle status bars on the
|
||||||
TopAppBar(
|
// floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
|
||||||
title = {
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.scaleOnPress(
|
|
||||||
scale = 0.96f,
|
|
||||||
onClick = if (profileUserId != null && onTitleClick != null) {
|
|
||||||
{ onTitleClick() }
|
|
||||||
} else null
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
|
|
||||||
val avatar = panelState.titleAvatar
|
|
||||||
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
|
|
||||||
?: panelState.title.takeIf { it.isNotBlank() }
|
|
||||||
?: ""
|
|
||||||
with(sharedTransitionScope) {
|
|
||||||
Avatar(
|
|
||||||
profilePictureUrl = avatar?.profilePictureUrl,
|
|
||||||
displayName = displayName,
|
|
||||||
modifier = Modifier
|
|
||||||
.sharedElement(
|
|
||||||
rememberSharedContentState(key = sharedAvatarKey),
|
|
||||||
animatedVisibilityScope = animatedVisibilityScope
|
|
||||||
)
|
|
||||||
.size(36.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
!hideTitleBarAvatar -> {
|
|
||||||
panelState.titleAvatar?.let { avatar ->
|
|
||||||
Avatar(
|
|
||||||
profilePictureUrl = avatar.profilePictureUrl,
|
|
||||||
displayName = avatar.displayName,
|
|
||||||
modifier = Modifier.size(36.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onAvatarSlotBounds != null -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
val pos = coords.positionInRoot()
|
|
||||||
val sz = coords.size
|
|
||||||
onAvatarSlotBounds(
|
|
||||||
Rect(
|
|
||||||
pos.x,
|
|
||||||
pos.y,
|
|
||||||
pos.x + sz.width.toFloat(),
|
|
||||||
pos.y + sz.height.toFloat()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
panelState.titleAvatar?.let {
|
|
||||||
Spacer(modifier = Modifier.width(36.dp))
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = panelState.title,
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
|
|
||||||
val subtitleKey = when {
|
|
||||||
!online -> "connecting"
|
|
||||||
connectionStatus == ConnectionStatus.UPDATING -> "updating"
|
|
||||||
connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
|
|
||||||
currentTypingUsers.isNotEmpty() -> "typing"
|
|
||||||
panel.usesPublicGroupSubtitle -> {
|
|
||||||
if (panelState.publicGroupMetaLoading ||
|
|
||||||
panelState.publicGroupMemberCount == null
|
|
||||||
) {
|
|
||||||
"group"
|
|
||||||
} else {
|
|
||||||
"members:${panelState.publicGroupMemberCount}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
panelState.profileUserId != null -> {
|
|
||||||
val userStatus = statusMap[panelState.profileUserId]
|
|
||||||
val statusText = userStatus?.let {
|
|
||||||
formatLastSeen(it.online, it.lastSeen, lastSeenFormat)
|
|
||||||
}.orEmpty()
|
|
||||||
if (statusText.isNotEmpty()) {
|
|
||||||
"presence:$statusText"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = subtitleKey,
|
|
||||||
transitionSpec = {
|
|
||||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
|
||||||
(slideOutVertically { -it / 2 } + fadeOut())
|
|
||||||
},
|
|
||||||
label = "chat_subtitle"
|
|
||||||
) { key ->
|
|
||||||
when {
|
|
||||||
key == "updating" -> {
|
|
||||||
val st = MaterialTheme.typography.bodySmall
|
|
||||||
val col = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = statusUpdating,
|
|
||||||
style = st,
|
|
||||||
color = col
|
|
||||||
)
|
|
||||||
ConnectingEllipsis(
|
|
||||||
fontSize = st.fontSize,
|
|
||||||
color = col,
|
|
||||||
baseStyle = st
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
key == "connecting" -> {
|
|
||||||
val st = MaterialTheme.typography.bodySmall
|
|
||||||
val col = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = statusConnecting,
|
|
||||||
style = st,
|
|
||||||
color = col
|
|
||||||
)
|
|
||||||
ConnectingEllipsis(
|
|
||||||
fontSize = st.fontSize,
|
|
||||||
color = col,
|
|
||||||
baseStyle = st
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
key == "typing" -> {
|
|
||||||
TypingIndicator(
|
|
||||||
typingUsers = currentTypingUsers.map { it.username },
|
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
key.startsWith("presence:") -> {
|
|
||||||
val text = key.removePrefix("presence:")
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
key == "group" -> {
|
|
||||||
Text(
|
|
||||||
text = chatGroupLabel,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
key.startsWith("members:") -> {
|
|
||||||
val n = key.removePrefix("members:").toIntOrNull() ?: 0
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.chat_members_count, n),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// No subtitle for this state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navController.navigateUp() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(Res.string.back)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (panel.showCallButton() && !isReadOnly) {
|
|
||||||
IconButton(onClick = { /* TODO: Handle call */ }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Call,
|
|
||||||
contentDescription = cdCall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
modifier = Modifier.hazeEffect(
|
|
||||||
state = hazeState,
|
|
||||||
style = HazeMaterials.thin()
|
|
||||||
),
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
scrolledContainerColor = Color.Transparent
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.windowInsetsPadding(WindowInsets.ime)
|
.windowInsetsPadding(WindowInsets.ime)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.hazeEffect(
|
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||||
state = hazeState,
|
|
||||||
style = HazeMaterials.thin()
|
|
||||||
) {
|
|
||||||
progressive = HazeProgressive.verticalGradient(
|
progressive = HazeProgressive.verticalGradient(
|
||||||
startIntensity = 0f,
|
startIntensity = 0f,
|
||||||
endIntensity = 1f
|
endIntensity = 1f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -668,6 +464,10 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
|
val floatingHeaderClearance = statusBarTopDp + 64.dp + 12.dp
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -687,10 +487,12 @@ fun ChatScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.hazeSource(hazeState),
|
.hazeSource(hazeState),
|
||||||
userScrollEnabled = !contextMenuState.isOpen,
|
userScrollEnabled = !contextMenuState.isOpen,
|
||||||
reverseLayout = true,
|
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