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 slideDx by remember { mutableFloatStateOf(0f) }
var slideDy by remember { mutableFloatStateOf(0f) }
var lockProgress by remember { mutableFloatStateOf(0f) }
var pendingLongPressJob by remember { mutableStateOf<Job?>(null) }
var pendingRecordAfterPermission by remember { mutableStateOf(false) }
var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) }
@@ -946,6 +947,7 @@ fun MessageInputBar(
slideDy = 0f
pressStartX = 0f
pressStartY = 0f
lockProgress = 0f
pendingLongPressJob?.cancel()
pendingLongPressJob = null
}
@@ -1197,6 +1199,12 @@ fun MessageInputBar(
val cancelDragThresholdPx = with(density) { 92.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 {
return if (recordMode == RecordMode.VOICE) {
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) {
onDispose {
pendingRecordAfterPermission = false
@@ -1909,9 +1940,9 @@ fun MessageInputBar(
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
.onGloballyPositioned { coordinates ->
recordingInputRowHeightPx = coordinates.size.height
recordingInputRowY = coordinates.positionInWindow().y
},
recordingInputRowHeightPx = coordinates.size.height
recordingInputRowY = coordinates.positionInWindow().y
},
contentAlignment = Alignment.CenterEnd
) {
Box(
@@ -1920,8 +1951,9 @@ fun MessageInputBar(
.height(40.dp)
.clip(RoundedCornerShape(20.dp))
.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(
modifier = Modifier
.align(Alignment.CenterStart)
@@ -1931,8 +1963,25 @@ fun MessageInputBar(
},
verticalAlignment = Alignment.CenterVertically
) {
RecordBlinkDot(isDarkTheme = isDarkTheme)
Spacer(modifier = Modifier.width(6.dp)) // TimerView margin from RecordDot
if (recordUiState == RecordUiState.PAUSED) {
// 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 = formatVoiceRecordTimer(voiceElapsedMs),
color = recordingTextColor,
@@ -1941,67 +1990,107 @@ fun MessageInputBar(
)
}
Text(
text =
if (recordUiState == RecordUiState.LOCKED) {
"CANCEL"
} else {
"Slide left to cancel • up to lock"
// Center: SlideToCancel or Waveform+Controls
AnimatedContent(
targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED,
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer {
alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() }
},
color = if (recordUiState == RecordUiState.LOCKED) PrimaryBlue else recordingTextColor.copy(alpha = 0.82f),
fontSize = if (recordUiState == RecordUiState.LOCKED) 15.sp else 13.sp,
fontWeight = if (recordUiState == RecordUiState.LOCKED) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.align(Alignment.Center)
.graphicsLayer {
alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() }
}
.then(
if (recordUiState == RecordUiState.LOCKED) {
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
inputJumpLog(
"tap CANCEL (locked) mode=$recordMode state=$recordUiState " +
"voice=$isVoiceRecording kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " +
inputHeightsSnapshot()
)
stopVoiceRecording(send = false)
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "record_center_content"
) { isLockedOrPaused ->
if (isLockedOrPaused) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
VoiceWaveformBar(
waves = voiceWaves,
isDarkTheme = isDarkTheme,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
RecordLockedControls(
isPaused = recordUiState == RecordUiState.PAUSED,
isDarkTheme = isDarkTheme,
onDelete = {
inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState")
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(
modifier = Modifier
.size(40.dp)
.offset(x = 8.dp),
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(
voiceLevel = voiceLevel,
isDarkTheme = isDarkTheme,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
// Visual-only enlargement like Telegram record circle,
// while keeping layout hitbox at normal input size.
scaleX = 2.05f
scaleY = 2.05f
clip = false
}
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = 2.05f
scaleY = 2.05f
clip = false
}
)
if (recordUiState == RecordUiState.LOCKED) {
if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) {
Box(
modifier = Modifier
.requiredSize(82.dp)
@@ -2017,7 +2106,7 @@ fun MessageInputBar(
indication = null
) {
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()}"
)
stopVoiceRecording(send = true)
@@ -2033,11 +2122,10 @@ fun MessageInputBar(
}
} else {
Box(
modifier =
Modifier
.requiredSize(82.dp)
.clip(CircleShape)
.background(PrimaryBlue.copy(alpha = 0.92f)),
modifier = Modifier
.requiredSize(82.dp)
.clip(CircleShape)
.background(PrimaryBlue.copy(alpha = 0.92f)),
contentAlignment = Alignment.Center
) {
Icon(
@@ -2257,6 +2345,7 @@ fun MessageInputBar(
slideDy = dy
if (recordUiState == RecordUiState.RECORDING) {
lockProgress = ((-dy) / lockDragThresholdPx).coerceIn(0f, 1f)
if (dx <= -cancelDragThresholdPx) {
inputJumpLog(
"gesture CANCEL dx=${dx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode"
@@ -2266,6 +2355,8 @@ fun MessageInputBar(
resetGestureState()
finished = true
} else if (dy <= -lockDragThresholdPx) {
view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
lockProgress = 1f
setRecordUiState(
RecordUiState.LOCKED,
"slide-lock dy=${dy.toInt()}"