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.draw.shadow
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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
|
* Message input bar and related components
|
||||||
* Extracted from ChatDetailScreen.kt for better organization
|
* Extracted from ChatDetailScreen.kt for better organization
|
||||||
|
|||||||
Reference in New Issue
Block a user