diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 819aeb7..693a113 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -88,6 +88,8 @@ import java.util.UUID import kotlin.math.PI import kotlin.math.sin +private val EaseOutQuint = CubicBezierEasing(0.23f, 1f, 0.32f, 1f) + private fun truncateEmojiSafe(text: String, maxLen: Int): String { if (text.length <= maxLen) return text var cutAt = maxLen @@ -404,14 +406,21 @@ private fun LockIcon( modifier: Modifier = Modifier ) { val progress = lockProgress.coerceIn(0f, 1f) + val lockedOrPaused = isLocked || isPaused - val snapProgress by animateFloatAsState( - targetValue = if (isLocked || isPaused) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), - label = "lock_snap" + // Staggered snap animations (Fix #3) + val snapRotation by animateFloatAsState( + targetValue = if (lockedOrPaused) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = EaseOutQuint), + label = "lock_snap_rotation" + ) + val snapTranslate by animateFloatAsState( + targetValue = if (lockedOrPaused) 1f else 0f, + animationSpec = tween(durationMillis = 350, delayMillis = 100, easing = LinearOutSlowInEasing), + label = "lock_snap_translate" ) val pauseTransform by animateFloatAsState( - targetValue = if (isPaused || isLocked) 1f else 0f, + targetValue = if (lockedOrPaused) 1f else 0f, animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), label = "lock_to_pause" ) @@ -421,7 +430,7 @@ private fun LockIcon( val shadowColor = if (isDarkTheme) Color.Black.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.15f) val enterAlpha by animateFloatAsState( - targetValue = if (lockProgress > 0.01f || isLocked || isPaused) 1f else 0f, + targetValue = if (lockProgress > 0.01f || lockedOrPaused) 1f else 0f, animationSpec = tween(durationMillis = 150), label = "lock_enter_alpha" ) @@ -432,9 +441,13 @@ private fun LockIcon( ) { val cx = size.width / 2f val lockSize = size.minDimension - val moveProgress = if (isLocked || isPaused) 1f else progress - val currentRotation = if (isLocked || isPaused) { - -15f * snapProgress * (1f - snapProgress) + val moveProgress = if (lockedOrPaused) 1f else progress + + // Dual-phase snap rotation (Fix #4) — Telegram formula + val snapRotateBackProgress = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f + val currentRotation = if (lockedOrPaused) { + 9f * (1f - moveProgress) * (1f - snapRotation) - + 15f * snapRotation * (1f - snapRotateBackProgress) } else { 9f * (1f - moveProgress) } @@ -520,12 +533,14 @@ private fun SlideToCancel( val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f + // Fix #5: Arrow pulsation ±6dp when slideRatio > 0.8, else ±3dp + val arrowAmplitude = if (slideRatio > 0.8f) -6f else -3f val arrowPulse = rememberInfiniteTransition(label = "slide_cancel_arrow") val arrowOffset by arrowPulse.animateFloat( initialValue = 0f, - targetValue = -3f, + targetValue = arrowAmplitude, animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 750, easing = LinearEasing), + animation = tween(durationMillis = 500, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "slide_cancel_arrow_offset" @@ -533,10 +548,11 @@ private fun SlideToCancel( val textColor = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.4f) + // Fix #6: damping 0.3 (Telegram-style) Row( modifier = modifier .graphicsLayer { - translationX = slideDx * 0.5f + translationX = slideDx * 0.3f alpha = textAlpha }, verticalAlignment = Alignment.CenterVertically, @@ -961,7 +977,6 @@ fun MessageInputBar( isKeyboardVisible || coordinator.isEmojiBoxVisible || isVoiceRecordTransitioning || - recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.PAUSED val shouldAddNavBarPadding = hasNativeNavigationBar && !shouldPinBottomForInput @@ -1139,7 +1154,6 @@ fun MessageInputBar( isKeyboardVisible || coordinator.isEmojiBoxVisible || isVoiceRecordTransitioning || - recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.PAUSED val navPad = hasNativeNavigationBar && !pinBottom "mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " + @@ -1196,7 +1210,7 @@ fun MessageInputBar( } val holdToRecordDelayMs = 260L - val cancelDragThresholdPx = with(density) { 92.dp.toPx() } + val cancelDragThresholdPx = with(density) { 140.dp.toPx() } val lockDragThresholdPx = with(density) { 70.dp.toPx() } var showLockTooltip by remember { mutableStateOf(false) } @@ -1907,7 +1921,12 @@ fun MessageInputBar( } } - if (isVoiceRecording) { + // Fix #8: AnimatedVisibility for smooth exit + androidx.compose.animation.AnimatedVisibility( + visible = isVoiceRecording, + enter = fadeIn(tween(180)) + expandVertically(tween(180)), + exit = fadeOut(tween(300)) + shrinkVertically(tween(300)) + ) { val recordingPanelColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD) val recordingTextColor = @@ -2055,14 +2074,18 @@ fun MessageInputBar( recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED ) { + // Fix #1: Lock grows 50dp→36dp as lockProgress 0→1 + // Fix #2: Y-position animates closer to mic + val lockSizeDp = (50.dp - 14.dp * lockProgress) + val lockYOffset = ((-60).dp + 14.dp * lockProgress) LockIcon( lockProgress = lockProgress, isLocked = recordUiState == RecordUiState.LOCKED, isPaused = recordUiState == RecordUiState.PAUSED, isDarkTheme = isDarkTheme, modifier = Modifier - .size(36.dp) - .offset(y = (-60).dp) + .size(lockSizeDp) + .offset(y = lockYOffset) .zIndex(10f) ) @@ -2090,10 +2113,20 @@ fun MessageInputBar( } ) + // Fix #7: Send button with scale animation + val sendScale by animateFloatAsState( + targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f, + animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), + label = "send_btn_scale" + ) if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) { Box( modifier = Modifier .requiredSize(82.dp) + .graphicsLayer { + scaleX = sendScale + scaleY = sendScale + } .shadow( elevation = 10.dp, shape = CircleShape, @@ -2138,7 +2171,8 @@ fun MessageInputBar( } } } - } else { + } + if (!isVoiceRecording) { Row( modifier = Modifier .fillMaxWidth()