From 963451d07d37f1608ae16e5ec0e3a2392cdd6caa Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 11 May 2026 19:38:22 +0300 Subject: [PATCH] Redesign UI to Google Messages --- .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 206 ++++++----- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 19 +- .../ru/fromchat/ui/chat/ChatScreenChrome.kt | 337 ++++++++++-------- 3 files changed, 310 insertions(+), 252 deletions(-) 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 4bcc3d6..a25aee4 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 @@ -1,18 +1,17 @@ package ru.fromchat.ui.chat +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize -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.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -22,8 +21,8 @@ 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.fillMaxSize 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 @@ -41,13 +40,16 @@ 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.GraphicEq 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.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -58,49 +60,48 @@ 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.layout.ContentScale import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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 -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials -import kotlin.time.Clock import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.Message -import ru.fromchat.* +import ru.fromchat.cd_close +import ru.fromchat.cd_emoji +import ru.fromchat.cd_pick_file +import ru.fromchat.cd_pick_image +import ru.fromchat.cd_remove +import ru.fromchat.cd_send +import ru.fromchat.message_corrupted_short +import ru.fromchat.message_editing_title +import ru.fromchat.message_placeholder +import ru.fromchat.message_replying_to +import ru.fromchat.suspend_chat_banner_message +import kotlin.time.Clock -private val ChatInputChromeHeight = 46.dp +private val ChatInputChromeHeight = 54.dp private val ChatInputLineVerticalPadding = 12.dp +/** Vertical padding inside the text field decoration (keep total row height aligned with chrome). */ +private val ChatInputDecorationVerticalPadding = 8.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?, @@ -225,7 +226,6 @@ private fun AttachmentChip( } } -@OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun ChatInput( text: String, @@ -241,7 +241,9 @@ fun ChatInput( currentUserId: Int? = null, isReadOnly: Boolean = false, onReadOnlyMessageClick: () -> Unit = {}, + snackbarHostState: SnackbarHostState? = null, ) { + val composerHazeStyle = rememberChatSurfaceContainerHazeStyle() val scope = rememberCoroutineScope() var typingJob by remember { mutableStateOf(null) } var attachments by remember { mutableStateOf>(emptyList()) } @@ -273,7 +275,6 @@ fun ChatInput( if (text.isNotBlank()) { typingJob?.cancel() typingHandler.sendTyping() - @Suppress("AssignedValueIsNeverRead") typingJob = scope.launch { delay(3000) typingHandler.stopTyping() @@ -294,6 +295,7 @@ fun ChatInput( 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) + val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!" Box( modifier = Modifier @@ -308,13 +310,8 @@ fun ChatInput( Column( modifier = Modifier .fillMaxWidth() - .border( - Dp.Hairline, - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - pillShape, - ) .clip(pillShape) - .hazeEffect(state = hazeState, style = HazeMaterials.thin()) + .hazeEffect(state = hazeState, style = composerHazeStyle) .clickable(enabled = true) { onReadOnlyMessageClick() }, ) { Text( @@ -328,43 +325,25 @@ fun ChatInput( ) } } else { - val sendTransition = updateTransition( - targetState = canSend, - label = "chat_input_send_transition", - ) - - val progress by sendTransition.animateFloat( - transitionSpec = { ChatInputSendSpringFloat }, - label = "chat_input_send_progress", - ) { state -> if (state) 1f else 0f } - - val slotMax = ChatInputChromeHeight + 8.dp - val slotPadding by sendTransition.animateDp( - transitionSpec = { ChatInputSendSpringDp }, - label = "chat_input_slot_padding", - ) { state -> if (state) slotMax else 0.dp } - - Box( + Row( modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + // Inner row must not use fillMaxWidth() here: it shares this outer Row with the + // send/voice slot. fillMaxWidth() would consume all width and leave the sibling Box at 0dp. Row( - modifier = Modifier - .fillMaxWidth() - .padding(end = slotPadding), + modifier = Modifier.weight(1f), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Column( modifier = Modifier - .weight(1f) + .fillMaxWidth() .animateContentSize() - .border( - Dp.Hairline, - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - pillShape, - ) + .background(MaterialTheme.colorScheme.surfaceContainer, pillShape) .clip(pillShape) - .hazeEffect(state = hazeState, style = HazeMaterials.thin()), + .hazeEffect(state = hazeState, style = composerHazeStyle), ) { AnimatedPreviewBar(replyTo) { reply -> val replySubtitle = if (reply.isContentCorrupted) { @@ -425,7 +404,7 @@ fun ChatInput( .defaultMinSize(minHeight = ChatInputChromeHeight) .wrapContentHeight() .padding(horizontal = 6.dp, vertical = 0.dp), - verticalAlignment = Alignment.Bottom, + verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier @@ -455,7 +434,6 @@ fun ChatInput( enabled = !isReadOnly, modifier = Modifier .weight(1f) - .wrapContentHeight() .animateContentSize(), textStyle = inputTextStyle, singleLine = false, @@ -467,7 +445,7 @@ fun ChatInput( .fillMaxWidth() .padding( horizontal = ChatInputLineVerticalPadding / 2, - vertical = ChatInputLineVerticalPadding, + vertical = ChatInputDecorationVerticalPadding, ), contentAlignment = Alignment.CenterStart, ) { @@ -522,36 +500,88 @@ fun ChatInput( } } + // Fixed slot next to the pill; width must stay non-zero (see Row + fillMaxWidth pitfall above). Box( - modifier = Modifier.align(Alignment.BottomEnd), + modifier = Modifier + .size(ChatInputChromeHeight) + .clip(CircleShape), + contentAlignment = Alignment.Center, ) { - 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() - }, + AnimatedContent( + targetState = canSend, + transitionSpec = { + val exitDuration = 140 + val enterDuration = 160 + val enterDelay = exitDuration // start after outbound scale+fade completes + val scaleFrom = 0.82f + // EnterTogetherWithExit: fading/scaling incoming content while outgoing runs. + ( + fadeIn( + animationSpec = tween(enterDuration, delayMillis = enterDelay), + ) + scaleIn( + animationSpec = tween(enterDuration, delayMillis = enterDelay), + initialScale = scaleFrom, + ) + ) togetherWith ( + fadeOut(animationSpec = tween(exitDuration)) + + scaleOut( + animationSpec = tween(exitDuration), + targetScale = scaleFrom, + ) + ) + }, + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + label = "chat_input_send_or_voice", + ) { showSend -> + Box( modifier = Modifier - .size(ChatInputChromeHeight) - .offset(x = offsetX) - .alpha(alpha), - shape = CircleShape, + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)) + .hazeEffect(state = hazeState, style = composerHazeStyle), + contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Filled.ArrowUpward, - contentDescription = cdSend, - tint = MaterialTheme.colorScheme.onPrimary, - ) + if (showSend) { + FilledIconButton( + onClick = { + val plaintext = text.trim().ifBlank { "" } + onSend(plaintext, attachments) + onTextChange("") + attachments = emptyList() + typingHandler.stopTyping() + }, + modifier = Modifier.fillMaxSize(), + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = cdSend, + ) + } + } else { + IconButton( + onClick = { + scope.launch { + snackbarHostState?.showSnackbar(message = cdVoiceUnavailable) + } + }, + modifier = Modifier.fillMaxSize(), + shape = CircleShape, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Icon( + imageVector = Icons.Filled.GraphicEq, + contentDescription = cdVoiceUnavailable, + ) + } + } } } } 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 bedde72..cd2b114 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 @@ -2,6 +2,7 @@ package ru.fromchat.ui.chat import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,12 +10,11 @@ import androidx.compose.foundation.layout.BoxWithConstraints 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.navigationBars import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn @@ -55,25 +55,28 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.* import ru.fromchat.api.ApiClient -import ru.fromchat.api.ProfileCache import ru.fromchat.api.AttachmentUploadJob import ru.fromchat.api.AttachmentUploadQueue import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.Message +import ru.fromchat.api.ProfileCache import ru.fromchat.api.UserStatusStore import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketUpdatesData +import ru.fromchat.back import ru.fromchat.calls.CallStore +import ru.fromchat.cd_call +import ru.fromchat.chat_group_label import ru.fromchat.core.Logger import ru.fromchat.net.NetworkConnectivity +import ru.fromchat.status_connecting +import ru.fromchat.status_updating 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.suspension.SuspendedAccountSupportSheet import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.rememberLastSeenFormatStrings @@ -123,6 +126,7 @@ fun ChatScreen( val hazeState = rememberHazeState( blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) ) + val chatBottomHazeStyle = rememberChatSurfaceContainerHazeStyle() val currentTypingUsers = panelState.typingUsers // Directly use from panelState val statusMap by UserStatusStore.status.collectAsState() @@ -372,6 +376,7 @@ fun ChatScreen( modifier = Modifier .windowInsetsPadding(WindowInsets.ime) .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer) .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { progressive = HazeProgressive.verticalGradient( startIntensity = 0f, @@ -473,7 +478,8 @@ fun ChatScreen( ) { innerPadding -> val density = LocalDensity.current val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val floatingHeaderClearance = statusBarTopDp + 64.dp + 12.dp + val floatingHeaderClearance = + statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius Box( modifier = Modifier @@ -594,7 +600,6 @@ fun ChatScreen( callContentDescription = cdCall, titleChrome = { ChatFloatingTitleChrome( - hazeState = hazeState, title = panelState.title, titleAvatar = panelState.titleAvatar, profileUserId = profileUserId, 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 index ec2386e..85f9ed1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt @@ -9,60 +9,69 @@ 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.statusBars 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.automirrored.rounded.ArrowBack import androidx.compose.material.icons.filled.Call +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults 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.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.HazeProgressive +import com.pr0gramm3r101.utils.conditional import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint 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.chat_members_count import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.scaleOnPress +/** + * Bottom-corner arc radius for [ChatFloatingHeaderBox] silhouette. + * Matches the inset of the horizontal bottom segment (flat part starts at [this] dx from edges). + * The scallop’s vertical depth below the flat bottom is **this** value px; [ChatFloatingHeaderBox] + * adds exactly that much height below the measured [TopAppBar] so the arc is not painted over content. + */ +val ChatFloatingHeaderBottomArcRadius: Dp = 35.dp + /** * 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. @@ -72,40 +81,17 @@ import ru.fromchat.ui.scaleOnPress 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), - ) - } + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = contentDescription + ) } } -@OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun ChatFloatingTitleChrome( - hazeState: HazeState, title: String, titleAvatar: AvatarInfo?, profileUserId: Int?, @@ -122,45 +108,29 @@ fun ChatFloatingTitleChrome( 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( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier.conditional( + pillClickable, + `if` = { + Modifier.scaleOnPress( + scale = 0.96f, + onClick = onTitleClick + ) + } + ), ) { - 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() } + val displayName = titleAvatar?.displayName?.takeIf { it.isNotBlank() } ?: title.takeIf { it.isNotBlank() } ?: "" with(sharedTransitionScope) { Avatar( - profilePictureUrl = avatar?.profilePictureUrl, + profilePictureUrl = titleAvatar?.profilePictureUrl, displayName = displayName, modifier = Modifier .sharedElement( @@ -172,6 +142,7 @@ fun ChatFloatingTitleChrome( } Spacer(modifier = Modifier.width(6.dp)) } + !hideTitleBarAvatar -> { titleAvatar?.let { avatar -> Avatar( @@ -182,6 +153,7 @@ fun ChatFloatingTitleChrome( Spacer(modifier = Modifier.width(6.dp)) } } + onAvatarSlotBounds != null -> { Box( modifier = Modifier @@ -201,6 +173,7 @@ fun ChatFloatingTitleChrome( ) Spacer(modifier = Modifier.width(8.dp)) } + else -> { titleAvatar?.let { Spacer(modifier = Modifier.width(40.dp)) @@ -226,7 +199,7 @@ fun ChatFloatingTitleChrome( targetState = subtitleKey, transitionSpec = { (slideInVertically { it / 2 } + fadeIn()) togetherWith - (slideOutVertically { -it / 2 } + fadeOut()) + (slideOutVertically { -it / 2 } + fadeOut()) }, label = "chat_subtitle", ) { key -> @@ -250,6 +223,7 @@ fun ChatFloatingTitleChrome( ) } } + key == "connecting" -> { val st = MaterialTheme.typography.bodySmall val col = MaterialTheme.colorScheme.onSurfaceVariant @@ -269,12 +243,14 @@ fun ChatFloatingTitleChrome( ) } } + key == "typing" -> { TypingIndicator( typingUsers = currentTypingUsers.map { it.username }, modifier = Modifier.padding(top = 2.dp), ) } + key.startsWith("presence:") -> { val text = key.removePrefix("presence:") Text( @@ -284,6 +260,7 @@ fun ChatFloatingTitleChrome( modifier = Modifier.padding(top = 2.dp), ) } + key == "group" -> { Text( text = chatGroupLabel, @@ -292,6 +269,7 @@ fun ChatFloatingTitleChrome( modifier = Modifier.padding(top = 2.dp), ) } + key.startsWith("members:") -> { val n = key.removePrefix("members:").toIntOrNull() ?: 0 Text( @@ -304,11 +282,10 @@ fun ChatFloatingTitleChrome( } } } - } } } -@OptIn(ExperimentalHazeMaterialsApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatFloatingHeaderBox( hazeState: HazeState, @@ -320,90 +297,136 @@ fun ChatFloatingHeaderBox( titleChrome: @Composable () -> Unit, modifier: Modifier = Modifier, ) { - val sideSlot = 56.dp + val containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f) + val headerHazeStyle = rememberChatSurfaceContainerHazeStyle() + val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius val density = LocalDensity.current - val statusBarDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val blurPlateHeight = statusBarDp + 64.dp + 12.dp + val shape = remember(bottomCornerRadius) { BottomInsetTopBarShape(bottomCornerRadius) } + // [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r); + // stack measured bar height + that depth so the scallop is never overlapped by TopAppBar children. + val arcExtentPx = with(density) { bottomCornerRadius.roundToPx() } - 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)) { + SubcomposeLayout(modifier = modifier.fillMaxWidth()) { constraints -> + val heightUnbounded = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity) + val topBarPlaceable = subcompose("topBar") { + TopAppBar( + modifier = Modifier.fillMaxWidth(), + windowInsets = WindowInsets.statusBars, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + navigationIcon = { ChatFloatingBackButton( contentDescription = backContentDescription, onClick = onBack, - modifier = Modifier - .fillMaxSize() - .aspectRatio(1f, matchHeightConstraintsFirst = true), ) - } - } + }, + title = { titleChrome() }, + actions = { + if (showCallButton) { + IconButton(onClick = onCallClick) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = callContentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + } + } + }, + ) + }.first().measure(heightUnbounded) + + val layoutWidth = topBarPlaceable.width.coerceIn(constraints.minWidth, constraints.maxWidth) + val layoutHeight = topBarPlaceable.height + arcExtentPx + + val bgPlaceable = subcompose("background") { 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)) - } + .fillMaxSize() + .clip(shape) + .background(containerColor) + .hazeEffect(state = hazeState, style = headerHazeStyle), + ) + }.first().measure(Constraints.fixed(layoutWidth, layoutHeight)) + + layout(layoutWidth, layoutHeight) { + bgPlaceable.place(0, 0) + topBarPlaceable.place(0, 0) } } } + +/** Cubic-fit constant for a quarter circle: `4/3 * tan(pi/8)` (~0.5522847498). */ +private const val CircularCornerBezierKappa = 0.5522847498f + +/** + * Bottom edge: inset horizontal segment (`y = height − r`), then quarter-circle corners with + * circle centers **`(r, height)` / `(width − r, height)`** — same as the SVG (`M r y … C …`). + * + * Compose's [Path.arcTo] uses an ellipse inscribed in `Rect` whose **center is rect center**, so it + * cannot express these corners reliably; cubic segments match the arcs instead. + */ +private class BottomInsetTopBarShape( + private val cornerRadius: Dp, +) : androidx.compose.ui.graphics.Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val w = size.width + val h = size.height + val r = with(density) { cornerRadius.toPx() }.coerceIn(0f, minOf(w / 2f, h)) + val straightY = (h - r).coerceAtLeast(0f) + val k = CircularCornerBezierKappa * r + + val path = Path().apply { + moveTo(r, straightY) + lineTo(w - r, straightY) + // Bottom-right: center (w - r, h), arc from (w - r, straightY) to (w, h). + cubicTo( + x1 = w - r + k, + y1 = straightY, + x2 = w, + y2 = h - k, + x3 = w, + y3 = h, + ) + lineTo(w, 0f) + lineTo(0f, 0f) + lineTo(0f, h) + // Bottom-left: center (r, h), arc from (0, h) to (r, straightY). + cubicTo( + x1 = 0f, + y1 = h - k, + x2 = r - k, + y2 = straightY, + x3 = r, + y3 = straightY, + ) + close() + } + + return Outline.Generic(path) + } +} + +/** + * [HazeStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer] + * (24dp blur + luminance-scaled tint, slightly denser than old “thin” defaults), without [dev.chrisbanes.haze.materials.HazeMaterials]. + */ +@Composable +fun rememberChatSurfaceContainerHazeStyle(): HazeStyle { + val surface = MaterialTheme.colorScheme.surfaceContainer + val tintAlpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f + return remember(surface) { + HazeStyle( + blurRadius = 24.dp, + backgroundColor = surface, + tint = HazeTint(surface.copy(alpha = tintAlpha)), + ) + } +}