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)) + } + } + } +}