fix: переписать recording layout 1:1 с Telegram — правильные пропорции и overlay

Telegram dimensions:
- Circle: 48dp layout → 82dp visual (scale 1.71x), как circleRadius=41dp
- Lock: 50dp→36dp pill, 70dp выше центра круга
- Panel bar: full width Row с end=52dp для overlap
- Blob: 1.7x scale = 82dp visual (Telegram blob minRadius)
- Controls: 36dp (delete + pause)
- Tooltip: 90dp левее, 70dp выше

Layout architecture:
- Layer 1: Panel bar (Row с clip RoundedCornerShape)
- Layer 2: Circle overlay (graphicsLayer scale, NO clip)
- Layer 3: Lock overlay (graphicsLayer translationY, NO clip)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 21:59:03 +05:00
parent b13cdb7ea1
commit 78fbe0b3c8

View File

@@ -1956,7 +1956,9 @@ fun MessageInputBar(
label = "record_ui_shift" label = "record_ui_shift"
) )
// Outer Box — no clip, allows children to overflow // ── Telegram-style recording layout ──
// Telegram uses separate overlay layers (RecordCircle 194dp, ControlsView 250dp)
// We replicate with Box layers + graphicsLayer for overflow
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1967,20 +1969,20 @@ fun MessageInputBar(
recordingInputRowY = coordinates.positionInWindow().y recordingInputRowY = coordinates.positionInWindow().y
} }
) { ) {
// ── Layer 1: Panel bar ── // ── Layer 1: Panel bar (timer + center) ──
Box( // Telegram: full-width bar, circle overlaps right edge
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(40.dp) .height(40.dp)
.padding(end = 44.dp) // space for circle overlap
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(recordingPanelColor) .background(recordingPanelColor)
.padding(horizontal = 13.dp) .padding(start = 13.dp, end = 52.dp), // 52dp = half-circle overlap
verticalAlignment = Alignment.CenterVertically
) { ) {
// Left: blink dot + timer // Blink dot + timer
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart)
.graphicsLayer { .graphicsLayer {
alpha = recordUiAlpha alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() } translationX = with(density) { recordUiShift.toPx() }
@@ -1988,18 +1990,9 @@ fun MessageInputBar(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (recordUiState == RecordUiState.PAUSED) { if (recordUiState == RecordUiState.PAUSED) {
Box( Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) {
modifier = Modifier.size(28.dp), Box(modifier = Modifier.size(10.dp).clip(CircleShape)
contentAlignment = Alignment.Center .background(if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)))
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(
if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
)
)
} }
} else { } else {
RecordBlinkDot(isDarkTheme = isDarkTheme) RecordBlinkDot(isDarkTheme = isDarkTheme)
@@ -2013,11 +2006,13 @@ fun MessageInputBar(
) )
} }
// Center content: SlideToCancel or Waveform+Controls Spacer(modifier = Modifier.width(12.dp))
// Center: SlideToCancel or Waveform+Controls
AnimatedContent( AnimatedContent(
targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED, targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED,
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .weight(1f)
.graphicsLayer { .graphicsLayer {
alpha = recordUiAlpha alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() } translationX = with(density) { recordUiShift.toPx() }
@@ -2066,22 +2061,24 @@ fun MessageInputBar(
} }
} }
// ── Layer 2: Mic/Send circle overlay ── // ── Layer 2: Circle + Lock overlay ──
// Positioned at right edge, overlapping the panel // 48dp layout box at right edge; visuals overflow via graphicsLayer
// Telegram: circle center at 26dp from right, radius 41dp = 82dp visual
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.offset(x = 4.dp) // slight overlap into right padding
.zIndex(5f), .zIndex(5f),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// ── Layer 3: LockIcon above circle ── // Lock icon: floats ~70dp above circle center (Telegram: ~92dp)
if (recordUiState == RecordUiState.RECORDING || if (recordUiState == RecordUiState.RECORDING ||
recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.LOCKED ||
recordUiState == RecordUiState.PAUSED recordUiState == RecordUiState.PAUSED
) { ) {
val lockSizeDp = (50.dp - 14.dp * lockProgress) val lockSizeDp = 50.dp - 14.dp * lockProgress
val lockYOffset = ((-56).dp + 14.dp * lockProgress) val lockYDp = -70.dp + 14.dp * lockProgress
LockIcon( LockIcon(
lockProgress = lockProgress, lockProgress = lockProgress,
isLocked = recordUiState == RecordUiState.LOCKED, isLocked = recordUiState == RecordUiState.LOCKED,
@@ -2090,7 +2087,7 @@ fun MessageInputBar(
modifier = Modifier modifier = Modifier
.size(lockSizeDp) .size(lockSizeDp)
.graphicsLayer { .graphicsLayer {
translationY = with(density) { lockYOffset.toPx() } translationY = with(density) { lockYDp.toPx() }
clip = false clip = false
} }
.zIndex(10f) .zIndex(10f)
@@ -2102,8 +2099,8 @@ fun MessageInputBar(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier modifier = Modifier
.graphicsLayer { .graphicsLayer {
translationX = with(density) { (-80).dp.toPx() } translationX = with(density) { (-90).dp.toPx() }
translationY = with(density) { (-56).dp.toPx() } translationY = with(density) { (-70).dp.toPx() }
clip = false clip = false
} }
.zIndex(11f) .zIndex(11f)
@@ -2111,38 +2108,37 @@ fun MessageInputBar(
} }
} }
// Blob animation (visual-only enlargement) // Blob: 48dp base → 1.7x = ~82dp visual (matches Telegram circleRadius 41dp)
VoiceButtonBlob( VoiceButtonBlob(
voiceLevel = voiceLevel, voiceLevel = voiceLevel,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.graphicsLayer { .graphicsLayer {
scaleX = 1.8f scaleX = 1.7f
scaleY = 1.8f scaleY = 1.7f
clip = false clip = false
} }
) )
// Send or Mic button // Solid circle: 48dp layout, scaled to 82dp visual
val sendScale by animateFloatAsState( val sendScale by animateFloatAsState(
targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f, targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f,
animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing),
label = "send_btn_scale" label = "send_btn_scale"
) )
val circleScale = 1.71f // 48dp * 1.71 ≈ 82dp (Telegram)
if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) { if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.graphicsLayer { .graphicsLayer {
scaleX = sendScale scaleX = circleScale * sendScale
scaleY = sendScale scaleY = circleScale * sendScale
}
.shadow(
elevation = 6.dp,
shape = CircleShape,
clip = false clip = false
) shadowElevation = 8f
shape = CircleShape
}
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue) .background(PrimaryBlue)
.clickable( .clickable(
@@ -2161,13 +2157,18 @@ fun MessageInputBar(
imageVector = TelegramSendIcon, imageVector = TelegramSendIcon,
contentDescription = "Send voice message", contentDescription = "Send voice message",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(22.dp) modifier = Modifier.size(24.dp)
) )
} }
} else { } else {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.graphicsLayer {
scaleX = circleScale
scaleY = circleScale
clip = false
}
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue), .background(PrimaryBlue),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -2176,7 +2177,7 @@ fun MessageInputBar(
imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(22.dp) modifier = Modifier.size(24.dp)
) )
} }
} }