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"
)
// 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(
modifier = Modifier
.fillMaxWidth()
@@ -1967,20 +1969,20 @@ fun MessageInputBar(
recordingInputRowY = coordinates.positionInWindow().y
}
) {
// ── Layer 1: Panel bar ──
Box(
// ── Layer 1: Panel bar (timer + center) ──
// Telegram: full-width bar, circle overlaps right edge
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.padding(end = 44.dp) // space for circle overlap
.clip(RoundedCornerShape(20.dp))
.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(
modifier = Modifier
.align(Alignment.CenterStart)
.graphicsLayer {
alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() }
@@ -1988,18 +1990,9 @@ fun MessageInputBar(
verticalAlignment = Alignment.CenterVertically
) {
if (recordUiState == RecordUiState.PAUSED) {
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(
if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
)
)
Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape)
.background(if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)))
}
} else {
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(
targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED,
modifier = Modifier
.align(Alignment.Center)
.weight(1f)
.graphicsLayer {
alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() }
@@ -2066,22 +2061,24 @@ fun MessageInputBar(
}
}
// ── Layer 2: Mic/Send circle overlay ──
// Positioned at right edge, overlapping the panel
// ── Layer 2: Circle + Lock overlay ──
// 48dp layout box at right edge; visuals overflow via graphicsLayer
// Telegram: circle center at 26dp from right, radius 41dp = 82dp visual
Box(
modifier = Modifier
.size(48.dp)
.align(Alignment.CenterEnd)
.offset(x = 4.dp) // slight overlap into right padding
.zIndex(5f),
contentAlignment = Alignment.Center
) {
// ── Layer 3: LockIcon above circle ──
// Lock icon: floats ~70dp above circle center (Telegram: ~92dp)
if (recordUiState == RecordUiState.RECORDING ||
recordUiState == RecordUiState.LOCKED ||
recordUiState == RecordUiState.PAUSED
) {
val lockSizeDp = (50.dp - 14.dp * lockProgress)
val lockYOffset = ((-56).dp + 14.dp * lockProgress)
val lockSizeDp = 50.dp - 14.dp * lockProgress
val lockYDp = -70.dp + 14.dp * lockProgress
LockIcon(
lockProgress = lockProgress,
isLocked = recordUiState == RecordUiState.LOCKED,
@@ -2090,7 +2087,7 @@ fun MessageInputBar(
modifier = Modifier
.size(lockSizeDp)
.graphicsLayer {
translationY = with(density) { lockYOffset.toPx() }
translationY = with(density) { lockYDp.toPx() }
clip = false
}
.zIndex(10f)
@@ -2102,8 +2099,8 @@ fun MessageInputBar(
isDarkTheme = isDarkTheme,
modifier = Modifier
.graphicsLayer {
translationX = with(density) { (-80).dp.toPx() }
translationY = with(density) { (-56).dp.toPx() }
translationX = with(density) { (-90).dp.toPx() }
translationY = with(density) { (-70).dp.toPx() }
clip = false
}
.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(
voiceLevel = voiceLevel,
isDarkTheme = isDarkTheme,
modifier = Modifier
.size(48.dp)
.graphicsLayer {
scaleX = 1.8f
scaleY = 1.8f
scaleX = 1.7f
scaleY = 1.7f
clip = false
}
)
// Send or Mic button
// Solid circle: 48dp layout, scaled to 82dp visual
val sendScale by animateFloatAsState(
targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f,
animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing),
label = "send_btn_scale"
)
val circleScale = 1.71f // 48dp * 1.71 ≈ 82dp (Telegram)
if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) {
Box(
modifier = Modifier
.size(48.dp)
.graphicsLayer {
scaleX = sendScale
scaleY = sendScale
}
.shadow(
elevation = 6.dp,
shape = CircleShape,
scaleX = circleScale * sendScale
scaleY = circleScale * sendScale
clip = false
)
shadowElevation = 8f
shape = CircleShape
}
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(
@@ -2161,13 +2157,18 @@ fun MessageInputBar(
imageVector = TelegramSendIcon,
contentDescription = "Send voice message",
tint = Color.White,
modifier = Modifier.size(22.dp)
modifier = Modifier.size(24.dp)
)
}
} else {
Box(
modifier = Modifier
.size(48.dp)
.graphicsLayer {
scaleX = circleScale
scaleY = circleScale
clip = false
}
.clip(CircleShape)
.background(PrimaryBlue),
contentAlignment = Alignment.Center
@@ -2176,7 +2177,7 @@ fun MessageInputBar(
imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp)
modifier = Modifier.size(24.dp)
)
}
}