From 620200ca4429c8ad8a20ea629e97be872ca204e5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 20:44:36 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20LockIcon,=20SlideT?= =?UTF-8?q?oCancel,=20waveform=20=D0=B8=20controls=20=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 203 +++++++++++++----- 1 file changed, 147 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index e0f8e3a..d228b0f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -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(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()}"