feat: добавить composable компоненты LockIcon, SlideToCancel, LockTooltip, VoiceWaveformBar, RecordLockedControls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user