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 a9e64c9..e0f8e3a 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 @@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -391,6 +392,329 @@ private fun VoiceButtonBlob( } } +private const val LOCK_HINT_PREF_KEY = "lock_record_hint_shown_count" +private const val LOCK_HINT_MAX_SHOWS = 3 + +@Composable +private fun LockIcon( + lockProgress: Float, + isLocked: Boolean, + isPaused: Boolean, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val progress = lockProgress.coerceIn(0f, 1f) + + val snapProgress by animateFloatAsState( + targetValue = if (isLocked || isPaused) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + label = "lock_snap" + ) + val pauseTransform by animateFloatAsState( + targetValue = if (isPaused || isLocked) 1f else 0f, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "lock_to_pause" + ) + + val bgColor = if (isDarkTheme) Color(0xFF2A2A3E) else Color(0xFFF0F0F0) + val iconColor = if (isDarkTheme) Color.White else Color(0xFF333333) + 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, + animationSpec = tween(durationMillis = 150), + label = "lock_enter_alpha" + ) + + Canvas( + modifier = modifier + .graphicsLayer { alpha = enterAlpha } + ) { + 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) + } else { + 9f * (1f - moveProgress) + } + + // Shadow first + drawRoundRect( + color = shadowColor, + topLeft = Offset(cx - lockSize / 2f - 2f, -2f), + size = androidx.compose.ui.geometry.Size(lockSize + 4f, lockSize * 1.38f + 4f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f + 2f) + ) + + // Background pill + val bgAlpha = 0.7f + 0.3f * moveProgress + drawRoundRect( + color = bgColor.copy(alpha = bgAlpha), + topLeft = Offset(cx - lockSize / 2f, 0f), + size = androidx.compose.ui.geometry.Size(lockSize, lockSize * 1.38f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f) + ) + + val bodyCx = cx + val bodyCy = lockSize * 0.78f + + // Transform to pause: body splits into two bars + if (pauseTransform > 0.01f) { + val gap = 3.3f * pauseTransform + val barW = lockSize * 0.12f + val barH = lockSize * 0.35f + drawRoundRect( + color = iconColor, + topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barW, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 4f) + ) + drawRoundRect( + color = iconColor, + topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barW, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 4f) + ) + } + + // Lock icon (fades out as pause transform progresses) + val lockAlpha = 1f - pauseTransform + if (lockAlpha > 0.01f) { + rotate(degrees = currentRotation, pivot = Offset(bodyCx, bodyCy)) { + val bodyW = lockSize * 0.38f + val bodyH = lockSize * 0.28f + drawRoundRect( + color = iconColor.copy(alpha = lockAlpha), + topLeft = Offset(bodyCx - bodyW / 2f, bodyCy - bodyH / 4f), + size = androidx.compose.ui.geometry.Size(bodyW, bodyH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize * 0.05f) + ) + val shackleW = lockSize * 0.26f + val shackleH = lockSize * 0.22f + val shackleStroke = lockSize * 0.047f + drawArc( + color = iconColor.copy(alpha = lockAlpha), + startAngle = 180f, + sweepAngle = 180f, + useCenter = false, + topLeft = Offset(bodyCx - shackleW / 2f, bodyCy - bodyH / 4f - shackleH), + size = androidx.compose.ui.geometry.Size(shackleW, shackleH), + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = shackleStroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + ) + } + } + } +} + +@Composable +private fun SlideToCancel( + slideDx: Float, + cancelThresholdPx: Float, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) + val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f + + val arrowPulse = rememberInfiniteTransition(label = "slide_cancel_arrow") + val arrowOffset by arrowPulse.animateFloat( + initialValue = 0f, + targetValue = -3f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 750, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "slide_cancel_arrow_offset" + ) + + val textColor = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.4f) + + Row( + modifier = modifier + .graphicsLayer { + translationX = slideDx * 0.5f + alpha = textAlpha + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "◀", + color = textColor, + fontSize = 13.sp, + modifier = Modifier.graphicsLayer { translationX = arrowOffset } + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Slide to Cancel", + color = textColor, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } +} + +@Composable +private fun LockTooltip( + visible: Boolean, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = tween(durationMillis = 150), + label = "tooltip_alpha" + ) + + if (alpha > 0.01f) { + Row( + modifier = modifier + .graphicsLayer { this.alpha = alpha } + .background( + color = Color(0xFF333333), + shape = RoundedCornerShape(5.dp) + ) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Slide Up to Lock", + color = Color.White, + fontSize = 14.sp, + maxLines = 1 + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "↑", + color = Color.White, + fontSize = 14.sp + ) + } + } +} + +@Composable +private fun VoiceWaveformBar( + waves: List, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val barColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF) + val barWidthDp = 2.dp + val barGapDp = 1.dp + val minBarHeightDp = 2.dp + val maxBarHeightDp = 20.dp + + Canvas(modifier = modifier.height(maxBarHeightDp)) { + val barWidthPx = barWidthDp.toPx() + val barGapPx = barGapDp.toPx() + val minH = minBarHeightDp.toPx() + val maxH = maxBarHeightDp.toPx() + val totalBarWidth = barWidthPx + barGapPx + val maxBars = (size.width / totalBarWidth).toInt().coerceAtLeast(1) + val displayWaves = if (waves.size > maxBars) waves.takeLast(maxBars) else waves + val cy = size.height / 2f + + displayWaves.forEachIndexed { index, level -> + val barH = minH + (maxH - minH) * level.coerceIn(0f, 1f) + val x = (maxBars - displayWaves.size + index) * totalBarWidth + drawRoundRect( + color = barColor, + topLeft = Offset(x, cy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barWidthPx, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidthPx / 2f) + ) + } + } +} + +@Composable +private fun RecordLockedControls( + isPaused: Boolean, + isDarkTheme: Boolean, + onDelete: () -> Unit, + onTogglePause: () -> Unit, + modifier: Modifier = Modifier +) { + val deleteBgColor = if (isDarkTheme) Color(0xFF444444) else Color(0xFFE0E0E0) + val deleteIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.8f) else Color(0xFF666666) + val pauseBgColor = if (isDarkTheme) Color(0xFF69CCFF).copy(alpha = 0.3f) else Color(0xFF2D9CFF).copy(alpha = 0.2f) + val pauseIconColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Delete button + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(deleteBgColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onDelete() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Delete recording", + tint = deleteIconColor, + modifier = Modifier.size(16.dp) + ) + } + + // Pause/Resume button + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(pauseBgColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onTogglePause() }, + contentAlignment = Alignment.Center + ) { + if (isPaused) { + Canvas(modifier = Modifier.size(12.dp)) { + val path = Path().apply { + moveTo(size.width * 0.2f, 0f) + lineTo(size.width, size.height / 2f) + lineTo(size.width * 0.2f, size.height) + close() + } + drawPath(path, color = pauseIconColor) + } + } else { + Canvas(modifier = Modifier.size(12.dp)) { + val barW = size.width * 0.25f + val gap = size.width * 0.15f + drawRoundRect( + color = pauseIconColor, + topLeft = Offset(size.width / 2f - gap - barW, 0f), + size = androidx.compose.ui.geometry.Size(barW, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f) + ) + drawRoundRect( + color = pauseIconColor, + topLeft = Offset(size.width / 2f + gap, 0f), + size = androidx.compose.ui.geometry.Size(barW, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f) + ) + } + } + } + } +} + /** * Message input bar and related components * Extracted from ChatDetailScreen.kt for better organization