feat: интегрировать LockIcon, SlideToCancel, waveform и controls в панель записи

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 20:44:36 +05:00
parent 47a6e20834
commit 620200ca44

View File

@@ -896,6 +896,7 @@ fun MessageInputBar(
var pressStartY by remember { mutableFloatStateOf(0f) } var pressStartY by remember { mutableFloatStateOf(0f) }
var slideDx by remember { mutableFloatStateOf(0f) } var slideDx by remember { mutableFloatStateOf(0f) }
var slideDy by remember { mutableFloatStateOf(0f) } var slideDy by remember { mutableFloatStateOf(0f) }
var lockProgress by remember { mutableFloatStateOf(0f) }
var pendingLongPressJob by remember { mutableStateOf<Job?>(null) } var pendingLongPressJob by remember { mutableStateOf<Job?>(null) }
var pendingRecordAfterPermission by remember { mutableStateOf(false) } var pendingRecordAfterPermission by remember { mutableStateOf(false) }
var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) } var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) }
@@ -946,6 +947,7 @@ fun MessageInputBar(
slideDy = 0f slideDy = 0f
pressStartX = 0f pressStartX = 0f
pressStartY = 0f pressStartY = 0f
lockProgress = 0f
pendingLongPressJob?.cancel() pendingLongPressJob?.cancel()
pendingLongPressJob = null pendingLongPressJob = null
} }
@@ -1197,6 +1199,12 @@ fun MessageInputBar(
val cancelDragThresholdPx = with(density) { 92.dp.toPx() } val cancelDragThresholdPx = with(density) { 92.dp.toPx() }
val lockDragThresholdPx = with(density) { 70.dp.toPx() } val lockDragThresholdPx = with(density) { 70.dp.toPx() }
var showLockTooltip by remember { mutableStateOf(false) }
val lockHintShownCount = remember {
context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
.getInt(LOCK_HINT_PREF_KEY, 0)
}
fun tryStartRecordingForCurrentMode(): Boolean { fun tryStartRecordingForCurrentMode(): Boolean {
return if (recordMode == RecordMode.VOICE) { return if (recordMode == RecordMode.VOICE) {
setRecordUiState(RecordUiState.RECORDING, "hold-threshold-passed") setRecordUiState(RecordUiState.RECORDING, "hold-threshold-passed")
@@ -1233,6 +1241,29 @@ fun MessageInputBar(
} }
} }
LaunchedEffect(recordUiState) {
if (recordUiState == RecordUiState.RECORDING && lockHintShownCount < LOCK_HINT_MAX_SHOWS) {
delay(200)
if (recordUiState == RecordUiState.RECORDING) {
showLockTooltip = true
context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
.edit()
.putInt(LOCK_HINT_PREF_KEY, lockHintShownCount + 1)
.apply()
delay(3000)
showLockTooltip = false
}
} else {
showLockTooltip = false
}
}
LaunchedEffect(lockProgress) {
if (lockProgress > 0.2f) {
showLockTooltip = false
}
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
pendingRecordAfterPermission = false pendingRecordAfterPermission = false
@@ -1909,9 +1940,9 @@ fun MessageInputBar(
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
.onGloballyPositioned { coordinates -> .onGloballyPositioned { coordinates ->
recordingInputRowHeightPx = coordinates.size.height recordingInputRowHeightPx = coordinates.size.height
recordingInputRowY = coordinates.positionInWindow().y recordingInputRowY = coordinates.positionInWindow().y
}, },
contentAlignment = Alignment.CenterEnd contentAlignment = Alignment.CenterEnd
) { ) {
Box( Box(
@@ -1920,8 +1951,9 @@ fun MessageInputBar(
.height(40.dp) .height(40.dp)
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(recordingPanelColor) .background(recordingPanelColor)
.padding(start = 13.dp, end = 94.dp) // record panel paddings .padding(start = 13.dp, end = 94.dp)
) { ) {
// Left: blink dot + timer (all states)
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
@@ -1931,8 +1963,25 @@ fun MessageInputBar(
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RecordBlinkDot(isDarkTheme = isDarkTheme) if (recordUiState == RecordUiState.PAUSED) {
Spacer(modifier = Modifier.width(6.dp)) // TimerView margin from RecordDot // Static dot (no blink) when 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)
)
)
}
} else {
RecordBlinkDot(isDarkTheme = isDarkTheme)
}
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = formatVoiceRecordTimer(voiceElapsedMs), text = formatVoiceRecordTimer(voiceElapsedMs),
color = recordingTextColor, color = recordingTextColor,
@@ -1941,67 +1990,107 @@ fun MessageInputBar(
) )
} }
Text( // Center: SlideToCancel or Waveform+Controls
text = AnimatedContent(
if (recordUiState == RecordUiState.LOCKED) { targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED,
"CANCEL" modifier = Modifier
} else { .align(Alignment.Center)
"Slide left to cancel • up to lock" .graphicsLayer {
alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() }
}, },
color = if (recordUiState == RecordUiState.LOCKED) PrimaryBlue else recordingTextColor.copy(alpha = 0.82f), transitionSpec = {
fontSize = if (recordUiState == RecordUiState.LOCKED) 15.sp else 13.sp, fadeIn(tween(200)) togetherWith fadeOut(tween(200))
fontWeight = if (recordUiState == RecordUiState.LOCKED) FontWeight.Bold else FontWeight.Medium, },
maxLines = 1, label = "record_center_content"
overflow = TextOverflow.Ellipsis, ) { isLockedOrPaused ->
modifier = if (isLockedOrPaused) {
Modifier Row(
.align(Alignment.Center) verticalAlignment = Alignment.CenterVertically,
.graphicsLayer { modifier = Modifier.fillMaxWidth()
alpha = recordUiAlpha ) {
translationX = with(density) { recordUiShift.toPx() } VoiceWaveformBar(
} waves = voiceWaves,
.then( isDarkTheme = isDarkTheme,
if (recordUiState == RecordUiState.LOCKED) { modifier = Modifier.weight(1f)
Modifier.clickable( )
interactionSource = remember { MutableInteractionSource() }, Spacer(modifier = Modifier.width(8.dp))
indication = null RecordLockedControls(
) { isPaused = recordUiState == RecordUiState.PAUSED,
inputJumpLog( isDarkTheme = isDarkTheme,
"tap CANCEL (locked) mode=$recordMode state=$recordUiState " + onDelete = {
"voice=$isVoiceRecording kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState")
inputHeightsSnapshot() stopVoiceRecording(send = false)
) },
stopVoiceRecording(send = false) onTogglePause = {
inputJumpLog("tap PAUSE/RESUME mode=$recordMode state=$recordUiState")
if (recordUiState == RecordUiState.PAUSED) {
resumeVoiceRecording()
} else {
pauseVoiceRecording()
} }
} else {
Modifier
} }
) )
) }
} else {
SlideToCancel(
slideDx = slideDx,
cancelThresholdPx = cancelDragThresholdPx,
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxWidth()
)
}
}
} }
// Mic button area with LockIcon overlay
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.offset(x = 8.dp), .offset(x = 8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// LockIcon positioned above the mic button
if (recordUiState == RecordUiState.RECORDING ||
recordUiState == RecordUiState.LOCKED ||
recordUiState == RecordUiState.PAUSED
) {
LockIcon(
lockProgress = lockProgress,
isLocked = recordUiState == RecordUiState.LOCKED,
isPaused = recordUiState == RecordUiState.PAUSED,
isDarkTheme = isDarkTheme,
modifier = Modifier
.size(36.dp)
.offset(y = (-60).dp)
.zIndex(10f)
)
// Tooltip
if (showLockTooltip && recordUiState == RecordUiState.RECORDING) {
LockTooltip(
visible = showLockTooltip,
isDarkTheme = isDarkTheme,
modifier = Modifier
.offset(x = (-80).dp, y = (-60).dp)
.zIndex(11f)
)
}
}
VoiceButtonBlob( VoiceButtonBlob(
voiceLevel = voiceLevel, voiceLevel = voiceLevel,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = modifier = Modifier
Modifier .fillMaxSize()
.fillMaxSize() .graphicsLayer {
.graphicsLayer { scaleX = 2.05f
// Visual-only enlargement like Telegram record circle, scaleY = 2.05f
// while keeping layout hitbox at normal input size. clip = false
scaleX = 2.05f }
scaleY = 2.05f
clip = false
}
) )
if (recordUiState == RecordUiState.LOCKED) { if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) {
Box( Box(
modifier = Modifier modifier = Modifier
.requiredSize(82.dp) .requiredSize(82.dp)
@@ -2017,7 +2106,7 @@ fun MessageInputBar(
indication = null indication = null
) { ) {
inputJumpLog( inputJumpLog(
"tap SEND (locked) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " + "tap SEND (locked/paused) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " +
"kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" "kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}"
) )
stopVoiceRecording(send = true) stopVoiceRecording(send = true)
@@ -2033,11 +2122,10 @@ fun MessageInputBar(
} }
} else { } else {
Box( Box(
modifier = modifier = Modifier
Modifier .requiredSize(82.dp)
.requiredSize(82.dp) .clip(CircleShape)
.clip(CircleShape) .background(PrimaryBlue.copy(alpha = 0.92f)),
.background(PrimaryBlue.copy(alpha = 0.92f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -2257,6 +2345,7 @@ fun MessageInputBar(
slideDy = dy slideDy = dy
if (recordUiState == RecordUiState.RECORDING) { if (recordUiState == RecordUiState.RECORDING) {
lockProgress = ((-dy) / lockDragThresholdPx).coerceIn(0f, 1f)
if (dx <= -cancelDragThresholdPx) { if (dx <= -cancelDragThresholdPx) {
inputJumpLog( inputJumpLog(
"gesture CANCEL dx=${dx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" "gesture CANCEL dx=${dx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode"
@@ -2266,6 +2355,8 @@ fun MessageInputBar(
resetGestureState() resetGestureState()
finished = true finished = true
} else if (dy <= -lockDragThresholdPx) { } else if (dy <= -lockDragThresholdPx) {
view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
lockProgress = 1f
setRecordUiState( setRecordUiState(
RecordUiState.LOCKED, RecordUiState.LOCKED,
"slide-lock dy=${dy.toInt()}" "slide-lock dy=${dy.toInt()}"