From 4396611355fe6214afad1ea71b07465bb8a3ca81 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 14 Apr 2026 13:53:01 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=BF=D0=BB=D0=B5?= =?UTF-8?q?=D0=B5=D1=80=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D1=85:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D1=87=D0=B0=D1=82,=20smooth=20UI,=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B1=D0=B0=D0=B3=20=D1=81=20auto-p?= =?UTF-8?q?lay=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 6 + .../messenger/ui/chats/ChatDetailScreen.kt | 45 +++++- .../messenger/ui/chats/ChatsListScreen.kt | 130 +++++++++-------- .../chats/components/AttachmentComponents.kt | 138 +++++++++++------- .../ui/chats/input/ChatDetailInput.kt | 58 +++++--- 5 files changed, 243 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index b681248..e6d1bca 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -58,6 +58,12 @@ import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.DeviceConfirmScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen +import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer +import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.ui.Alignment import com.rosetta.messenger.ui.chats.ConnectionLogsScreen import com.rosetta.messenger.ui.chats.GroupInfoScreen import com.rosetta.messenger.ui.chats.GroupSetupScreen diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 0633e54..5b14678 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -102,6 +102,7 @@ import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.components.* +import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer import com.rosetta.messenger.ui.chats.components.InAppCameraScreen import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.input.* @@ -441,11 +442,29 @@ fun ChatDetailScreen( showEmojiPicker = false } - // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager + // 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat) val hideKeyboardAndBack: () -> Unit = { hideInputOverlays() onBack() } + // 🔥 Поведение как у нативного Android back: + // сначала закрываем IME/emoji, и только следующим back выходим из чата. + val handleBackWithInputPriority: () -> Unit = { + val imeVisible = + androidx.core.view.ViewCompat.getRootWindowInsets(view) + ?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true + val hasInputOverlay = + showEmojiPicker || + coordinator.isEmojiBoxVisible || + coordinator.isKeyboardVisible || + imeVisible + + if (hasInputOverlay) { + hideInputOverlays() + } else { + onBack() + } + } // Определяем это Saved Messages или обычный чат val isSavedMessages = user.publicKey == currentUserPublicKey @@ -1344,7 +1363,7 @@ fun ChatDetailScreen( if (isInChatSearchMode) { closeInChatSearch() } else { - hideKeyboardAndBack() + handleBackWithInputPriority() } } @@ -1859,7 +1878,7 @@ fun ChatDetailScreen( Box { IconButton( onClick = - hideKeyboardAndBack, + handleBackWithInputPriority, modifier = Modifier.size( 40.dp @@ -2305,6 +2324,26 @@ fun ChatDetailScreen( avatarRepository = avatarRepository ) } + // Voice mini player — shown right under the chat header when audio is playing + val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() + if (!playingVoiceAttachmentId.isNullOrBlank()) { + val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() + val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() + val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() + val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() + val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" } + val time = playingVoiceTimeLabel.trim() + val voiceTitle = if (time.isBlank()) sender else "$sender at $time" + VoiceTopMiniPlayer( + title = voiceTitle, + isDarkTheme = isDarkTheme, + isPlaying = isVoicePlaybackRunning, + speed = voicePlaybackSpeed, + onTogglePlay = { VoicePlaybackCoordinator.toggleCurrentPlayback() }, + onCycleSpeed = { VoicePlaybackCoordinator.cycleSpeed() }, + onClose = { VoicePlaybackCoordinator.stop() } + ) + } } // Закрытие Column topBar }, containerColor = backgroundColor, // Фон всего чата diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 0a94abc..ebcbda7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.Immutable @@ -5059,7 +5063,7 @@ private fun VoicePlaybackIndicatorSmall( } } -private fun formatVoiceSpeedLabel(speed: Float): String { +fun formatVoiceSpeedLabel(speed: Float): String { val normalized = (speed * 10f).roundToInt() / 10f return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) { "${normalized.toInt()}x" @@ -5069,7 +5073,7 @@ private fun formatVoiceSpeedLabel(speed: Float): String { } @Composable -private fun VoiceTopMiniPlayer( +fun VoiceTopMiniPlayer( title: String, isDarkTheme: Boolean, isPlaying: Boolean, @@ -5078,74 +5082,80 @@ private fun VoiceTopMiniPlayer( onCycleSpeed: () -> Unit, onClose: () -> Unit ) { - val containerColor = if (isDarkTheme) Color(0xFF203446) else Color(0xFFEAF4FF) - val accentColor = if (isDarkTheme) Color(0xFF58AAFF) else Color(0xFF2481CC) - val textColor = if (isDarkTheme) Color(0xFFF3F8FF) else Color(0xFF183047) - val secondaryColor = if (isDarkTheme) Color(0xFF9EB6CC) else Color(0xFF4F6F8A) + // Match overall screen surface aesthetic — neutral elevated surface, no blue accent + val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White + val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA) + val primaryIconColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + val textColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) - Row( - modifier = - Modifier.fillMaxWidth() - .height(36.dp) - .background(containerColor) + Column(modifier = Modifier.fillMaxWidth().background(containerColor)) { + Row( + modifier = Modifier.fillMaxWidth() + .height(40.dp) .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = onTogglePlay, - modifier = Modifier.size(28.dp) + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = - if (isPlaying) TablerIcons.PlayerPause - else TablerIcons.PlayerPlay, - contentDescription = if (isPlaying) "Pause voice" else "Play voice", - tint = accentColor, - modifier = Modifier.size(18.dp) + IconButton( + onClick = onTogglePlay, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = + if (isPlaying) Icons.Default.Pause + else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause voice" else "Play voice", + tint = primaryIconColor, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + AppleEmojiText( + text = title, + fontSize = 14.sp, + color = textColor, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f), + enableLinks = false, + minHeightMultiplier = 1f ) - } - AppleEmojiText( - text = title, - fontSize = 14.sp, - color = textColor, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f), - enableLinks = false, - minHeightMultiplier = 1f - ) + Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = - Modifier.clip(RoundedCornerShape(8.dp)) - .border(1.dp, accentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + Box( + modifier = Modifier.clip(RoundedCornerShape(8.dp)) + .border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp)) .clickable { onCycleSpeed() } - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = formatVoiceSpeedLabel(speed), - color = accentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - } + .padding(horizontal = 8.dp, vertical = 3.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = formatVoiceSpeedLabel(speed), + color = secondaryColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(4.dp)) - IconButton( - onClick = onClose, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = TablerIcons.X, - contentDescription = "Close voice", - tint = secondaryColor, - modifier = Modifier.size(18.dp) - ) + IconButton( + onClick = onClose, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close voice", + tint = secondaryColor, + modifier = Modifier.size(20.dp) + ) + } } + Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 6c6ef99..3e53b9f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -85,6 +85,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit @@ -144,12 +145,24 @@ private fun decodeBase64Payload(data: String): ByteArray? { private fun decodeHexPayload(data: String): ByteArray? { val raw = data.trim().removePrefix("0x") if (raw.isBlank() || raw.length % 2 != 0) return null - if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null - return runCatching { - ByteArray(raw.length / 2) { index -> - raw.substring(index * 2, index * 2 + 2).toInt(16).toByte() + fun nibble(ch: Char): Int = + when (ch) { + in '0'..'9' -> ch.code - '0'.code + in 'a'..'f' -> ch.code - 'a'.code + 10 + in 'A'..'F' -> ch.code - 'A'.code + 10 + else -> -1 } - }.getOrNull() + val out = ByteArray(raw.length / 2) + var outIndex = 0 + var index = 0 + while (index < raw.length) { + val hi = nibble(raw[index]) + val lo = nibble(raw[index + 1]) + if (hi < 0 || lo < 0) return null + out[outIndex++] = ((hi shl 4) or lo).toByte() + index += 2 + } + return out } private fun decodeVoicePayload(data: String): ByteArray? { @@ -280,7 +293,12 @@ object VoicePlaybackCoordinator { val normalized = speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first() _playbackSpeed.value = normalized - player?.let { applyPlaybackSpeed(it) } + // Only apply to the player if it's currently playing — otherwise setting + // playbackParams auto-resumes a paused MediaPlayer (Android quirk). + // The new speed will be applied on the next resume() call. + if (_isPlaying.value) { + player?.let { applyPlaybackSpeed(it) } + } } private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) { @@ -2125,9 +2143,10 @@ private fun ensureVoiceAudioFile( attachmentId: String, payload: String ): File? { - val bytes = decodeVoicePayload(payload) ?: return null val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() } val file = File(directory, "$attachmentId.webm") + if (file.exists() && file.length() > 0L) return file + val bytes = decodeVoicePayload(payload) ?: return null runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null return file } @@ -2149,10 +2168,16 @@ private fun VoiceAttachment( val context = LocalContext.current val scope = rememberCoroutineScope() val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() - val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState() - val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState() - val playbackIsPlaying by VoicePlaybackCoordinator.isPlaying.collectAsState() val isActiveTrack = activeAttachmentId == attachment.id + val playbackPositionMs by + (if (isActiveTrack) VoicePlaybackCoordinator.positionMs else flowOf(0)) + .collectAsState(initial = 0) + val playbackDurationMs by + (if (isActiveTrack) VoicePlaybackCoordinator.durationMs else flowOf(0)) + .collectAsState(initial = 0) + val playbackIsPlaying by + (if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false)) + .collectAsState(initial = false) val isPlaying = isActiveTrack && playbackIsPlaying val (previewDurationSecRaw, previewWavesRaw) = @@ -2167,12 +2192,19 @@ private fun VoiceAttachment( var payload by remember(attachment.id, attachment.blob) { - mutableStateOf(attachment.blob.trim()) + mutableStateOf(attachment.blob) } + val cachedAudioPath = + remember(attachment.id) { + val file = File(context.cacheDir, "voice_messages/${attachment.id}.webm") + file.takeIf { it.exists() && it.length() > 0L }?.absolutePath + } + var audioFilePath by remember(attachment.id) { mutableStateOf(cachedAudioPath) } var downloadStatus by remember(attachment.id, attachment.blob, attachment.transportTag) { mutableStateOf( when { + cachedAudioPath != null -> DownloadStatus.DOWNLOADED attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED else -> DownloadStatus.ERROR @@ -2180,7 +2212,6 @@ private fun VoiceAttachment( ) } var errorText by remember { mutableStateOf("") } - var audioFilePath by remember(attachment.id) { mutableStateOf(null) } val effectiveDurationSec = remember(isPlaying, playbackDurationMs, previewDurationSec) { @@ -2217,24 +2248,6 @@ private fun VoiceAttachment( .getOrDefault("") } - LaunchedEffect(payload, attachment.id) { - if (payload.isBlank()) return@LaunchedEffect - val prepared = ensureVoiceAudioFile(context, attachment.id, payload) - if (prepared != null) { - audioFilePath = prepared.absolutePath - if (downloadStatus != DownloadStatus.DOWNLOADING && - downloadStatus != DownloadStatus.DECRYPTING - ) { - downloadStatus = DownloadStatus.DOWNLOADED - } - if (errorText.isNotBlank()) errorText = "" - } else { - audioFilePath = null - downloadStatus = DownloadStatus.ERROR - if (errorText.isBlank()) errorText = "Cannot decode voice" - } - } - val triggerDownload: () -> Unit = download@{ if (attachment.transportTag.isBlank()) { downloadStatus = DownloadStatus.ERROR @@ -2258,17 +2271,30 @@ private fun VoiceAttachment( errorText = "Failed to decrypt" return@launch } + downloadStatus = DownloadStatus.DECRYPTING + val prepared = + withContext(Dispatchers.IO) { + ensureVoiceAudioFile(context, attachment.id, decrypted) + } + if (prepared == null) { + downloadStatus = DownloadStatus.ERROR + errorText = "Cannot decode voice" + return@launch + } + audioFilePath = prepared.absolutePath val saved = - runCatching { - AttachmentFileManager.saveAttachment( - context = context, - blob = decrypted, - attachmentId = attachment.id, - publicKey = senderPublicKey, - privateKey = privateKey - ) - } - .getOrDefault(false) + withContext(Dispatchers.IO) { + runCatching { + AttachmentFileManager.saveAttachment( + context = context, + blob = decrypted, + attachmentId = attachment.id, + publicKey = senderPublicKey, + privateKey = privateKey + ) + } + .getOrDefault(false) + } payload = decrypted if (!saved) { // Не блокируем UI, но оставляем маркер в логе. @@ -2286,22 +2312,30 @@ private fun VoiceAttachment( val file = audioFilePath?.let { File(it) } if (file == null || !file.exists()) { if (payload.isNotBlank()) { - val prepared = ensureVoiceAudioFile(context, attachment.id, payload) + scope.launch { + downloadStatus = DownloadStatus.DECRYPTING + errorText = "" + val prepared = + withContext(Dispatchers.IO) { + ensureVoiceAudioFile(context, attachment.id, payload) + } if (prepared != null) { audioFilePath = prepared.absolutePath - VoicePlaybackCoordinator.toggle( - attachmentId = attachment.id, - sourceFile = prepared, - dialogKey = dialogPublicKey, - senderLabel = playbackSenderLabel, - playedAtLabel = playbackTimeLabel - ) { message -> + downloadStatus = DownloadStatus.DOWNLOADED + VoicePlaybackCoordinator.toggle( + attachmentId = attachment.id, + sourceFile = prepared, + dialogKey = dialogPublicKey, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel + ) { message -> + downloadStatus = DownloadStatus.ERROR + errorText = message + } + } else { downloadStatus = DownloadStatus.ERROR - errorText = message + errorText = "Cannot decode voice" } - } else { - downloadStatus = DownloadStatus.ERROR - errorText = "Cannot decode voice" } } else { triggerDownload() 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 cad9e45..2acacdf 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 @@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Videocam import androidx.compose.animation.* @@ -2387,7 +2388,7 @@ fun MessageInputBar( .background(recordingPanelColor), verticalAlignment = Alignment.CenterVertically ) { - // Delete button — Telegram: 44×44dp, Lottie trash icon + // Delete button — Telegram-style trash action Box( modifier = Modifier .size(recordingActionButtonBaseSize) @@ -2401,7 +2402,7 @@ fun MessageInputBar( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Default.Close, + imageVector = Icons.Default.Delete, contentDescription = "Delete recording", tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D), modifier = Modifier.size(20.dp) @@ -2419,38 +2420,57 @@ fun MessageInputBar( } } else { // ── RECORDING panel ── - // [dot][timer] [◀ Slide to cancel] + // [attach-slot => dot/trash morph][timer] [◀ Slide to cancel] + val dragCancelProgress = + ((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx) + .coerceIn(0f, 1f) + val leftDeleteProgress = + maxOf( + cancelAnimProgress, + FastOutSlowInEasing.transform( + (dragCancelProgress * 0.85f).coerceIn(0f, 1f) + ) + ) Row( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(24.dp)) .background(recordingPanelColor) - .padding(start = 13.dp), + .padding(start = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - // Blink dot + timer - Row( + // Left slot (same anchor as attach icon in normal input): + // morphs from recording dot to trash while user cancels. + Box( modifier = Modifier + .size(40.dp) .graphicsLayer { - alpha = recordUiAlpha * (1f - cancelAnimProgress) + alpha = recordUiAlpha translationX = with(density) { recordUiShift.toPx() } }, - verticalAlignment = Alignment.CenterVertically + contentAlignment = Alignment.Center ) { TelegramVoiceDeleteIndicator( - cancelProgress = cancelAnimProgress, - isDarkTheme = isDarkTheme - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = formatVoiceRecordTimer(voiceElapsedMs), - color = recordingTextColor, - fontSize = 15.sp, - fontWeight = FontWeight.Bold + cancelProgress = leftDeleteProgress, + isDarkTheme = isDarkTheme, + modifier = Modifier.size(28.dp) ) } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(2.dp)) + + Text( + text = formatVoiceRecordTimer(voiceElapsedMs), + color = recordingTextColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.graphicsLayer { + alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f) + translationX = with(density) { recordUiShift.toPx() } + } + ) + + Spacer(modifier = Modifier.width(10.dp)) // Slide to cancel SlideToCancel( @@ -2459,7 +2479,7 @@ fun MessageInputBar( modifier = Modifier .weight(1f) .graphicsLayer { - alpha = recordUiAlpha * (1f - cancelAnimProgress) + alpha = recordUiAlpha * (1f - leftDeleteProgress) translationX = with(density) { recordUiShift.toPx() } } )