fix: переписать LockIcon 1:1 с Telegram — правильный замок с keyhole и idle animation

Telegram-exact lock icon:
- Body: 16×16dp прямоугольник, radius 3dp (заливка)
- Shackle: 8×8dp полукруг (stroke 1.7dp) + две ножки
- Левая ножка: idle "breathing" animation (1.2s cycle)
- Левая ножка: удлиняется при snap lock
- Keyhole: 4dp точка в центре body (цвет фона)
- Pause transform: body раздваивается с gap 1.66dp (Telegram exact)
- Pill background: 36×50dp с тенью
- Lock виден сразу при начале записи (не ждёт свайпа)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 22:01:53 +05:00
parent 78fbe0b3c8
commit 946ba7838c

View File

@@ -408,118 +408,164 @@ private fun LockIcon(
val progress = lockProgress.coerceIn(0f, 1f) val progress = lockProgress.coerceIn(0f, 1f)
val lockedOrPaused = isLocked || isPaused val lockedOrPaused = isLocked || isPaused
// Staggered snap animations (Fix #3) // Staggered snap animations — Telegram timing
val snapRotation by animateFloatAsState( val snapAnim by animateFloatAsState(
targetValue = if (lockedOrPaused) 1f else 0f, targetValue = if (lockedOrPaused) 1f else 0f,
animationSpec = tween(durationMillis = 250, easing = EaseOutQuint), animationSpec = tween(durationMillis = 250, easing = EaseOutQuint),
label = "lock_snap_rotation" label = "lock_snap"
)
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( val pauseTransform by animateFloatAsState(
targetValue = if (lockedOrPaused) 1f else 0f, targetValue = if (lockedOrPaused) 1f else 0f,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), animationSpec = tween(durationMillis = 300, delayMillis = 150, easing = FastOutSlowInEasing),
label = "lock_to_pause" label = "lock_to_pause"
) )
// Idle "breathing" animation for shackle
val idlePhase by rememberInfiniteTransition(label = "lock_idle").animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing), RepeatMode.Reverse),
label = "lock_idle_phase"
)
val bgColor = if (isDarkTheme) Color(0xFF2A2A3E) else Color(0xFFF0F0F0) val bgColor = if (isDarkTheme) Color(0xFF2A2A3E) else Color(0xFFF0F0F0)
val iconColor = if (isDarkTheme) Color.White else Color(0xFF333333) 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 shadowColor = if (isDarkTheme) Color.Black.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.15f)
val bgPaintColor = if (isDarkTheme) Color(0xFF3A3A4E) else Color(0xFFE8E8E8)
// Lock is always visible during recording (Telegram shows it immediately)
val enterAlpha by animateFloatAsState( val enterAlpha by animateFloatAsState(
targetValue = if (lockProgress > 0.01f || lockedOrPaused) 1f else 0f, targetValue = 1f,
animationSpec = tween(durationMillis = 150), animationSpec = tween(durationMillis = 200),
label = "lock_enter_alpha" label = "lock_enter_alpha"
) )
Canvas( Canvas(
modifier = modifier modifier = modifier.graphicsLayer { alpha = enterAlpha }
.graphicsLayer { alpha = enterAlpha }
) { ) {
val cx = size.width / 2f val cx = size.width / 2f
val lockSize = size.minDimension val dp1 = size.width / 36f // normalize to 36dp base
val moveProgress = if (lockedOrPaused) 1f else progress val moveProgress = if (lockedOrPaused) 1f else progress
// Dual-phase snap rotation (Fix #4) — Telegram formula // Telegram rotation: dual-phase snap
val snapRotateBackProgress = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f val snapRotateBack = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f
val currentRotation = if (lockedOrPaused) { val rotation = if (lockedOrPaused) {
9f * (1f - moveProgress) * (1f - snapRotation) - 9f * (1f - moveProgress) * (1f - snapAnim) -
15f * snapRotation * (1f - snapRotateBackProgress) 15f * snapAnim * (1f - snapRotateBack)
} else { } else {
9f * (1f - moveProgress) 9f * (1f - moveProgress)
} }
// Shadow first // ── Background pill with shadow ──
val pillW = 36f * dp1
val pillH = 50f * dp1
val pillLeft = cx - pillW / 2f
val pillTop = 0f
val pillRadius = pillW / 2f
// Shadow
drawRoundRect( drawRoundRect(
color = shadowColor, color = shadowColor,
topLeft = Offset(cx - lockSize / 2f - 2f, -2f), topLeft = Offset(pillLeft - 3f * dp1, pillTop - 2f * dp1),
size = androidx.compose.ui.geometry.Size(lockSize + 4f, lockSize * 1.38f + 4f), size = androidx.compose.ui.geometry.Size(pillW + 6f * dp1, pillH + 4f * dp1),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f + 2f) cornerRadius = androidx.compose.ui.geometry.CornerRadius(pillRadius + 3f * dp1)
) )
// Pill background
// Background pill
val bgAlpha = 0.7f + 0.3f * moveProgress
drawRoundRect( drawRoundRect(
color = bgColor.copy(alpha = bgAlpha), color = bgColor,
topLeft = Offset(cx - lockSize / 2f, 0f), topLeft = Offset(pillLeft, pillTop),
size = androidx.compose.ui.geometry.Size(lockSize, lockSize * 1.38f), size = androidx.compose.ui.geometry.Size(pillW, pillH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f) cornerRadius = androidx.compose.ui.geometry.CornerRadius(pillRadius)
) )
// ── Lock icon drawing (Telegram-exact) ──
// Body: 16dp × 16dp centered, with corner radius 3dp
val bodyW = 16f * dp1
val bodyH = 16f * dp1
val bodyRadius = 3f * dp1
val bodyCx = cx val bodyCx = cx
val bodyCy = lockSize * 0.78f val bodyCy = pillTop + pillH * 0.62f // body center in lower part of pill
val bodyLeft = bodyCx - bodyW / 2f
val bodyTop = bodyCy - bodyH / 2f
// Transform to pause: body splits into two bars // Shackle: 8dp × 8dp arc above body, stroke 1.7dp
if (pauseTransform > 0.01f) { val shackleW = 8f * dp1
val gap = 3.3f * pauseTransform val shackleH = 8f * dp1
val barW = lockSize * 0.12f val shackleStroke = 1.7f * dp1
val barH = lockSize * 0.35f val shackleLeft = bodyCx - shackleW / 2f
drawRoundRect( val shackleTop = bodyTop - shackleH * 0.7f - 2f * dp1
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 lockIconAlpha = 1f - pauseTransform
val lockAlpha = 1f - pauseTransform val idleOffset = idlePhase * 2f * dp1 * (1f - moveProgress) // breathing on left leg
if (lockAlpha > 0.01f) {
rotate(degrees = currentRotation, pivot = Offset(bodyCx, bodyCy)) { if (lockIconAlpha > 0.01f) {
val bodyW = lockSize * 0.38f rotate(degrees = rotation, pivot = Offset(bodyCx, bodyCy)) {
val bodyH = lockSize * 0.28f // Shackle arc (half circle)
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( drawArc(
color = iconColor.copy(alpha = lockAlpha), color = iconColor.copy(alpha = lockIconAlpha),
startAngle = 180f, startAngle = 180f,
sweepAngle = 180f, sweepAngle = 180f,
useCenter = false, useCenter = false,
topLeft = Offset(bodyCx - shackleW / 2f, bodyCy - bodyH / 4f - shackleH), topLeft = Offset(shackleLeft, shackleTop),
size = androidx.compose.ui.geometry.Size(shackleW, shackleH), size = androidx.compose.ui.geometry.Size(shackleW, shackleH),
style = androidx.compose.ui.graphics.drawscope.Stroke( style = androidx.compose.ui.graphics.drawscope.Stroke(
width = shackleStroke, width = shackleStroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round cap = androidx.compose.ui.graphics.StrokeCap.Round
) )
) )
// Right leg (fixed)
drawLine(
color = iconColor.copy(alpha = lockIconAlpha),
start = Offset(shackleLeft + shackleW, shackleTop + shackleH / 2f),
end = Offset(shackleLeft + shackleW, bodyTop + 2f * dp1),
strokeWidth = shackleStroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
// Left leg (animated — idle breathing + lock closing)
val leftLegEnd = bodyTop + 2f * dp1 + idleOffset +
4f * dp1 * snapAnim * (1f - moveProgress)
drawLine(
color = iconColor.copy(alpha = lockIconAlpha),
start = Offset(shackleLeft, shackleTop + shackleH / 2f),
end = Offset(shackleLeft, leftLegEnd),
strokeWidth = shackleStroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
// Body (filled rounded rect)
drawRoundRect(
color = iconColor.copy(alpha = lockIconAlpha),
topLeft = Offset(bodyLeft, bodyTop),
size = androidx.compose.ui.geometry.Size(bodyW, bodyH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius)
)
// Keyhole dot (Telegram: dpf2(2) radius at center)
drawCircle(
color = bgPaintColor.copy(alpha = lockIconAlpha),
radius = 2f * dp1,
center = Offset(bodyCx, bodyCy)
)
} }
} }
// ── Pause transform: body splits into two bars ──
if (pauseTransform > 0.01f) {
val gap = 1.66f * dp1 * pauseTransform
val barW = 4f * dp1
val barH = 14f * dp1
val barRadius = 1.5f * dp1
drawRoundRect(
color = iconColor.copy(alpha = pauseTransform),
topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f),
size = androidx.compose.ui.geometry.Size(barW, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
)
drawRoundRect(
color = iconColor.copy(alpha = pauseTransform),
topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f),
size = androidx.compose.ui.geometry.Size(barW, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
)
}
} }
} }