feat: интегрировать LockIcon, SlideToCancel, waveform и controls в панель записи
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
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)
|
RecordBlinkDot(isDarkTheme = isDarkTheme)
|
||||||
Spacer(modifier = Modifier.width(6.dp)) // TimerView margin from RecordDot
|
}
|
||||||
|
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 {
|
|
||||||
"Slide left to cancel • up to lock"
|
|
||||||
},
|
|
||||||
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)
|
.align(Alignment.Center)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = recordUiAlpha
|
alpha = recordUiAlpha
|
||||||
translationX = with(density) { recordUiShift.toPx() }
|
translationX = with(density) { recordUiShift.toPx() }
|
||||||
}
|
},
|
||||||
.then(
|
transitionSpec = {
|
||||||
if (recordUiState == RecordUiState.LOCKED) {
|
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
|
||||||
Modifier.clickable(
|
},
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
label = "record_center_content"
|
||||||
indication = null
|
) { isLockedOrPaused ->
|
||||||
|
if (isLockedOrPaused) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
inputJumpLog(
|
VoiceWaveformBar(
|
||||||
"tap CANCEL (locked) mode=$recordMode state=$recordUiState " +
|
waves = voiceWaves,
|
||||||
"voice=$isVoiceRecording kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " +
|
isDarkTheme = isDarkTheme,
|
||||||
inputHeightsSnapshot()
|
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)
|
stopVoiceRecording(send = false)
|
||||||
|
},
|
||||||
|
onTogglePause = {
|
||||||
|
inputJumpLog("tap PAUSE/RESUME mode=$recordMode state=$recordUiState")
|
||||||
|
if (recordUiState == RecordUiState.PAUSED) {
|
||||||
|
resumeVoiceRecording()
|
||||||
|
} else {
|
||||||
|
pauseVoiceRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
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 {
|
||||||
// Visual-only enlargement like Telegram record circle,
|
|
||||||
// while keeping layout hitbox at normal input size.
|
|
||||||
scaleX = 2.05f
|
scaleX = 2.05f
|
||||||
scaleY = 2.05f
|
scaleY = 2.05f
|
||||||
clip = false
|
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,8 +2122,7 @@ 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)),
|
||||||
@@ -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()}"
|
||||||
|
|||||||
Reference in New Issue
Block a user