diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 605a563..4594532 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -60,6 +60,7 @@
Выбрать фото
Выбрать файл
Отправить
+ Эмодзи
Профиль
Не получилось загрузить профиль
Профиль не найден
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index 97f6b52..05196fa 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -77,6 +77,7 @@
Choose photo
Choose file
Send
+ Emoji
Profile
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
index b74f0e0..4bcc3d6 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
@@ -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(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMedium,
+)
+private val ChatInputSendSpringDp = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMedium,
+)
+
@Composable
private fun 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(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)
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .border(
- Dp.Hairline,
- MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
- shape
- )
- .clip(shape)
- .hazeEffect(
- state = hazeState,
- style = HazeMaterials.thin()
- )
- .clickable(enabled = isReadOnly) {
- if (isReadOnly) {
- onReadOnlyMessageClick()
- }
- }
- ) {
- if (isReadOnly) {
+ if (isReadOnly) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(
+ Dp.Hairline,
+ MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
+ pillShape,
+ )
+ .clip(pillShape)
+ .hazeEffect(state = hazeState, style = HazeMaterials.thin())
+ .clickable(enabled = true) { onReadOnlyMessageClick() },
+ ) {
Text(
text = blockedMessage,
style = MaterialTheme.typography.bodyLarge,
@@ -303,150 +324,239 @@ 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) {
- corruptedShort
- } else {
- replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else ""
- }
- val replyName = messageDisplayUsername(replyTo, currentUserId)
- PreviewBar(
- icon = Icons.AutoMirrored.Filled.Reply,
- title = stringResource(Res.string.message_replying_to, replyName),
- subtitle = replySubtitle,
- closeContentDescription = cdClose,
- onClose = { onClearReply() }
- )
- }
+ }
+ } else {
+ val sendTransition = updateTransition(
+ targetState = canSend,
+ label = "chat_input_send_transition",
+ )
- AnimatedPreviewBar(editingMessage) { message ->
- val subtitle = if (message.isContentCorrupted) {
- corruptedShort
- } else {
- message.content.take(50) + if (message.content.length > 50) "..." else ""
- }
- PreviewBar(
- icon = Icons.Filled.Edit,
- title = editingTitle,
- subtitle = subtitle,
- closeContentDescription = cdClose,
- onClose = { onClearEdit() }
- )
- }
+ val progress by sendTransition.animateFloat(
+ transitionSpec = { ChatInputSendSpringFloat },
+ label = "chat_input_send_progress",
+ ) { state -> if (state) 1f else 0f }
- AnimatedVisibility(
- visible = attachments.isNotEmpty(),
- enter = fadeIn() + expandVertically(),
- exit = fadeOut() + shrinkVertically()
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .horizontalScroll(rememberScrollState())
- .padding(start = 12.dp, end = 12.dp, top = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- attachments.forEach { attachment ->
- AttachmentChip(
- attachment = attachment,
- onRemove = { attachments = attachments.filter { it.id != attachment.id } },
- removeContentDescription = cdRemove
- )
- }
- }
- }
+ 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(),
- verticalAlignment = Alignment.Bottom
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = slotPadding),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- if (recipientId != null && !isReadOnly) {
- IconButton(onClick = { launchImagePicker() }) {
- Icon(
- imageVector = Icons.Default.Image,
- contentDescription = cdPickImage,
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- IconButton(onClick = { launchFilePicker() }) {
- Icon(
- imageVector = Icons.Default.AttachFile,
- contentDescription = cdPickFile,
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- OutlinedTextField(
- value = text,
- onValueChange = onTextChange,
- enabled = !isReadOnly,
+ Column(
modifier = Modifier
.weight(1f)
- .animateContentSize(),
- placeholder = {
- Text(
- text = stringResource(Res.string.message_placeholder),
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ .animateContentSize()
+ .border(
+ Dp.Hairline,
+ MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
+ pillShape,
)
- },
- 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() }
+ .clip(pillShape)
+ .hazeEffect(state = hazeState, style = HazeMaterials.thin()),
+ ) {
+ AnimatedPreviewBar(replyTo) { reply ->
+ val replySubtitle = if (reply.isContentCorrupted) {
+ corruptedShort
+ } else {
+ reply.content.take(50) + if (reply.content.length > 50) "..." else ""
+ }
+ 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() },
+ )
+ }
- AnimatedVisibility(
- visible = canSend,
- enter = slideInHorizontally(
- initialOffsetX = { it + offset },
- animationSpec = tween(durationMillis = 300)
- ),
- exit = slideOutHorizontally(
- targetOffsetX = { it + offset },
- animationSpec = tween(durationMillis = 200)
- )
+ AnimatedPreviewBar(editingMessage) { message ->
+ val subtitle = if (message.isContentCorrupted) {
+ corruptedShort
+ } else {
+ message.content.take(50) + if (message.content.length > 50) "..." else ""
+ }
+ PreviewBar(
+ icon = Icons.Filled.Edit,
+ title = editingTitle,
+ subtitle = subtitle,
+ closeContentDescription = cdClose,
+ onClose = { onClearEdit() },
+ )
+ }
+
+ AnimatedVisibility(
+ visible = attachments.isNotEmpty(),
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- Box(Modifier.padding(end = 5.dp)) {
- FilledIconButton(
- onClick = {
- if (isReadOnly) {
- return@FilledIconButton
- }
- val plaintext = text.trim().ifBlank { "" }
- onSend(plaintext, attachments)
- onTextChange("")
- attachments = emptyList()
- typingHandler.stopTyping()
- },
- modifier = Modifier.size(36.dp)
- ) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.Send,
- contentDescription = cdSend,
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(18.dp)
- )
- }
+ attachments.forEach { attachment ->
+ AttachmentChip(
+ attachment = attachment,
+ onRemove = { attachments = attachments.filter { it.id != attachment.id } },
+ removeContentDescription = cdRemove,
+ )
}
}
}
- )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = ChatInputChromeHeight)
+ .wrapContentHeight()
+ .padding(horizontal = 6.dp, vertical = 0.dp),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(vertical = ChatInputIconSlotVerticalInset)
+ .size(ChatInputIconSlotSize)
+ .clip(CircleShape)
+ .clickable(onClick = { /* emoji picker to be wired */ }),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.SentimentSatisfied,
+ contentDescription = cdEmoji,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
+ TextStyle(
+ color = MaterialTheme.colorScheme.onSurface,
+ lineHeight = ChatInputTextLineHeight,
+ ),
+ )
+
+ BasicTextField(
+ value = text,
+ onValueChange = onTextChange,
+ enabled = !isReadOnly,
+ modifier = Modifier
+ .weight(1f)
+ .wrapContentHeight()
+ .animateContentSize(),
+ textStyle = inputTextStyle,
+ singleLine = false,
+ maxLines = 5,
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ decorationBox = { innerTextField ->
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = ChatInputLineVerticalPadding / 2,
+ vertical = ChatInputLineVerticalPadding,
+ ),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (text.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.message_placeholder),
+ style = inputTextStyle.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ softWrap = false,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+
+ if (recipientId != null) {
+ Box(
+ modifier = Modifier
+ .padding(vertical = ChatInputIconSlotVerticalInset)
+ .size(ChatInputIconSlotSize)
+ .clip(CircleShape)
+ .clickable { launchImagePicker() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Image,
+ contentDescription = cdPickImage,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Box(
+ modifier = Modifier
+ .padding(vertical = ChatInputIconSlotVerticalInset)
+ .size(ChatInputIconSlotSize)
+ .clip(CircleShape)
+ .clickable { launchFilePicker() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.AttachFile,
+ contentDescription = cdPickFile,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier.align(Alignment.BottomEnd),
+ ) {
+ if (progress > 0.01f) {
+ val alpha by sendTransition.animateFloat(
+ transitionSpec = { ChatInputSendSpringFloat },
+ label = "chat_input_send_alpha",
+ ) { state -> if (state) 1f else 0f }
+ val travel = ChatInputChromeHeight * 1.2f
+ val offsetX = travel * (1f - progress)
+
+ FilledIconButton(
+ onClick = {
+ val plaintext = text.trim().ifBlank { "" }
+ onSend(plaintext, attachments)
+ onTextChange("")
+ attachments = emptyList()
+ typingHandler.stopTyping()
+ },
+ modifier = Modifier
+ .size(ChatInputChromeHeight)
+ .offset(x = offsetX)
+ .alpha(alpha),
+ shape = CircleShape,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.ArrowUpward,
+ contentDescription = cdSend,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ }
}
}
}
}
-}
\ No newline at end of file
+}
+
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
index 961808c..9f913ff 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -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,27 +296,37 @@ 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) {
- delay(100)
- listState.animateScrollToItem(0)
- delay(150)
- listState.animateScrollToItem(0)
+ if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) {
+ delay(100)
+ listState.animateScrollToItem(0)
+ delay(150)
+ listState.animateScrollToItem(0)
+ }
}
}
@@ -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,15 +487,17 @@ fun ChatScreen(
CircularProgressIndicator()
}
} else {
- LazyColumn(
- state = listState,
- modifier = Modifier
- .fillMaxSize()
- .hazeSource(hazeState),
- userScrollEnabled = !contextMenuState.isOpen,
- reverseLayout = true,
- verticalArrangement = Arrangement.spacedBy(4.dp)
- ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .hazeSource(hazeState),
+ userScrollEnabled = !contextMenuState.isOpen,
+ reverseLayout = true,
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
items(
@@ -768,7 +570,37 @@ fun ChatScreen(
)
}
- item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
+ item { Spacer(Modifier.height(floatingHeaderClearance)) }
+ }
+
+ ChatFloatingHeaderBox(
+ hazeState = hazeState,
+ onBack = { navController.navigateUp() },
+ backContentDescription = stringResource(Res.string.back),
+ showCallButton = panel.showCallButton() && !isReadOnly,
+ onCallClick = { /* TODO: Handle call */ },
+ callContentDescription = cdCall,
+ titleChrome = {
+ ChatFloatingTitleChrome(
+ hazeState = hazeState,
+ title = panelState.title,
+ titleAvatar = panelState.titleAvatar,
+ profileUserId = profileUserId,
+ onTitleClick = onTitleClick,
+ hideTitleBarAvatar = hideTitleBarAvatar,
+ onAvatarSlotBounds = onAvatarSlotBounds,
+ sharedTransitionScope = sharedTransitionScope,
+ animatedVisibilityScope = animatedVisibilityScope,
+ sharedAvatarKey = sharedAvatarKey,
+ subtitleKey = subtitleKey,
+ currentTypingUsers = currentTypingUsers,
+ statusConnecting = statusConnecting,
+ statusUpdating = statusUpdating,
+ chatGroupLabel = chatGroupLabel,
+ )
+ },
+ modifier = Modifier.align(Alignment.TopCenter),
+ )
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt
new file mode 100644
index 0000000..ec2386e
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt
@@ -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,
+ 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))
+ }
+ }
+ }
+}