feat: добавить composable компоненты LockIcon, SlideToCancel, LockTooltip, VoiceWaveformBar, RecordLockedControls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 20:39:30 +05:00
parent fad8bfb1d1
commit 47a6e20834

View File

@@ -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<Float>,
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