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 package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement 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.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -41,13 +40,16 @@ import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.outlined.AttachFile import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.SentimentSatisfied import androidx.compose.material.icons.outlined.SentimentSatisfied
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -58,49 +60,48 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlin.time.Clock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.Message 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 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 ChatInputTextLineHeight = 22.sp
private val ChatInputIconSlotSize = 36.dp private val ChatInputIconSlotSize = 36.dp
private val ChatInputIconSlotVerticalInset = private val ChatInputIconSlotVerticalInset =
(ChatInputChromeHeight - ChatInputIconSlotSize) / 2f (ChatInputChromeHeight - ChatInputIconSlotSize) / 2f
private val ChatInputSendSpringFloat = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
private val ChatInputSendSpringDp = spring<Dp>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
@Composable @Composable
private fun <T> AnimatedPreviewBar( private fun <T> AnimatedPreviewBar(
state: T?, state: T?,
@@ -225,7 +226,6 @@ private fun AttachmentChip(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun ChatInput( fun ChatInput(
text: String, text: String,
@@ -241,7 +241,9 @@ fun ChatInput(
currentUserId: Int? = null, currentUserId: Int? = null,
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {}, onReadOnlyMessageClick: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null,
) { ) {
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) } var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) }
@@ -273,7 +275,6 @@ fun ChatInput(
if (text.isNotBlank()) { if (text.isNotBlank()) {
typingJob?.cancel() typingJob?.cancel()
typingHandler.sendTyping() typingHandler.sendTyping()
@Suppress("AssignedValueIsNeverRead")
typingJob = scope.launch { typingJob = scope.launch {
delay(3000) delay(3000)
typingHandler.stopTyping() typingHandler.stopTyping()
@@ -294,6 +295,7 @@ fun ChatInput(
val corruptedShort = stringResource(Res.string.message_corrupted_short) val corruptedShort = stringResource(Res.string.message_corrupted_short)
val editingTitle = stringResource(Res.string.message_editing_title) val editingTitle = stringResource(Res.string.message_editing_title)
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message) val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!"
Box( Box(
modifier = Modifier modifier = Modifier
@@ -308,13 +310,8 @@ fun ChatInput(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.border(
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
pillShape,
)
.clip(pillShape) .clip(pillShape)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) .hazeEffect(state = hazeState, style = composerHazeStyle)
.clickable(enabled = true) { onReadOnlyMessageClick() }, .clickable(enabled = true) { onReadOnlyMessageClick() },
) { ) {
Text( Text(
@@ -328,43 +325,25 @@ fun ChatInput(
) )
} }
} else { } 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( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() verticalAlignment = Alignment.Bottom,
.padding(end = slotPadding), 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, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.animateContentSize() .animateContentSize()
.border( .background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
pillShape,
)
.clip(pillShape) .clip(pillShape)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()), .hazeEffect(state = hazeState, style = composerHazeStyle),
) { ) {
AnimatedPreviewBar(replyTo) { reply -> AnimatedPreviewBar(replyTo) { reply ->
val replySubtitle = if (reply.isContentCorrupted) { val replySubtitle = if (reply.isContentCorrupted) {
@@ -425,7 +404,7 @@ fun ChatInput(
.defaultMinSize(minHeight = ChatInputChromeHeight) .defaultMinSize(minHeight = ChatInputChromeHeight)
.wrapContentHeight() .wrapContentHeight()
.padding(horizontal = 6.dp, vertical = 0.dp), .padding(horizontal = 6.dp, vertical = 0.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -455,7 +434,6 @@ fun ChatInput(
enabled = !isReadOnly, enabled = !isReadOnly,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.wrapContentHeight()
.animateContentSize(), .animateContentSize(),
textStyle = inputTextStyle, textStyle = inputTextStyle,
singleLine = false, singleLine = false,
@@ -467,7 +445,7 @@ fun ChatInput(
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
horizontal = ChatInputLineVerticalPadding / 2, horizontal = ChatInputLineVerticalPadding / 2,
vertical = ChatInputLineVerticalPadding, vertical = ChatInputDecorationVerticalPadding,
), ),
contentAlignment = Alignment.CenterStart, 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( Box(
modifier = Modifier.align(Alignment.BottomEnd), modifier = Modifier
.size(ChatInputChromeHeight)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) { ) {
if (progress > 0.01f) { AnimatedContent(
val alpha by sendTransition.animateFloat( targetState = canSend,
transitionSpec = { ChatInputSendSpringFloat }, transitionSpec = {
label = "chat_input_send_alpha", val exitDuration = 140
) { state -> if (state) 1f else 0f } val enterDuration = 160
val travel = ChatInputChromeHeight * 1.2f val enterDelay = exitDuration // start after outbound scale+fade completes
val offsetX = travel * (1f - progress) 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( FilledIconButton(
onClick = { onClick = {
val plaintext = text.trim().ifBlank { "" } val plaintext = text.trim().ifBlank { "" }
@@ -541,18 +550,39 @@ fun ChatInput(
attachments = emptyList() attachments = emptyList()
typingHandler.stopTyping() typingHandler.stopTyping()
}, },
modifier = Modifier modifier = Modifier.fillMaxSize(),
.size(ChatInputChromeHeight)
.offset(x = offsetX)
.alpha(alpha),
shape = CircleShape, shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary,
),
) { ) {
Icon( Icon(
imageVector = Icons.Filled.ArrowUpward, imageVector = Icons.Filled.ArrowUpward,
contentDescription = cdSend, 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,
)
}
}
}
} }
} }
} }
@@ -2,6 +2,7 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -9,12 +10,11 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -55,25 +55,28 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.AttachmentUploadJob import ru.fromchat.api.AttachmentUploadJob
import ru.fromchat.api.AttachmentUploadQueue import ru.fromchat.api.AttachmentUploadQueue
import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStateStore
import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.ConnectionStatus
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserStatusStore import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.calls.CallStore import ru.fromchat.calls.CallStore
import ru.fromchat.cd_call
import ru.fromchat.chat_group_label
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.status_connecting
import ru.fromchat.status_updating
import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.chat.getImageAspectRatio
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.rememberLastSeenFormatStrings import ru.fromchat.utils.rememberLastSeenFormatStrings
@@ -123,6 +126,7 @@ fun ChatScreen(
val hazeState = rememberHazeState( val hazeState = rememberHazeState(
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
) )
val chatBottomHazeStyle = rememberChatSurfaceContainerHazeStyle()
val currentTypingUsers = panelState.typingUsers // Directly use from panelState val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
@@ -372,6 +376,7 @@ fun ChatScreen(
modifier = Modifier modifier = Modifier
.windowInsetsPadding(WindowInsets.ime) .windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { .hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient( progressive = HazeProgressive.verticalGradient(
startIntensity = 0f, startIntensity = 0f,
@@ -473,7 +478,8 @@ fun ChatScreen(
) { innerPadding -> ) { innerPadding ->
val density = LocalDensity.current val density = LocalDensity.current
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } 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( Box(
modifier = Modifier modifier = Modifier
@@ -594,7 +600,6 @@ fun ChatScreen(
callContentDescription = cdCall, callContentDescription = cdCall,
titleChrome = { titleChrome = {
ChatFloatingTitleChrome( ChatFloatingTitleChrome(
hazeState = hazeState,
title = panelState.title, title = panelState.title,
titleAvatar = panelState.titleAvatar, titleAvatar = panelState.titleAvatar,
profileUserId = profileUserId, profileUserId = profileUserId,
@@ -9,60 +9,69 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width 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.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.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.material.icons.filled.Call
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.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.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow 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.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp 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.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.* import ru.fromchat.chat_members_count
import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.scaleOnPress 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), * 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. * 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( fun ChatFloatingBackButton(
contentDescription: String, contentDescription: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val shape = CircleShape IconButton(onClick = onClick) {
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( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBackIos, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = contentDescription, contentDescription = contentDescription
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(28.dp),
) )
} }
}
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun ChatFloatingTitleChrome( fun ChatFloatingTitleChrome(
hazeState: HazeState,
title: String, title: String,
titleAvatar: AvatarInfo?, titleAvatar: AvatarInfo?,
profileUserId: Int?, profileUserId: Int?,
@@ -122,45 +108,29 @@ fun ChatFloatingTitleChrome(
chatGroupLabel: String, chatGroupLabel: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pillShape = RoundedCornerShape(percent = 50)
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
val pillClickable = profileUserId != null && onTitleClick != null 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( 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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
modifier = modifier.conditional(
pillClickable,
`if` = {
Modifier.scaleOnPress(
scale = 0.96f,
onClick = onTitleClick
)
}
),
) { ) {
when { when {
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> { sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
val avatar = titleAvatar val displayName = titleAvatar?.displayName?.takeIf { it.isNotBlank() }
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: title.takeIf { it.isNotBlank() } ?: title.takeIf { it.isNotBlank() }
?: "" ?: ""
with(sharedTransitionScope) { with(sharedTransitionScope) {
Avatar( Avatar(
profilePictureUrl = avatar?.profilePictureUrl, profilePictureUrl = titleAvatar?.profilePictureUrl,
displayName = displayName, displayName = displayName,
modifier = Modifier modifier = Modifier
.sharedElement( .sharedElement(
@@ -172,6 +142,7 @@ fun ChatFloatingTitleChrome(
} }
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
!hideTitleBarAvatar -> { !hideTitleBarAvatar -> {
titleAvatar?.let { avatar -> titleAvatar?.let { avatar ->
Avatar( Avatar(
@@ -182,6 +153,7 @@ fun ChatFloatingTitleChrome(
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
} }
onAvatarSlotBounds != null -> { onAvatarSlotBounds != null -> {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -201,6 +173,7 @@ fun ChatFloatingTitleChrome(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
else -> { else -> {
titleAvatar?.let { titleAvatar?.let {
Spacer(modifier = Modifier.width(40.dp)) Spacer(modifier = Modifier.width(40.dp))
@@ -250,6 +223,7 @@ fun ChatFloatingTitleChrome(
) )
} }
} }
key == "connecting" -> { key == "connecting" -> {
val st = MaterialTheme.typography.bodySmall val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant val col = MaterialTheme.colorScheme.onSurfaceVariant
@@ -269,12 +243,14 @@ fun ChatFloatingTitleChrome(
) )
} }
} }
key == "typing" -> { key == "typing" -> {
TypingIndicator( TypingIndicator(
typingUsers = currentTypingUsers.map { it.username }, typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
key.startsWith("presence:") -> { key.startsWith("presence:") -> {
val text = key.removePrefix("presence:") val text = key.removePrefix("presence:")
Text( Text(
@@ -284,6 +260,7 @@ fun ChatFloatingTitleChrome(
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
key == "group" -> { key == "group" -> {
Text( Text(
text = chatGroupLabel, text = chatGroupLabel,
@@ -292,6 +269,7 @@ fun ChatFloatingTitleChrome(
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
key.startsWith("members:") -> { key.startsWith("members:") -> {
val n = key.removePrefix("members:").toIntOrNull() ?: 0 val n = key.removePrefix("members:").toIntOrNull() ?: 0
Text( Text(
@@ -305,10 +283,9 @@ fun ChatFloatingTitleChrome(
} }
} }
} }
}
} }
@OptIn(ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatFloatingHeaderBox( fun ChatFloatingHeaderBox(
hazeState: HazeState, hazeState: HazeState,
@@ -320,90 +297,136 @@ fun ChatFloatingHeaderBox(
titleChrome: @Composable () -> Unit, titleChrome: @Composable () -> Unit,
modifier: Modifier = Modifier, 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 density = LocalDensity.current
val statusBarDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val shape = remember(bottomCornerRadius) { BottomInsetTopBarShape(bottomCornerRadius) }
val blurPlateHeight = statusBarDp + 64.dp + 12.dp // [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( SubcomposeLayout(modifier = modifier.fillMaxWidth()) { constraints ->
modifier = modifier val heightUnbounded = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
.fillMaxWidth() val topBarPlaceable = subcompose("topBar") {
.wrapContentHeight(), TopAppBar(
) { modifier = Modifier.fillMaxWidth(),
Box( windowInsets = WindowInsets.statusBars,
modifier = Modifier colors = TopAppBarDefaults.topAppBarColors(
.fillMaxWidth() containerColor = Color.Transparent,
.height(blurPlateHeight) scrolledContainerColor = Color.Transparent,
.align(Alignment.TopCenter) titleContentColor = MaterialTheme.colorScheme.onSurface,
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { ),
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f) navigationIcon = {
},
)
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( ChatFloatingBackButton(
contentDescription = backContentDescription, contentDescription = backContentDescription,
onClick = onBack, onClick = onBack,
modifier = Modifier
.fillMaxSize()
.aspectRatio(1f, matchHeightConstraintsFirst = true),
) )
} },
} title = { titleChrome() },
Box( actions = {
modifier = Modifier
.weight(1f)
.wrapContentHeight(),
contentAlignment = Alignment.Center,
) {
titleChrome()
}
if (showCallButton) { if (showCallButton) {
val shape = CircleShape IconButton(onClick = onCallClick) {
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( Icon(
imageVector = Icons.Default.Call, imageVector = Icons.Default.Call,
contentDescription = callContentDescription, contentDescription = callContentDescription,
tint = MaterialTheme.colorScheme.onSurface, 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)),
)
}
}