Redesign UI to Google Messages

This commit is contained in:
2026-05-11 19:38:22 +03:00
Unverified
parent 5262c15f48
commit 963451d07d
3 changed files with 310 additions and 252 deletions
@@ -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<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
private val ChatInputSendSpringDp = spring<Dp>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
@Composable
private fun <T> AnimatedPreviewBar(
state: T?,
@@ -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<kotlinx.coroutines.Job?>(null) }
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(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(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = slotPadding),
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.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,17 +500,48 @@ 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)
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
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect(state = hazeState, style = composerHazeStyle),
contentAlignment = Alignment.Center,
) {
if (showSend) {
FilledIconButton(
onClick = {
val plaintext = text.trim().ifBlank { "" }
@@ -541,16 +550,35 @@ fun ChatInput(
attachments = emptyList()
typingHandler.stopTyping()
},
modifier = Modifier
.size(ChatInputChromeHeight)
.offset(x = offsetX)
.alpha(alpha),
modifier = Modifier.fillMaxSize(),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary,
),
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = cdSend,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} 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,
)
}
}
@@ -559,4 +587,6 @@ fun ChatInput(
}
}
}
}
}
@@ -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,
@@ -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 scallops 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,
) {
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBackIos,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(28.dp),
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(
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,
modifier = modifier.conditional(
pillClickable,
`if` = {
Modifier.scaleOnPress(
scale = 0.96f,
onClick = onTitleClick
)
}
),
) {
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))
@@ -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(
@@ -306,9 +284,8 @@ 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),
)
}
}
Box(
modifier = Modifier
.weight(1f)
.wrapContentHeight(),
contentAlignment = Alignment.Center,
) {
titleChrome()
}
},
title = { titleChrome() },
actions = {
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,
) {
IconButton(onClick = onCallClick) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = callContentDescription,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(28.dp),
modifier = Modifier.size(24.dp),
)
}
}
} else {
Spacer(modifier = Modifier.width(sideSlot))
},
)
}.first().measure(heightUnbounded)
val layoutWidth = topBarPlaceable.width.coerceIn(constraints.minWidth, constraints.maxWidth)
val layoutHeight = topBarPlaceable.height + arcExtentPx
val bgPlaceable = subcompose("background") {
Box(
modifier = Modifier
.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)),
)
}
}