diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index b153ab1..4463cdd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -57,6 +57,18 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.* +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.draw.shadow +import androidx.compose.animation.core.CubicBezierEasing + +// Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910) +val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) /** * Модель сообщения (Legacy - для совместимости) @@ -122,7 +134,7 @@ fun ChatDetailScreen( ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + val backgroundColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFEFEFF3) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) @@ -131,7 +143,7 @@ fun ChatDetailScreen( var isVisible by remember { mutableStateOf(false) } val screenAlpha by animateFloatAsState( targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), label = "screenFade" ) @@ -242,16 +254,16 @@ fun ChatDetailScreen( .fillMaxSize() .graphicsLayer { alpha = screenAlpha } ) { - // Цвета для матового стекла + // Цвета для матового стекла (более прозрачные для лучшего blur эффекта) val glassHeaderBackground = if (isDarkTheme) - Color(0xFF1A1A1A).copy(alpha = 0.85f) + Color(0xFF1A1A1A).copy(alpha = 0.7f) else - Color(0xFFF5F5F5).copy(alpha = 0.85f) + Color(0xFFF5F5F5).copy(alpha = 0.7f) val glassInputPanelBackground = if (isDarkTheme) - Color(0xFF1A1A1A).copy(alpha = 0.88f) + Color(0xFF1A1A1A).copy(alpha = 0.75f) else - Color(0xFFF5F5F5).copy(alpha = 0.88f) + Color(0xFFF5F5F5).copy(alpha = 0.75f) Scaffold( topBar = { @@ -260,6 +272,7 @@ fun ChatDetailScreen( modifier = Modifier .fillMaxWidth() .background(glassHeaderBackground) + .blur(radius = 20.dp) // Blur эффект для frosted glass ) { Row( modifier = Modifier @@ -554,24 +567,223 @@ fun ChatDetailScreen( } /** - * 🚀 Пузырек сообщения с fade-in анимацией (только при первом появлении) + * 🚀 Анимация появления сообщения Telegram-style + */ +@Composable +fun rememberMessageEnterAnimation(messageId: String): Pair { + var animationPlayed by remember(messageId) { mutableStateOf(false) } + + val alpha by animateFloatAsState( + targetValue = if (animationPlayed) 1f else 0f, + animationSpec = tween( + durationMillis = 250, + easing = TelegramEasing + ), + label = "messageAlpha" + ) + + val translationY by animateFloatAsState( + targetValue = if (animationPlayed) 0f else 20f, + animationSpec = tween( + durationMillis = 250, + easing = TelegramEasing + ), + label = "messageTranslationY" + ) + + LaunchedEffect(messageId) { + delay(16) // One frame delay + animationPlayed = true + } + + return Pair(alpha, translationY) +} + +/** + * 🚀 Telegram-style bubble shape с хвостиком + */ +class TelegramBubbleShape( + private val isOutgoing: Boolean, + private val radius: Dp = 18.dp, + private val tailSize: Dp = 6.dp +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val path = Path() + val radiusPx = with(density) { radius.toPx() } + val tailSizePx = with(density) { tailSize.toPx() } + val padding = with(density) { 2.dp.toPx() } + + if (isOutgoing) { + // Исходящее сообщение - хвостик справа внизу + // Начинаем с правого нижнего угла (перед хвостиком) + path.moveTo(size.width - 2.6f * density.density, size.height - padding) + + // Линия к левому нижнему углу + path.lineTo(padding + radiusPx, size.height - padding) + + // Левый нижний угол + path.arcTo( + rect = Rect( + left = padding, + top = size.height - padding - radiusPx * 2, + right = padding + radiusPx * 2, + bottom = size.height - padding + ), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Левая сторона вверх + path.lineTo(padding, padding + radiusPx) + + // Левый верхний угол + path.arcTo( + rect = Rect( + left = padding, + top = padding, + right = padding + radiusPx * 2, + bottom = padding + radiusPx * 2 + ), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Верхняя сторона вправо + path.lineTo(size.width - 8.dp.toPx() * density.density - radiusPx, padding) + + // Правый верхний угол (с небольшим отступом для хвостика) + path.arcTo( + rect = Rect( + left = size.width - 8.dp.toPx() * density.density - radiusPx * 2, + top = padding, + right = size.width - 8.dp.toPx() * density.density, + bottom = padding + radiusPx * 2 + ), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Правая сторона вниз (до хвостика) + path.lineTo( + size.width - 8.dp.toPx() * density.density, + size.height - padding - tailSizePx - 3.dp.toPx() * density.density + ) + + // Хвостик (маленькая дуга) + path.arcTo( + rect = Rect( + left = size.width - 8.dp.toPx() * density.density, + top = size.height - padding - tailSizePx * 2 - 9.dp.toPx() * density.density, + right = size.width - 7.dp.toPx() * density.density + tailSizePx * 2, + bottom = size.height - padding - 1.dp.toPx() * density.density + ), + startAngleDegrees = 180f, + sweepAngleDegrees = -83f, + forceMoveTo = false + ) + } else { + // Входящее сообщение - хвостик слева внизу + path.moveTo(2.6f * density.density, size.height - padding) + + // Линия к правому нижнему углу + path.lineTo(size.width - padding - radiusPx, size.height - padding) + + // Правый нижний угол + path.arcTo( + rect = Rect( + left = size.width - padding - radiusPx * 2, + top = size.height - padding - radiusPx * 2, + right = size.width - padding, + bottom = size.height - padding + ), + startAngleDegrees = 90f, + sweepAngleDegrees = -90f, + forceMoveTo = false + ) + + // Правая сторона вверх + path.lineTo(size.width - padding, padding + radiusPx) + + // Правый верхний угол + path.arcTo( + rect = Rect( + left = size.width - padding - radiusPx * 2, + top = padding, + right = size.width - padding, + bottom = padding + radiusPx * 2 + ), + startAngleDegrees = 0f, + sweepAngleDegrees = -90f, + forceMoveTo = false + ) + + // Верхняя сторона влево + path.lineTo(8.dp.toPx() * density.density + radiusPx, padding) + + // Левый верхний угол + path.arcTo( + rect = Rect( + left = 8.dp.toPx() * density.density, + top = padding, + right = 8.dp.toPx() * density.density + radiusPx * 2, + bottom = padding + radiusPx * 2 + ), + startAngleDegrees = 270f, + sweepAngleDegrees = -90f, + forceMoveTo = false + ) + + // Левая сторона вниз (до хвостика) + path.lineTo( + 8.dp.toPx() * density.density, + size.height - padding - tailSizePx - 3.dp.toPx() * density.density + ) + + // Хвостик (маленькая дуга) + path.arcTo( + rect = Rect( + left = 7.dp.toPx() * density.density - tailSizePx * 2, + top = size.height - padding - tailSizePx * 2 - 9.dp.toPx() * density.density, + right = 8.dp.toPx() * density.density, + bottom = size.height - padding - 1.dp.toPx() * density.density + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 83f, + forceMoveTo = false + ) + } + + path.close() + return Outline.Generic(path) + } +} + +/** + * 🚀 Пузырек сообщения Telegram-style с хвостиком */ @Composable private fun MessageBubble( message: ChatMessage, isDarkTheme: Boolean, - index: Int = 0 // Для staggered анимации + index: Int = 0 ) { - // 🔥 Fade-in + slide анимация - используем key для предотвращения повторной анимации - var isVisible by remember(message.id) { mutableStateOf(true) } // Сразу true - без повторной анимации + // Telegram-style enter animation + val (alpha, translationY) = rememberMessageEnterAnimation(message.id) val bubbleColor = if (message.isOutgoing) { PrimaryBlue } else { - if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) + if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) } val textColor = if (message.isOutgoing) Color.White else { - if (isDarkTheme) Color.White else Color.Black + if (isDarkTheme) Color.White else Color(0xFF000000) } val timeColor = if (message.isOutgoing) Color.White.copy(alpha = 0.7f) else { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) @@ -582,22 +794,39 @@ private fun MessageBubble( Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 2.dp), + .padding(vertical = 2.dp) + .graphicsLayer { + this.alpha = alpha + this.translationY = translationY + }, horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start ) { Box( modifier = Modifier .widthIn(max = 280.dp) + .shadow( + elevation = if (message.isOutgoing) 0.dp else 0.5.dp, + shape = TelegramBubbleShape( + isOutgoing = message.isOutgoing, + radius = 18.dp, + tailSize = 6.dp + ), + clip = false + ) .clip( - RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = if (message.isOutgoing) 16.dp else 4.dp, - bottomEnd = if (message.isOutgoing) 4.dp else 16.dp + TelegramBubbleShape( + isOutgoing = message.isOutgoing, + radius = 18.dp, + tailSize = 6.dp ) ) .background(bubbleColor) - .padding(horizontal = 12.dp, vertical = 8.dp) + .padding( + start = if (message.isOutgoing) 12.dp else 16.dp, + end = if (message.isOutgoing) 16.dp else 12.dp, + top = 8.dp, + bottom = 8.dp + ) ) { Column { AppleEmojiText( @@ -650,7 +879,7 @@ private fun DateHeader( var isVisible by remember { mutableStateOf(false) } val alpha by animateFloatAsState( targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), label = "dateAlpha" ) @@ -771,6 +1000,7 @@ private fun MessageInputBar( modifier = Modifier .fillMaxWidth() .background(panelBackground) + .blur(radius = 20.dp) // Blur эффект для frosted glass ) { // Верхняя линия для разделения (эффект стекла) Box(