From ce7f913de70e9ebde2c44f84d89b2c76938fd4ae Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 14 Apr 2026 04:19:34 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=91=D0=BE=D0=BB=D1=8C=D1=88=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=BE=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rosetta/messenger/crypto/CryptoManager.kt | 16 +- .../messenger/ui/chats/ChatViewModel.kt | 45 +- .../messenger/ui/chats/ChatsListScreen.kt | 333 ++++++- .../messenger/ui/chats/SearchScreen.kt | 34 +- .../chats/components/AttachmentComponents.kt | 191 +++- .../chats/components/ChatDetailComponents.kt | 18 +- .../ui/chats/input/ChatDetailInput.kt | 846 ++++++++++++------ .../ui/components/SwipeBackContainer.kt | 78 +- .../rosetta/messenger/ui/qr/MyQrCodeScreen.kt | 39 +- .../messenger/ui/settings/AppIconScreen.kt | 17 +- .../messenger/ui/splash/SplashScreen.kt | 8 +- .../drawable-xxxhdpi/ic_calc_downloaded.png | Bin 0 -> 12147 bytes .../drawable-xxxhdpi/ic_notes_downloaded.png | Bin 0 -> 8893 bytes .../ic_weather_downloaded.png | Bin 0 -> 9733 bytes .../main/res/drawable/ic_calc_background.xml | 2 +- .../main/res/drawable/ic_calc_foreground.xml | 45 +- .../main/res/drawable/ic_notes_background.xml | 2 +- .../main/res/drawable/ic_notes_foreground.xml | 59 +- .../res/drawable/ic_weather_background.xml | 2 +- .../res/drawable/ic_weather_foreground.xml | 39 +- 20 files changed, 1241 insertions(+), 533 deletions(-) create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png diff --git a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt index b7def35..b754626 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt @@ -45,6 +45,10 @@ object CryptoManager { // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной // расшифровке private const val DECRYPTION_CACHE_SIZE = 2000 + // Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key + // и хранения гигантских plaintext в памяти. + private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024 + private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024 private val decryptionCache = ConcurrentHashMap(DECRYPTION_CACHE_SIZE, 0.75f, 4) init { @@ -298,17 +302,21 @@ object CryptoManager { * 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений */ fun decryptWithPassword(encryptedData: String, password: String): String? { + val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS + val cacheKey = if (useCache) "$password:$encryptedData" else null + // 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap) - val cacheKey = "$password:$encryptedData" - decryptionCache[cacheKey]?.let { - return it + if (cacheKey != null) { + decryptionCache[cacheKey]?.let { + return it + } } return try { val result = decryptWithPasswordInternal(encryptedData, password) // 🚀 Сохраняем в кэш (lock-free) - if (result != null) { + if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) { // Ограничиваем размер кэша if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) { // Удаляем ~10% самых старых записей diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 2ab85fa..bf37bfe 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isOutgoing = fm.isOutgoing, publicKey = fm.senderPublicKey, senderName = fm.senderName, - attachments = fm.attachments + attachments = fm.attachments, + chachaKeyPlainHex = fm.chachaKeyPlain ) } _isForwardMode.value = true @@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { forwardedList.add(ReplyData( messageId = fwdMessageId, senderName = senderDisplayName, - text = fwdText, + text = resolveReplyPreviewText(fwdText, fwdAttachments), isFromMe = fwdIsFromMe, isForwarded = true, forwardedFromName = senderDisplayName, @@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ReplyData( messageId = realMessageId, senderName = resolvedSenderName, - text = replyText, + text = resolveReplyPreviewText(replyText, originalAttachments), isFromMe = isReplyFromMe, isForwarded = isForwarded, forwardedFromName = forwardFromDisplay, @@ -2501,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + private fun resolveReplyPreviewText( + text: String, + attachments: List + ): String { + if (text.isNotBlank()) return text + return when { + attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message" + attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message" + else -> text + } + } + /** * 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для * правильного отображения цитаты @@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { msg.senderPublicKey.trim().ifEmpty { if (msg.isOutgoing) sender else opponent } + val resolvedAttachments = + msg.attachments + .filter { it.type != AttachmentType.MESSAGES } ReplyMessage( messageId = msg.id, - text = msg.text, + text = resolveReplyPreviewText(msg.text, resolvedAttachments), timestamp = msg.timestamp.time, isOutgoing = msg.isOutgoing, publicKey = resolvedPublicKey, senderName = msg.senderName, - attachments = - msg.attachments - .filter { it.type != AttachmentType.MESSAGES }, + attachments = resolvedAttachments, chachaKeyPlainHex = msg.chachaKeyPlainHex ) } @@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { msg.senderPublicKey.trim().ifEmpty { if (msg.isOutgoing) sender else opponent } + val resolvedAttachments = + msg.attachments + .filter { it.type != AttachmentType.MESSAGES } ReplyMessage( messageId = msg.id, - text = msg.text, + text = resolveReplyPreviewText(msg.text, resolvedAttachments), timestamp = msg.timestamp.time, isOutgoing = msg.isOutgoing, publicKey = resolvedPublicKey, senderName = msg.senderName, - attachments = - msg.attachments - .filter { it.type != AttachmentType.MESSAGES } + attachments = resolvedAttachments, + chachaKeyPlainHex = msg.chachaKeyPlainHex ) } _isForwardMode.value = true @@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ReplyData( messageId = firstReply.messageId, senderName = firstReplySenderName, - text = firstReply.text, + text = resolveReplyPreviewText(firstReply.text, replyAttachments), isFromMe = firstReply.isOutgoing, isForwarded = isForward, forwardedFromName = @@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ReplyData( messageId = msg.messageId, senderName = senderDisplayName, - text = msg.text, + text = resolveReplyPreviewText(msg.text, resolvedAttachments), isFromMe = msg.isOutgoing, isForwarded = true, forwardedFromName = senderDisplayName, @@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (isForwardToSend) { put("forwarded", true) put("senderName", msg.senderName) + if (msg.chachaKeyPlainHex.isNotEmpty()) { + put("chacha_key_plain", msg.chachaKeyPlainHex) + } } } replyJsonArray.put(replyJson) 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 8c6e7d0..0a94abc 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 @@ -74,6 +74,7 @@ import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner +import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge @@ -222,6 +223,18 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set): Bo return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) } } +private fun isVoicePlayingForDialog(dialogKey: String, playingDialogKey: String?): Boolean { + val active = playingDialogKey?.trim().orEmpty() + if (active.isEmpty()) return false + if (isGroupDialogKey(dialogKey) || isGroupDialogKey(active)) { + return normalizeGroupDialogKey(dialogKey).equals( + normalizeGroupDialogKey(active), + ignoreCase = true + ) + } + return dialogKey.trim().equals(active, ignoreCase = true) +} + private fun shortPublicKey(value: String): String { val trimmed = value.trim() if (trimmed.length <= 12) return trimmed @@ -467,6 +480,12 @@ fun ChatsListScreen( // �🔥 Пользователи, которые сейчас печатают val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState() + val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() + val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState() + val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() + val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() + val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() + val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() // Load dialogs when account is available LaunchedEffect(accountPublicKey, accountPrivateKey) { @@ -2130,6 +2149,50 @@ fun ChatsListScreen( callUiState.phase != CallPhase.INCOMING } val callBannerHeight = 40.dp + val showVoiceMiniPlayer = + remember( + showRequestsScreen, + showDownloadsScreen, + showCallsScreen, + playingVoiceAttachmentId + ) { + !showRequestsScreen && + !showDownloadsScreen && + !showCallsScreen && + !playingVoiceAttachmentId.isNullOrBlank() + } + val voiceBannerHeight = 36.dp + val stickyTopInset = + remember( + showStickyCallBanner, + showVoiceMiniPlayer + ) { + var topInset = 0.dp + if (showStickyCallBanner) { + topInset += callBannerHeight + } + if (showVoiceMiniPlayer) { + topInset += voiceBannerHeight + } + topInset + } + val voiceMiniPlayerTitle = + remember( + playingVoiceSenderLabel, + playingVoiceTimeLabel + ) { + val sender = + playingVoiceSenderLabel + .trim() + .ifBlank { + "Voice" + } + val time = + playingVoiceTimeLabel + .trim() + if (time.isBlank()) sender + else "$sender at $time" + } // 🔥 Берем dialogs из chatsState для // консистентности // 📌 Порядок по времени готовится в ViewModel. @@ -2332,9 +2395,7 @@ fun ChatsListScreen( Modifier.fillMaxSize() .padding( top = - if (showStickyCallBanner) - callBannerHeight - else 0.dp + stickyTopInset ) .then( if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll) @@ -2572,6 +2633,18 @@ fun ChatsListScreen( } } } + val isVoicePlaybackActive by + remember( + dialog.opponentKey, + playingVoiceDialogKey + ) { + derivedStateOf { + isVoicePlayingForDialog( + dialog.opponentKey, + playingVoiceDialogKey + ) + } + } val isSelectedDialog = selectedChatKeys .contains( @@ -2613,6 +2686,8 @@ fun ChatsListScreen( typingDisplayName, typingSenderPublicKey = typingSenderPublicKey, + isVoicePlaybackActive = + isVoicePlaybackActive, isBlocked = isBlocked, isSavedMessages = @@ -2746,14 +2821,41 @@ fun ChatsListScreen( } } } - if (showStickyCallBanner) { - CallTopBanner( - state = callUiState, - isSticky = true, - isDarkTheme = isDarkTheme, - avatarRepository = avatarRepository, - onOpenCall = onOpenCallOverlay - ) + if (showStickyCallBanner || showVoiceMiniPlayer) { + Column( + modifier = + Modifier.fillMaxWidth() + .align( + Alignment.TopCenter + ) + ) { + if (showStickyCallBanner) { + CallTopBanner( + state = callUiState, + isSticky = true, + isDarkTheme = isDarkTheme, + avatarRepository = avatarRepository, + onOpenCall = onOpenCallOverlay + ) + } + if (showVoiceMiniPlayer) { + VoiceTopMiniPlayer( + title = voiceMiniPlayerTitle, + isDarkTheme = isDarkTheme, + isPlaying = isVoicePlaybackRunning, + speed = voicePlaybackSpeed, + onTogglePlay = { + VoicePlaybackCoordinator.toggleCurrentPlayback() + }, + onCycleSpeed = { + VoicePlaybackCoordinator.cycleSpeed() + }, + onClose = { + VoicePlaybackCoordinator.stop() + } + ) + } + } } } } @@ -3722,6 +3824,7 @@ fun SwipeableDialogItem( isTyping: Boolean = false, typingDisplayName: String = "", typingSenderPublicKey: String = "", + isVoicePlaybackActive: Boolean = false, isBlocked: Boolean = false, isGroupChat: Boolean = false, isSavedMessages: Boolean = false, @@ -4125,6 +4228,7 @@ fun SwipeableDialogItem( isTyping = isTyping, typingDisplayName = typingDisplayName, typingSenderPublicKey = typingSenderPublicKey, + isVoicePlaybackActive = isVoicePlaybackActive, isPinned = isPinned, isBlocked = isBlocked, isMuted = isMuted, @@ -4144,6 +4248,7 @@ fun DialogItemContent( isTyping: Boolean = false, typingDisplayName: String = "", typingSenderPublicKey: String = "", + isVoicePlaybackActive: Boolean = false, isPinned: Boolean = false, isBlocked: Boolean = false, isMuted: Boolean = false, @@ -4278,12 +4383,12 @@ fun DialogItemContent( // Name and last message Column(modifier = Modifier.weight(1f)) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).heightIn(min = 22.dp), verticalAlignment = Alignment.CenterVertically ) { AppleEmojiText( @@ -4293,7 +4398,8 @@ fun DialogItemContent( color = textColor, maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false + enableLinks = false, + minHeightMultiplier = 1f ) if (isGroupDialog) { Spacer(modifier = Modifier.width(5.dp)) @@ -4301,7 +4407,7 @@ fun DialogItemContent( imageVector = TablerIcons.Users, contentDescription = null, tint = secondaryTextColor.copy(alpha = 0.9f), - modifier = Modifier.size(15.dp) + modifier = Modifier.size(14.dp) ) } val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey) @@ -4310,7 +4416,7 @@ fun DialogItemContent( VerifiedBadge( verified = if (dialog.verified > 0) dialog.verified else 1, size = 16, - modifier = Modifier.offset(y = (-2).dp), + modifier = Modifier, isDarkTheme = isDarkTheme, badgeTint = PrimaryBlue ) @@ -4337,6 +4443,7 @@ fun DialogItemContent( } Row( + modifier = Modifier.heightIn(min = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { @@ -4467,7 +4574,7 @@ fun DialogItemContent( 0.6f ), modifier = - Modifier.size(14.dp) + Modifier.size(16.dp) ) Spacer( modifier = @@ -4487,9 +4594,11 @@ fun DialogItemContent( Text( text = formattedTime, fontSize = 13.sp, + lineHeight = 13.sp, color = if (visibleUnreadCount > 0) PrimaryBlue - else secondaryTextColor + else secondaryTextColor, + modifier = Modifier.align(Alignment.CenterVertically) ) } } @@ -4506,18 +4615,35 @@ fun DialogItemContent( modifier = Modifier.weight(1f).heightIn(min = 20.dp), contentAlignment = Alignment.CenterStart ) { + val subtitleMode = + remember( + isVoicePlaybackActive, + isTyping, + dialog.draftText + ) { + when { + isVoicePlaybackActive -> "voice" + isTyping -> "typing" + !dialog.draftText.isNullOrEmpty() -> "draft" + else -> "message" + } + } Crossfade( - targetState = isTyping, + targetState = subtitleMode, animationSpec = tween(150), label = "chatSubtitle" - ) { showTyping -> - if (showTyping) { + ) { mode -> + if (mode == "voice") { + VoicePlaybackIndicatorSmall( + isDarkTheme = isDarkTheme + ) + } else if (mode == "typing") { TypingIndicatorSmall( isDarkTheme = isDarkTheme, typingDisplayName = typingDisplayName, typingSenderPublicKey = typingSenderPublicKey ) - } else if (!dialog.draftText.isNullOrEmpty()) { + } else if (mode == "draft") { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "Draft: ", @@ -4527,7 +4653,7 @@ fun DialogItemContent( maxLines = 1 ) AppleEmojiText( - text = dialog.draftText, + text = dialog.draftText.orEmpty(), modifier = Modifier.weight(1f), fontSize = 14.sp, color = secondaryTextColor, @@ -4868,6 +4994,161 @@ fun TypingIndicatorSmall( } } +@Composable +private fun VoicePlaybackIndicatorSmall( + isDarkTheme: Boolean +) { + val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC) + val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator") + val levels = List(3) { index -> + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 900 + 0f at 0 + 1f at 280 + 0.2f at 580 + 0f at 900 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(index * 130) + ), + label = "voiceBar$index" + ).value + } + + Row( + modifier = Modifier.heightIn(min = 18.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) { + val barWidth = 2.dp.toPx() + val gap = 2.dp.toPx() + val baseY = size.height + repeat(3) { index -> + val x = index * (barWidth + gap) + val progress = levels[index].coerceIn(0f, 1f) + val minH = 3.dp.toPx() + val maxH = 10.dp.toPx() + val height = minH + (maxH - minH) * progress + drawRoundRect( + color = accentColor.copy(alpha = 0.6f + progress * 0.4f), + topLeft = Offset(x, baseY - height), + size = androidx.compose.ui.geometry.Size(barWidth, height), + cornerRadius = + androidx.compose.ui.geometry.CornerRadius( + x = barWidth, + y = barWidth + ) + ) + } + } + Spacer(modifier = Modifier.width(5.dp)) + AppleEmojiText( + text = "Listening", + fontSize = 14.sp, + color = accentColor, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false, + minHeightMultiplier = 1f + ) + } +} + +private 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" + } else { + "${normalized}x" + } +} + +@Composable +private fun VoiceTopMiniPlayer( + title: String, + isDarkTheme: Boolean, + isPlaying: Boolean, + speed: Float, + onTogglePlay: () -> Unit, + 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) + + Row( + modifier = + Modifier.fillMaxWidth() + .height(36.dp) + .background(containerColor) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onTogglePlay, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = + if (isPlaying) TablerIcons.PlayerPause + else TablerIcons.PlayerPlay, + contentDescription = if (isPlaying) "Pause voice" else "Play voice", + tint = accentColor, + modifier = Modifier.size(18.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 + ) + + Box( + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .border(1.dp, accentColor.copy(alpha = 0.55f), 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 + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + IconButton( + onClick = onClose, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = TablerIcons.X, + contentDescription = "Close voice", + tint = secondaryColor, + modifier = Modifier.size(18.dp) + ) + } + } +} + @Composable private fun SwipeBackContainer( onBack: () -> Unit, @@ -5467,7 +5748,7 @@ fun DrawerMenuItemEnhanced( Text( text = text, fontSize = 15.sp, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = textColor, modifier = Modifier.weight(1f) ) @@ -5527,7 +5808,7 @@ fun DrawerMenuItemEnhanced( Text( text = text, fontSize = 15.sp, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = textColor, modifier = Modifier.weight(1f) ) @@ -5561,7 +5842,7 @@ fun DrawerMenuItemEnhanced( fun DrawerDivider(isDarkTheme: Boolean) { Spacer(modifier = Modifier.height(8.dp)) Divider( - modifier = Modifier.padding(horizontal = 20.dp), + modifier = Modifier.fillMaxWidth(), color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC), thickness = 0.5.dp ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 5f1ae95..134e9c5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -573,23 +573,23 @@ private fun ChatsTabContent( } } - // ─── Recent header (always show with Clear All) ─── - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 14.dp, bottom = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Recent", - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = PrimaryBlue - ) - if (recentUsers.isNotEmpty()) { + // ─── Recent header (only when there are recents) ─── + if (recentUsers.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 14.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Recent", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = PrimaryBlue + ) Text( "Clear All", fontSize = 13.sp, 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 55488ce..6c6ef99 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 @@ -7,6 +7,7 @@ import android.graphics.Matrix import android.media.AudioAttributes import android.media.MediaPlayer import android.net.Uri +import android.os.Build import android.os.SystemClock import android.util.Base64 import android.util.LruCache @@ -91,6 +92,8 @@ import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.File import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Locale import kotlin.math.min import androidx.compose.ui.platform.LocalConfiguration import androidx.core.content.FileProvider @@ -153,25 +156,48 @@ private fun decodeVoicePayload(data: String): ByteArray? { return decodeHexPayload(data) ?: decodeBase64Payload(data) } -private object VoicePlaybackCoordinator { +object VoicePlaybackCoordinator { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val speedSteps = listOf(1f, 1.5f, 2f) private var player: MediaPlayer? = null private var currentAttachmentId: String? = null private var progressJob: Job? = null private val _playingAttachmentId = MutableStateFlow(null) val playingAttachmentId: StateFlow = _playingAttachmentId.asStateFlow() + private val _playingDialogKey = MutableStateFlow(null) + val playingDialogKey: StateFlow = _playingDialogKey.asStateFlow() private val _positionMs = MutableStateFlow(0) val positionMs: StateFlow = _positionMs.asStateFlow() private val _durationMs = MutableStateFlow(0) val durationMs: StateFlow = _durationMs.asStateFlow() + private val _isPlaying = MutableStateFlow(false) + val isPlaying: StateFlow = _isPlaying.asStateFlow() + private val _playbackSpeed = MutableStateFlow(1f) + val playbackSpeed: StateFlow = _playbackSpeed.asStateFlow() + private val _playingSenderLabel = MutableStateFlow("") + val playingSenderLabel: StateFlow = _playingSenderLabel.asStateFlow() + private val _playingTimeLabel = MutableStateFlow("") + val playingTimeLabel: StateFlow = _playingTimeLabel.asStateFlow() - fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) { + fun toggle( + attachmentId: String, + sourceFile: File, + dialogKey: String = "", + senderLabel: String = "", + playedAtLabel: String = "", + onError: (String) -> Unit = {} + ) { if (!sourceFile.exists()) { onError("Voice file is missing") return } - if (currentAttachmentId == attachmentId && player?.isPlaying == true) { - stop() + + if (currentAttachmentId == attachmentId && player != null) { + if (_isPlaying.value) { + pause() + } else { + resume(onError = onError) + } return } @@ -187,22 +213,18 @@ private object VoicePlaybackCoordinator { mediaPlayer.setDataSource(sourceFile.absolutePath) mediaPlayer.setOnCompletionListener { stop() } mediaPlayer.prepare() + applyPlaybackSpeed(mediaPlayer) mediaPlayer.start() player = mediaPlayer currentAttachmentId = attachmentId _playingAttachmentId.value = attachmentId + _playingDialogKey.value = dialogKey.trim().ifBlank { null } + _playingSenderLabel.value = senderLabel.trim() + _playingTimeLabel.value = playedAtLabel.trim() _durationMs.value = mediaPlayer.duration.coerceAtLeast(0) _positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0) - progressJob?.cancel() - progressJob = - scope.launch { - while (isActive && currentAttachmentId == attachmentId) { - val active = player - if (active == null || !active.isPlaying) break - _positionMs.value = active.currentPosition.coerceAtLeast(0) - delay(120) - } - } + _isPlaying.value = true + startProgressUpdates(attachmentId) } catch (e: Exception) { runCatching { mediaPlayer.release() } stop() @@ -210,6 +232,80 @@ private object VoicePlaybackCoordinator { } } + fun toggleCurrentPlayback(onError: (String) -> Unit = {}) { + if (player == null || currentAttachmentId.isNullOrBlank()) return + if (_isPlaying.value) { + pause() + } else { + resume(onError = onError) + } + } + + fun pause() { + val active = player ?: return + runCatching { + if (active.isPlaying) active.pause() + } + _isPlaying.value = false + progressJob?.cancel() + progressJob = null + _positionMs.value = active.currentPosition.coerceAtLeast(0) + } + + fun resume(onError: (String) -> Unit = {}) { + val active = player ?: return + val attachmentId = currentAttachmentId + if (attachmentId.isNullOrBlank()) return + try { + applyPlaybackSpeed(active) + active.start() + _durationMs.value = active.duration.coerceAtLeast(0) + _positionMs.value = active.currentPosition.coerceAtLeast(0) + _isPlaying.value = true + startProgressUpdates(attachmentId) + } catch (e: Exception) { + stop() + onError(e.message ?: "Playback failed") + } + } + + fun cycleSpeed() { + val current = _playbackSpeed.value + val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f } + val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size] + setPlaybackSpeed(next) + } + + private fun setPlaybackSpeed(speed: Float) { + val normalized = + speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first() + _playbackSpeed.value = normalized + player?.let { applyPlaybackSpeed(it) } + } + + private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + runCatching { + val current = mediaPlayer.playbackParams + mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value) + } + } + + private fun startProgressUpdates(attachmentId: String) { + progressJob?.cancel() + progressJob = + scope.launch { + while (isActive && currentAttachmentId == attachmentId) { + val active = player ?: break + _positionMs.value = active.currentPosition.coerceAtLeast(0) + _durationMs.value = active.duration.coerceAtLeast(0) + if (!active.isPlaying) break + delay(120) + } + _isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId + } + } + fun stop() { val active = player player = null @@ -217,8 +313,12 @@ private object VoicePlaybackCoordinator { progressJob?.cancel() progressJob = null _playingAttachmentId.value = null + _playingDialogKey.value = null + _playingSenderLabel.value = "" + _playingTimeLabel.value = "" _positionMs.value = 0 _durationMs.value = 0 + _isPlaying.value = false if (active != null) { runCatching { if (active.isPlaying) active.stop() @@ -593,6 +693,7 @@ fun MessageAttachments( isOutgoing: Boolean, isDarkTheme: Boolean, senderPublicKey: String, + senderDisplayName: String = "", dialogPublicKey: String = "", isGroupChat: Boolean = false, timestamp: java.util.Date, @@ -683,6 +784,8 @@ fun MessageAttachments( chachaKeyPlainHex = chachaKeyPlainHex, privateKey = privateKey, senderPublicKey = senderPublicKey, + senderDisplayName = senderDisplayName, + dialogPublicKey = dialogPublicKey, isOutgoing = isOutgoing, isDarkTheme = isDarkTheme, timestamp = timestamp, @@ -2036,6 +2139,8 @@ private fun VoiceAttachment( chachaKeyPlainHex: String, privateKey: String, senderPublicKey: String, + senderDisplayName: String, + dialogPublicKey: String, isOutgoing: Boolean, isDarkTheme: Boolean, timestamp: java.util.Date, @@ -2043,10 +2148,12 @@ private fun VoiceAttachment( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() + val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState() val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState() - val isPlaying = playingAttachmentId == attachment.id + val playbackIsPlaying by VoicePlaybackCoordinator.isPlaying.collectAsState() + val isActiveTrack = activeAttachmentId == attachment.id + val isPlaying = isActiveTrack && playbackIsPlaying val (previewDurationSecRaw, previewWavesRaw) = remember(attachment.preview) { parseVoicePreview(attachment.preview) } @@ -2078,21 +2185,37 @@ private fun VoiceAttachment( val effectiveDurationSec = remember(isPlaying, playbackDurationMs, previewDurationSec) { val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0) - if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec + if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec } val progress = - if (isPlaying && playbackDurationMs > 0) { + if (isActiveTrack && playbackDurationMs > 0) { (playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f) } else { 0f } val timeText = - if (isPlaying && playbackDurationMs > 0) { + if (isActiveTrack && playbackDurationMs > 0) { val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) - "-${formatVoiceDuration(leftSec)}" + formatVoiceDuration(leftSec) } else { formatVoiceDuration(effectiveDurationSec) } + val playbackSenderLabel = + remember(isOutgoing, senderDisplayName) { + val senderName = senderDisplayName.trim() + when { + isOutgoing -> "You" + senderName.isNotBlank() -> senderName + else -> "Voice" + } + } + val playbackTimeLabel = + remember(timestamp.time) { + runCatching { + SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp) + } + .getOrDefault("") + } LaunchedEffect(payload, attachment.id) { if (payload.isBlank()) return@LaunchedEffect @@ -2112,14 +2235,6 @@ private fun VoiceAttachment( } } - DisposableEffect(attachment.id) { - onDispose { - if (playingAttachmentId == attachment.id) { - VoicePlaybackCoordinator.stop() - } - } - } - val triggerDownload: () -> Unit = download@{ if (attachment.transportTag.isBlank()) { downloadStatus = DownloadStatus.ERROR @@ -2172,9 +2287,15 @@ private fun VoiceAttachment( if (file == null || !file.exists()) { if (payload.isNotBlank()) { val prepared = ensureVoiceAudioFile(context, attachment.id, payload) - if (prepared != null) { - audioFilePath = prepared.absolutePath - VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message -> + if (prepared != null) { + audioFilePath = prepared.absolutePath + VoicePlaybackCoordinator.toggle( + attachmentId = attachment.id, + sourceFile = prepared, + dialogKey = dialogPublicKey, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel + ) { message -> downloadStatus = DownloadStatus.ERROR errorText = message } @@ -2186,7 +2307,13 @@ private fun VoiceAttachment( triggerDownload() } } else { - VoicePlaybackCoordinator.toggle(attachment.id, file) { message -> + VoicePlaybackCoordinator.toggle( + attachmentId = attachment.id, + sourceFile = file, + dialogKey = dialogPublicKey, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel + ) { message -> downloadStatus = DownloadStatus.ERROR errorText = message } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 852c411..02f634c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -355,6 +355,16 @@ fun MessageBubble( onGroupInviteOpen: (SearchUser) -> Unit = {}, contextMenuContent: @Composable () -> Unit = {} ) { + val isTextSelectionOnThisMessage = + remember( + textSelectionHelper?.isInSelectionMode, + textSelectionHelper?.selectedMessageId, + message.id + ) { + textSelectionHelper?.isInSelectionMode == true && + textSelectionHelper.selectedMessageId == message.id + } + // Swipe-to-reply state val hapticFeedback = LocalHapticFeedback.current var swipeOffset by remember { mutableStateOf(0f) } @@ -374,7 +384,7 @@ fun MessageBubble( // Selection animations val selectionAlpha by animateFloatAsState( - targetValue = if (isSelected) 0.85f else 1f, + targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f, animationSpec = tween(150), label = "selectionAlpha" ) @@ -558,7 +568,8 @@ fun MessageBubble( val selectionBackgroundColor by animateColorAsState( targetValue = - if (isSelected) PrimaryBlue.copy(alpha = 0.15f) + if (isSelected && !isTextSelectionOnThisMessage) + PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent, animationSpec = tween(200), label = "selectionBg" @@ -1004,6 +1015,7 @@ fun MessageBubble( isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, senderPublicKey = senderPublicKey, + senderDisplayName = senderName, dialogPublicKey = dialogPublicKey, isGroupChat = isGroupChat, timestamp = message.timestamp, @@ -2381,6 +2393,8 @@ fun ReplyBubble( ) } else if (!hasImage) { val displayText = when { + replyData.attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message" + replyData.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message" replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call" else -> "..." 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 859abab..cad9e45 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer @@ -48,6 +49,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.draw.shadow @@ -78,10 +80,12 @@ import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.ChatViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import java.io.File import java.util.Locale import java.util.UUID @@ -89,6 +93,11 @@ import kotlin.math.PI import kotlin.math.sin private val EaseOutQuint = CubicBezierEasing(0.23f, 1f, 0.32f, 1f) +private const val INPUT_JUMP_LOG_ENABLED = false + +private fun lerpFloat(start: Float, end: Float, fraction: Float): Float { + return start + (end - start) * fraction +} private fun truncateEmojiSafe(text: String, maxLen: Int): String { if (text.length <= maxLen) return text @@ -209,6 +218,139 @@ private fun RecordBlinkDot( } } +@Composable +private fun TelegramVoiceDeleteIndicator( + cancelProgress: Float, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val progress = cancelProgress.coerceIn(0f, 1f) + val appear = FastOutSlowInEasing.transform(progress) + val openPhase = FastOutSlowInEasing.transform((progress / 0.45f).coerceIn(0f, 1f)) + val closePhase = FastOutSlowInEasing.transform(((progress - 0.55f) / 0.45f).coerceIn(0f, 1f)) + val lidAngle = -26f * openPhase * (1f - closePhase) + val dotFlight = FastOutSlowInEasing.transform((progress / 0.82f).coerceIn(0f, 1f)) + + val dangerColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) + val dotStartX = with(density) { (-8).dp.toPx() } + val dotEndX = with(density) { 0.dp.toPx() } + val dotEndY = with(density) { 6.dp.toPx() } + val dotX = lerpFloat(dotStartX, dotEndX, dotFlight) + val dotY = dotEndY * dotFlight * dotFlight + val dotScale = (1f - 0.72f * dotFlight).coerceAtLeast(0f) + val dotAlpha = (1f - dotFlight).coerceIn(0f, 1f) + + Box( + modifier = modifier.size(28.dp), + contentAlignment = Alignment.Center + ) { + RecordBlinkDot( + isDarkTheme = isDarkTheme, + modifier = Modifier.graphicsLayer { + alpha = 1f - appear + scaleX = 1f - 0.14f * appear + scaleY = 1f - 0.14f * appear + } + ) + + Canvas( + modifier = Modifier + .matchParentSize() + .graphicsLayer { + alpha = appear + scaleX = 0.84f + 0.16f * appear + scaleY = 0.84f + 0.16f * appear + } + ) { + val stroke = 1.7.dp.toPx() + val cx = size.width / 2f + val bodyW = size.width * 0.36f + val bodyH = size.height * 0.34f + val bodyLeft = cx - bodyW / 2f + val bodyTop = size.height * 0.45f + val bodyRadius = bodyW * 0.16f + val bodyRight = bodyLeft + bodyW + + drawRoundRect( + color = dangerColor, + topLeft = Offset(bodyLeft, bodyTop), + size = androidx.compose.ui.geometry.Size(bodyW, bodyH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius), + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round, + join = androidx.compose.ui.graphics.StrokeJoin.Round + ) + ) + + val slatYStart = bodyTop + bodyH * 0.18f + val slatYEnd = bodyTop + bodyH * 0.82f + drawLine( + color = dangerColor, + start = Offset(cx - bodyW * 0.18f, slatYStart), + end = Offset(cx - bodyW * 0.18f, slatYEnd), + strokeWidth = stroke * 0.85f, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + drawLine( + color = dangerColor, + start = Offset(cx + bodyW * 0.18f, slatYStart), + end = Offset(cx + bodyW * 0.18f, slatYEnd), + strokeWidth = stroke * 0.85f, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + + val rimY = bodyTop - 2.4.dp.toPx() + drawLine( + color = dangerColor, + start = Offset(bodyLeft - bodyW * 0.09f, rimY), + end = Offset(bodyRight + bodyW * 0.09f, rimY), + strokeWidth = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + + val lidY = rimY - 1.4.dp.toPx() + val lidLeft = bodyLeft - bodyW * 0.05f + val lidRight = bodyRight + bodyW * 0.05f + val lidPivot = Offset(bodyLeft + bodyW * 0.22f, lidY) + rotate( + degrees = lidAngle, + pivot = lidPivot + ) { + drawLine( + color = dangerColor, + start = Offset(lidLeft, lidY), + end = Offset(lidRight, lidY), + strokeWidth = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + drawLine( + color = dangerColor, + start = Offset(cx - bodyW * 0.1f, lidY - 2.dp.toPx()), + end = Offset(cx + bodyW * 0.1f, lidY - 2.dp.toPx()), + strokeWidth = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + } + + Box( + modifier = Modifier + .size(10.dp) + .graphicsLayer { + translationX = dotX + translationY = dotY + alpha = if (progress > 0f) dotAlpha else 0f + scaleX = dotScale + scaleY = dotScale + } + .clip(CircleShape) + .background(dangerColor) + ) + } +} + @Composable private fun VoiceMovingBlob( voiceLevel: Float, @@ -570,141 +712,110 @@ private fun LockIcon( } /** - * Telegram-exact SlideToCancel. + * iOS parity slide-to-cancel transform. * - * Layout: [chevron arrow] "Slide to cancel" - * - * - Arrow is a Canvas-drawn chevron (4×5dp, stroke 1.6dp, round caps) - * - Arrow oscillates ±6dp ONLY when slideProgress > 0.8 at 12dp/s - * - Text alpha = slideProgress (fades in with drag, 0 = invisible at rest) - * - Translation follows finger × 0.3 damping - * - Entry: slides in from right (translationX 20dp→0) with fade - * - * Reference: ChatActivityEnterView.SlideTextView (lines 13083-13357) + * Port from VoiceRecordingPanel.updateCancelTranslation(): + * - don't move until |dx| > 8dp + * - then translationX = -(abs(dx) - 8) * 0.5 + * - fade out while dragging left + * - idle arrow jiggle only when close to rest */ @Composable private fun SlideToCancel( slideDx: Float, - cancelThresholdPx: Float, isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - // slideProgress: 1.0 = at rest, decreases as finger drags left toward cancel - val slideProgress = 1f - ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) - val density = LocalDensity.current + val dragPx = (-slideDx).coerceAtLeast(0f) + val dragTransformThresholdPx = with(density) { 8.dp.toPx() } + val effectiveDragPx = (dragPx - dragTransformThresholdPx).coerceAtLeast(0f) + val slideTranslationX = -effectiveDragPx * 0.5f + val fadeDistancePx = with(density) { 90.dp.toPx() } + val contentAlpha = (1f - (effectiveDragPx / fadeDistancePx)).coerceIn(0f, 1f) - // Pre-compute px values for use in LaunchedEffect val maxOffsetPx = with(density) { 6.dp.toPx() } - val speedPxPerMs = with(density) { (3f / 250f).dp.toPx() } // 12dp/s + val speedPxPerMs = with(density) { (3f / 250f).dp.toPx() } - // Telegram: arrow oscillates ±6dp only when slideProgress > 0.8 + // Arrow oscillation: only when near the resting position. var xOffset by remember { mutableFloatStateOf(0f) } var moveForward by remember { mutableStateOf(true) } - LaunchedEffect(slideProgress > 0.8f) { - if (slideProgress <= 0.8f) { + LaunchedEffect(contentAlpha > 0.85f) { + if (contentAlpha <= 0.85f) { xOffset = 0f moveForward = true return@LaunchedEffect } var lastTime = System.nanoTime() while (true) { - delay(16) // ~60fps + delay(16) val now = System.nanoTime() val dtMs = (now - lastTime) / 1_000_000f lastTime = now - val step = speedPxPerMs * dtMs if (moveForward) { xOffset += step - if (xOffset > maxOffsetPx) { - xOffset = maxOffsetPx - moveForward = false - } + if (xOffset > maxOffsetPx) { xOffset = maxOffsetPx; moveForward = false } } else { xOffset -= step - if (xOffset < -maxOffsetPx) { - xOffset = -maxOffsetPx - moveForward = true - } + if (xOffset < -maxOffsetPx) { xOffset = -maxOffsetPx; moveForward = true } } } } - // Colors — Telegram: key_chat_recordTime (gray), key_glass_defaultIcon (arrow) - val textColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) - val arrowColor = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF8E8E93) - - // Entry animation: slide in from right - var entered by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - entered = true - } - val entryTranslation by animateFloatAsState( - targetValue = if (entered) 0f else with(density) { 20.dp.toPx() }, - animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), - label = "slide_cancel_entry" - ) - val entryAlpha by animateFloatAsState( - targetValue = if (entered) 1f else 0f, - animationSpec = tween(durationMillis = 200), - label = "slide_cancel_entry_alpha" - ) + val textColor = if (isDarkTheme) Color.White else Color(0xFF1F1F1F) + val arrowColor = if (isDarkTheme) Color.White else Color(0xFF1F1F1F) + Box( + modifier = modifier.clipToBounds() + ) { Row( - modifier = modifier + modifier = Modifier + .align(Alignment.Center) .graphicsLayer { - // Telegram: text follows finger × damping + entry slide + pulse offset - translationX = slideDx * 0.3f + entryTranslation + - xOffset * slideProgress - alpha = slideProgress * entryAlpha + translationX = slideTranslationX + xOffset * contentAlpha + alpha = contentAlpha }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - // Chevron arrow — Canvas-drawn, NOT text character - // Telegram: 4dp × 5dp chevron, stroke 1.6dp, round caps + // Telegram: arrow path 4×5dp, stroke 1.6dp, round caps+joins, total 10dp offset to text Canvas( - modifier = Modifier - .size(width = 10.dp, height = 14.dp) - .graphicsLayer { - translationX = xOffset * slideProgress - } + modifier = Modifier.size(width = 4.dp, height = 10.dp) ) { val midY = size.height / 2f - val arrowW = 4.dp.toPx() + val arrowW = size.width val arrowH = 5.dp.toPx() val strokeW = 1.6f.dp.toPx() - val startX = (size.width - arrowW) / 2f drawLine( color = arrowColor, - start = Offset(startX + arrowW, midY - arrowH), - end = Offset(startX, midY), + start = Offset(arrowW, midY - arrowH), + end = Offset(0f, midY), strokeWidth = strokeW, cap = androidx.compose.ui.graphics.StrokeCap.Round ) drawLine( color = arrowColor, - start = Offset(startX, midY), - end = Offset(startX + arrowW, midY + arrowH), + start = Offset(0f, midY), + end = Offset(arrowW, midY + arrowH), strokeWidth = strokeW, cap = androidx.compose.ui.graphics.StrokeCap.Round ) } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(6.dp)) - // "Slide to cancel" text — Telegram: 15sp, normal weight Text( text = "Slide to cancel", color = textColor, - fontSize = 15.sp, + fontSize = 14.sp, fontWeight = FontWeight.Normal, maxLines = 1 ) } + } } @Composable @@ -1045,13 +1156,24 @@ fun MessageInputBar( var voiceOutputFile by remember { mutableStateOf(null) } var isVoiceRecording by remember { mutableStateOf(false) } var isVoiceRecordTransitioning by remember { mutableStateOf(false) } + var isVoiceCancelAnimating by remember { mutableStateOf(false) } + var keepMicGestureCapture by remember { mutableStateOf(false) } var recordMode by rememberSaveable { mutableStateOf(RecordMode.VOICE) } var recordUiState by remember { mutableStateOf(RecordUiState.IDLE) } var pressStartX by remember { mutableFloatStateOf(0f) } var pressStartY by remember { mutableFloatStateOf(0f) } + var rawSlideDx by remember { mutableFloatStateOf(0f) } + var rawSlideDy by remember { mutableFloatStateOf(0f) } var slideDx by remember { mutableFloatStateOf(0f) } var slideDy by remember { mutableFloatStateOf(0f) } var lockProgress by remember { mutableFloatStateOf(0f) } + var dragVelocityX by remember { mutableFloatStateOf(0f) } + var dragVelocityY by remember { mutableFloatStateOf(0f) } + var lastDragDx by remember { mutableFloatStateOf(0f) } + var lastDragDy by remember { mutableFloatStateOf(0f) } + var lastDragEventTimeMs by remember { mutableLongStateOf(0L) } + var didCancelHaptic by remember { mutableStateOf(false) } + var didLockHaptic by remember { mutableStateOf(false) } var pendingLongPressJob by remember { mutableStateOf(null) } var pendingRecordAfterPermission by remember { mutableStateOf(false) } var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) } @@ -1065,22 +1187,8 @@ fun MessageInputBar( var normalInputRowY by remember { mutableFloatStateOf(0f) } var recordingInputRowHeightPx by remember { mutableIntStateOf(0) } var recordingInputRowY by remember { mutableFloatStateOf(0f) } - fun inputJumpLog(msg: String) { - try { - val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()) - .format(java.util.Date()) - val dir = java.io.File(context.filesDir, "crash_reports") - if (!dir.exists()) dir.mkdirs() - val line = "$ts [InputJump] $msg\n" - // Write newest records to TOP so they are immediately visible in Crash Details preview. - fun writeNewestFirst(file: java.io.File, maxChars: Int = 220_000) { - val existing = if (file.exists()) runCatching { file.readText() }.getOrDefault("") else "" - file.writeText(line + existing.take(maxChars)) - } - writeNewestFirst(java.io.File(dir, "rosettadev1.txt")) - writeNewestFirst(java.io.File(dir, "rosettadev1_input.txt")) - } catch (_: Exception) {} + if (!INPUT_JUMP_LOG_ENABLED) return } fun inputHeightsSnapshot(): String { @@ -1091,15 +1199,29 @@ fun MessageInputBar( } fun setRecordUiState(newState: RecordUiState, reason: String) { - if (recordUiState == newState) return + // Temporary rollout: lock/pause flow disabled, keep a single recording state. + val normalizedState = when (newState) { + RecordUiState.LOCKED, RecordUiState.PAUSED -> RecordUiState.RECORDING + else -> newState + } + if (recordUiState == normalizedState) return val oldState = recordUiState - recordUiState = newState - inputJumpLog("recordState $oldState -> $newState reason=$reason mode=$recordMode") + recordUiState = normalizedState + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordState $oldState -> $normalizedState reason=$reason mode=$recordMode") } fun resetGestureState() { + rawSlideDx = 0f + rawSlideDy = 0f slideDx = 0f slideDy = 0f + dragVelocityX = 0f + dragVelocityY = 0f + lastDragDx = 0f + lastDragDy = 0f + lastDragEventTimeMs = 0L + didCancelHaptic = false + didLockHaptic = false pressStartX = 0f pressStartY = 0f lockProgress = 0f @@ -1109,19 +1231,25 @@ fun MessageInputBar( fun toggleRecordModeByTap() { recordMode = if (recordMode == RecordMode.VOICE) RecordMode.VIDEO else RecordMode.VOICE - inputJumpLog("recordMode toggled -> $recordMode (short tap)") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordMode toggled -> $recordMode (short tap)") } val shouldPinBottomForInput = isKeyboardVisible || coordinator.isEmojiBoxVisible || - isVoiceRecordTransitioning || - recordUiState == RecordUiState.PAUSED + isVoiceRecordTransitioning val shouldAddNavBarPadding = hasNativeNavigationBar && !shouldPinBottomForInput - fun stopVoiceRecording(send: Boolean) { + fun stopVoiceRecording( + send: Boolean, + preserveCancelAnimation: Boolean = false + ) { isVoiceRecordTransitioning = false - inputJumpLog( + if (!preserveCancelAnimation) { + isVoiceCancelAnimating = false + } + keepMicGestureCapture = false + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "stopVoiceRecording begin send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " + "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx" @@ -1154,31 +1282,46 @@ fun MessageInputBar( scope.launch(kotlinx.coroutines.Dispatchers.IO) { var recordedOk = false if (recorder != null) { - recordedOk = runCatching { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: calling recorder.stop() send=$send") + val stopResult = runCatching { recorder.stop() true - }.getOrDefault(false) + } + recordedOk = stopResult.getOrDefault(false) + if (stopResult.isFailure) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder.stop() FAILED: ${stopResult.exceptionOrNull()?.message}") + } + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder.stop() ok=$recordedOk, calling reset+release") runCatching { recorder.reset() } runCatching { recorder.release() } + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder released") } if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) { + val fileSize = outputFile.length() + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: reading file ${outputFile.name} size=${fileSize}bytes duration=${durationSnapshot}s") val voiceHex = runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: hex length=${voiceHex.length} sending=${voiceHex.isNotBlank()}") if (voiceHex.isNotBlank()) { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording Main: calling onSendVoiceMessage duration=$durationSnapshot waves=${wavesSnapshot.size}") onSendVoiceMessage( voiceHex, durationSnapshot, compressVoiceWaves(wavesSnapshot, 35) ) + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording Main: onSendVoiceMessage done") } } + } else if (send) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: NOT sending — recordedOk=$recordedOk file=${outputFile?.name} exists=${outputFile?.exists()} size=${outputFile?.length()}") } runCatching { outputFile?.delete() } + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: cleanup done") } - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " + "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx" @@ -1187,7 +1330,7 @@ fun MessageInputBar( fun startVoiceRecording() { if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx" ) @@ -1210,10 +1353,13 @@ fun MessageInputBar( recorder.setAudioSamplingRate(48_000) recorder.setOutputFile(output.absolutePath) recorder.setMaxDuration(15 * 60 * 1000) // 15 min safety limit + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: calling prepare() file=${output.name}") recorder.prepare() + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: calling start()") recorder.start() + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: recorder started OK") recorder.setOnErrorListener { _, what, extra -> - inputJumpLog("MediaRecorder error what=$what extra=$extra") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("MediaRecorder error what=$what extra=$extra") stopVoiceRecording(send = false) } @@ -1230,7 +1376,7 @@ fun MessageInputBar( if (showEmojiPicker || coordinator.isEmojiBoxVisible) { onToggleEmojiPicker(false) } - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "startVoiceRecording armed mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " + "pinBottom=$shouldPinBottomForInput " + @@ -1248,14 +1394,14 @@ fun MessageInputBar( if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) { setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started") } - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "startVoiceRecording ui-enter mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " + "panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx" ) } catch (e: Exception) { isVoiceRecordTransitioning = false - inputJumpLog("startVoiceRecording launch failed: ${e.message}") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording launch failed: ${e.message}") } } } catch (_: Exception) { @@ -1269,24 +1415,46 @@ fun MessageInputBar( } } + fun cancelVoiceRecordingWithAnimation(origin: String) { + if (isVoiceCancelAnimating) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "cancelVoiceRecordingWithAnimation already animating origin=$origin " + + "voice=$isVoiceRecording recorder=${voiceRecorder != null}" + ) + if (isVoiceRecording || voiceRecorder != null) { + stopVoiceRecording(send = false) + } + return + } + if (!isVoiceRecording && voiceRecorder == null) { + setRecordUiState(RecordUiState.IDLE, "cancel-no-recorder origin=$origin") + return + } + keepMicGestureCapture = false + isVoiceCancelAnimating = true + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancelVoiceRecordingWithAnimation start origin=$origin") + // Stop recorder immediately (off-main) to avoid stuck recording state / ANR on cancel. + stopVoiceRecording(send = false, preserveCancelAnimation = true) + } + fun pauseVoiceRecording() { val recorder = voiceRecorder ?: return if (!isVoiceRecording || isVoicePaused) return - inputJumpLog("pauseVoiceRecording mode=$recordMode state=$recordUiState") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("pauseVoiceRecording mode=$recordMode state=$recordUiState") try { recorder.pause() isVoicePaused = true voicePausedElapsedMs = voiceElapsedMs setRecordUiState(RecordUiState.PAUSED, "pause-pressed") } catch (e: Exception) { - inputJumpLog("pauseVoiceRecording failed: ${e.message}") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("pauseVoiceRecording failed: ${e.message}") } } fun resumeVoiceRecording() { val recorder = voiceRecorder ?: return if (!isVoiceRecording || !isVoicePaused) return - inputJumpLog("resumeVoiceRecording mode=$recordMode state=$recordUiState") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("resumeVoiceRecording mode=$recordMode state=$recordUiState") try { recorder.resume() voiceRecordStartedAtMs = System.currentTimeMillis() - voicePausedElapsedMs @@ -1294,32 +1462,34 @@ fun MessageInputBar( voicePausedElapsedMs = 0L setRecordUiState(RecordUiState.LOCKED, "resume-pressed") } catch (e: Exception) { - inputJumpLog("resumeVoiceRecording failed: ${e.message}") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("resumeVoiceRecording failed: ${e.message}") } } - LaunchedEffect(Unit) { - snapshotFlow { - val kb = coordinator.keyboardHeight.value.toInt() - val em = coordinator.emojiHeight.value.toInt() - val panelY = (inputPanelY * 10f).toInt() / 10f - val normalY = (normalInputRowY * 10f).toInt() / 10f - val recY = (recordingInputRowY * 10f).toInt() / 10f - val pinBottom = - isKeyboardVisible || - coordinator.isEmojiBoxVisible || - isVoiceRecordTransitioning || - recordUiState == RecordUiState.PAUSED - val navPad = hasNativeNavigationBar && !pinBottom - "mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " + - "voice=$isVoiceRecording kbVis=$isKeyboardVisible kbDp=$kb emojiBox=${coordinator.isEmojiBoxVisible} " + - "emojiVisible=$showEmojiPicker emojiDp=$em suppress=$suppressKeyboard " + - "voiceTransitioning=$isVoiceRecordTransitioning " + - "pinBottom=$pinBottom navPad=$navPad " + - "panelH=$inputPanelHeightPx panelY=$panelY normalH=$normalInputRowHeightPx " + - "normalY=$normalY recH=$recordingInputRowHeightPx recY=$recY" - }.distinctUntilChanged().collect { stateLine -> - inputJumpLog(stateLine) + if (INPUT_JUMP_LOG_ENABLED) { + LaunchedEffect(Unit) { + snapshotFlow { + val kb = coordinator.keyboardHeight.value.toInt() + val em = coordinator.emojiHeight.value.toInt() + val panelY = (inputPanelY * 10f).toInt() / 10f + val normalY = (normalInputRowY * 10f).toInt() / 10f + val recY = (recordingInputRowY * 10f).toInt() / 10f + val pinBottom = + isKeyboardVisible || + coordinator.isEmojiBoxVisible || + isVoiceRecordTransitioning || + recordUiState == RecordUiState.PAUSED + val navPad = hasNativeNavigationBar && !pinBottom + "mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " + + "voice=$isVoiceRecording kbVis=$isKeyboardVisible kbDp=$kb emojiBox=${coordinator.isEmojiBoxVisible} " + + "emojiVisible=$showEmojiPicker emojiDp=$em suppress=$suppressKeyboard " + + "voiceTransitioning=$isVoiceRecordTransitioning " + + "pinBottom=$pinBottom navPad=$navPad " + + "panelH=$inputPanelHeightPx panelY=$panelY normalH=$normalInputRowHeightPx " + + "normalY=$normalY recH=$recordingInputRowHeightPx recY=$recY" + }.distinctUntilChanged().collect { stateLine -> + inputJumpLog(stateLine) + } } } @@ -1345,7 +1515,7 @@ fun MessageInputBar( } fun requestVoiceRecordingFromHold(): Boolean { - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "requestVoiceRecordingFromHold mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" ) @@ -1364,15 +1534,17 @@ fun MessageInputBar( } } - val holdToRecordDelayMs = 260L - val cancelDragThresholdPx = with(density) { 140.dp.toPx() } - val lockDragThresholdPx = with(density) { 70.dp.toPx() } + // iOS parity (RecordingMicButton.swift / VoiceRecordingParityMath.swift): + // hold=0.19s, cancel=-150, cancel-on-release=-100, velocityGate=-400. + val holdToRecordDelayMs = 190L + val preHoldCancelDistancePx = with(density) { 10.dp.toPx() } + val cancelDragThresholdPx = with(density) { 150.dp.toPx() } + val releaseCancelThresholdPx = with(density) { 100.dp.toPx() } + val velocityGatePxPerSec = -400f + val dragSmoothingPrev = 0.7f + val dragSmoothingNew = 0.3f 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) { @@ -1410,26 +1582,46 @@ 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 - } + LaunchedEffect(recordUiState, lockProgress) { + showLockTooltip = false + } + + // Deterministic cancel commit: after animation, always finalize recording stop. + LaunchedEffect(isVoiceCancelAnimating) { + if (!isVoiceCancelAnimating) return@LaunchedEffect + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "cancel animation commit scheduled voice=$isVoiceRecording recorder=${voiceRecorder != null}" + ) + delay(220) + if (!isVoiceCancelAnimating) return@LaunchedEffect + if (isVoiceRecording || voiceRecorder != null) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancel animation commit -> stopVoiceRecording(send=false)") + stopVoiceRecording(send = false) } else { - showLockTooltip = false + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancel animation commit -> no recorder, reset UI") + isVoiceCancelAnimating = false + keepMicGestureCapture = false + setRecordUiState(RecordUiState.IDLE, "cancel-commit-no-recorder") + resetGestureState() } } - LaunchedEffect(lockProgress) { - if (lockProgress > 0.2f) { - showLockTooltip = false + // Safety guard: never allow cancel animation flag to stick and block UI. + LaunchedEffect(isVoiceCancelAnimating) { + if (!isVoiceCancelAnimating) return@LaunchedEffect + delay(1300) + if (isVoiceCancelAnimating) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "cancel animation watchdog: force-finish voice=$isVoiceRecording recorder=${voiceRecorder != null}" + ) + if (isVoiceRecording || voiceRecorder != null) { + stopVoiceRecording(send = false) + } else { + isVoiceCancelAnimating = false + keepMicGestureCapture = false + setRecordUiState(RecordUiState.IDLE, "cancel-watchdog-reset") + resetGestureState() + } } } @@ -1437,6 +1629,8 @@ fun MessageInputBar( onDispose { pendingRecordAfterPermission = false isVoiceRecordTransitioning = false + isVoiceCancelAnimating = false + keepMicGestureCapture = false resetGestureState() if (isVoiceRecording || voiceRecorder != null) { stopVoiceRecording(send = false) @@ -1896,9 +2090,19 @@ fun MessageInputBar( val hasCallAttachment = msg.attachments.any { it.type == AttachmentType.CALL } + val hasVoiceAttachment = msg.attachments.any { + it.type == AttachmentType.VOICE + } + val hasVideoCircleAttachment = msg.attachments.any { + it.type == AttachmentType.VIDEO_CIRCLE + } AppleEmojiText( text = if (panelReplyMessages.size == 1) { - if (msg.text.isEmpty() && hasCallAttachment) { + if (msg.text.isEmpty() && hasVoiceAttachment) { + "Voice Message" + } else if (msg.text.isEmpty() && hasVideoCircleAttachment) { + "Video Message" + } else if (msg.text.isEmpty() && hasCallAttachment) { "Call" } else if (msg.text.isEmpty() && hasImageAttachment) { "Photo" @@ -2081,19 +2285,41 @@ fun MessageInputBar( // Layer 1: panel bar (timer + center content) // Layer 2: mic/send circle OVERLAY at right edge (extends beyond panel) // Layer 3: lock icon ABOVE circle (extends above panel) + val isRecordingPanelVisible = isVoiceRecording || isVoiceCancelAnimating + val recordingPanelTransitionState = + remember { MutableTransitionState(false) }.apply { + targetState = isRecordingPanelVisible + } + // True while visible OR while enter/exit animation is still running. + val isRecordingPanelComposed = + recordingPanelTransitionState.currentState || recordingPanelTransitionState.targetState androidx.compose.animation.AnimatedVisibility( - visible = isVoiceRecording, - enter = fadeIn(tween(180)) + expandVertically(tween(180)), - exit = fadeOut(tween(300)) + shrinkVertically(tween(300)) + visibleState = recordingPanelTransitionState, + // Telegram-like smooth dissolve without any vertical resize. + enter = fadeIn(tween(durationMillis = 170, easing = LinearOutSlowInEasing)), + exit = fadeOut(tween(durationMillis = 210, easing = FastOutLinearInEasing)) ) { val recordingPanelColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD) val recordingTextColor = if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF1E2A37) + // Keep layout height equal to normal input row. + // The button "grows" visually via scale, not via measured size. + val recordingActionButtonBaseSize = 40.dp + // Telegram-like proportions: large button that does not dominate the panel. + val recordingActionVisualScale = 1.42f // 40dp -> ~57dp visual size + val recordingActionInset = 34.dp + val recordingActionOverflowX = 8.dp + val recordingActionOverflowY = 10.dp val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f } + val cancelAnimProgress by animateFloatAsState( + targetValue = if (isVoiceCancelAnimating) 1f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "voice_cancel_anim" + ) var recordUiEntered by remember { mutableStateOf(false) } - LaunchedEffect(isVoiceRecording) { - if (isVoiceRecording) { + LaunchedEffect(isVoiceRecording, isVoiceCancelAnimating) { + if (isVoiceRecording || isVoiceCancelAnimating) { recordUiEntered = false delay(16) recordUiEntered = true @@ -2122,20 +2348,30 @@ fun MessageInputBar( .fillMaxWidth() .heightIn(min = 48.dp) .padding(horizontal = 8.dp, vertical = 8.dp) + .zIndex(2f) .onGloballyPositioned { coordinates -> recordingInputRowHeightPx = coordinates.size.height recordingInputRowY = coordinates.positionInWindow().y } ) { val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED + // iOS parity (VoiceRecordingOverlay.applyCurrentTransforms): + // valueX = abs(distanceX) / 300 + // innerScale = clamp(1 - valueX, 0.4..1) + // translatedX = distanceX * innerScale + val slideDistanceX = slideDx.coerceAtMost(0f) + val slideValueX = (kotlin.math.abs(slideDistanceX) / with(density) { 300.dp.toPx() }) + .coerceIn(0f, 1f) + val circleSlideCancelScale = (1f - slideValueX).coerceIn(0.4f, 1f) + val circleSlideDelta = slideDistanceX * circleSlideCancelScale // Crossfade between RECORDING panel and LOCKED panel AnimatedContent( targetState = isLockedOrPaused, modifier = Modifier .fillMaxWidth() - .height(44.dp) - .padding(end = 52.dp), // space for circle overlay + .height(48.dp) + .padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap) transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, @@ -2147,19 +2383,19 @@ fun MessageInputBar( Row( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(24.dp)) .background(recordingPanelColor), verticalAlignment = Alignment.CenterVertically ) { // Delete button — Telegram: 44×44dp, Lottie trash icon Box( modifier = Modifier - .size(44.dp) + .size(recordingActionButtonBaseSize) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) { - inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState") stopVoiceRecording(send = false) }, contentAlignment = Alignment.Center @@ -2187,7 +2423,7 @@ fun MessageInputBar( Row( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(24.dp)) .background(recordingPanelColor) .padding(start = 13.dp), verticalAlignment = Alignment.CenterVertically @@ -2196,12 +2432,15 @@ fun MessageInputBar( Row( modifier = Modifier .graphicsLayer { - alpha = recordUiAlpha + alpha = recordUiAlpha * (1f - cancelAnimProgress) translationX = with(density) { recordUiShift.toPx() } }, verticalAlignment = Alignment.CenterVertically ) { - RecordBlinkDot(isDarkTheme = isDarkTheme) + TelegramVoiceDeleteIndicator( + cancelProgress = cancelAnimProgress, + isDarkTheme = isDarkTheme + ) Spacer(modifier = Modifier.width(6.dp)) Text( text = formatVoiceRecordTimer(voiceElapsedMs), @@ -2216,12 +2455,11 @@ fun MessageInputBar( // Slide to cancel SlideToCancel( slideDx = slideDx, - cancelThresholdPx = cancelDragThresholdPx, isDarkTheme = isDarkTheme, modifier = Modifier .weight(1f) .graphicsLayer { - alpha = recordUiAlpha + alpha = recordUiAlpha * (1f - cancelAnimProgress) translationX = with(density) { recordUiShift.toPx() } } ) @@ -2230,23 +2468,26 @@ fun MessageInputBar( } // ── Layer 2: Circle + Lock overlay ── - // 48dp layout box at right edge; visuals overflow via graphicsLayer - // Telegram: circle center at 26dp from right, radius 41dp = 82dp visual Box( modifier = Modifier - .size(48.dp) - .align(Alignment.CenterEnd) - .offset(x = 4.dp) // slight overlap into right padding + .size(recordingActionButtonBaseSize) + .align(Alignment.BottomEnd) + .offset(x = recordingActionOverflowX, y = recordingActionOverflowY) + .graphicsLayer { + translationX = circleSlideDelta + scaleX = recordingActionVisualScale * circleSlideCancelScale + scaleY = recordingActionVisualScale * circleSlideCancelScale + transformOrigin = TransformOrigin(0.5f, 0.5f) + } .zIndex(5f), contentAlignment = Alignment.Center ) { - // Lock icon: floats ~70dp above circle center (Telegram: ~92dp) - if (recordUiState == RecordUiState.RECORDING || - recordUiState == RecordUiState.LOCKED || + // Lock icon above circle + if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED ) { - val lockSizeDp = 50.dp - 14.dp * lockProgress - val lockYDp = -70.dp + 14.dp * lockProgress + val lockSizeDp = 36.dp + 10.dp * (1f - lockProgress) + val lockYDp = -80.dp + 14.dp * lockProgress LockIcon( lockProgress = lockProgress, isLocked = recordUiState == RecordUiState.LOCKED, @@ -2268,7 +2509,7 @@ fun MessageInputBar( modifier = Modifier .graphicsLayer { translationX = with(density) { (-90).dp.toPx() } - translationY = with(density) { (-70).dp.toPx() } + translationY = with(density) { (-80).dp.toPx() } clip = false } .zIndex(11f) @@ -2276,36 +2517,34 @@ fun MessageInputBar( } } - // Blob: only during RECORDING (Telegram hides waves when locked) - if (recordUiState == RecordUiState.RECORDING) { + // Blob: only during RECORDING + if (recordUiState == RecordUiState.RECORDING && !isVoiceCancelAnimating) { VoiceButtonBlob( voiceLevel = voiceLevel, isDarkTheme = isDarkTheme, modifier = Modifier - .size(48.dp) + .size(recordingActionButtonBaseSize) .graphicsLayer { - scaleX = 1.7f - scaleY = 1.7f + scaleX = recordingActionVisualScale * 1.1f + scaleY = recordingActionVisualScale * 1.1f clip = false } ) } - // Solid circle: 48dp layout, scaled to 82dp visual + // Mic/Send circle — same size as panel height val sendScale by animateFloatAsState( targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f, animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), label = "send_btn_scale" ) - val circleScale = 1.71f // 48dp * 1.71 ≈ 82dp (Telegram) if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) { Box( modifier = Modifier - .size(48.dp) + .size(recordingActionButtonBaseSize) .graphicsLayer { - scaleX = circleScale * sendScale - scaleY = circleScale * sendScale - clip = false + scaleX = sendScale * recordingActionVisualScale + scaleY = sendScale * recordingActionVisualScale shadowElevation = 8f shape = CircleShape } @@ -2315,7 +2554,7 @@ fun MessageInputBar( interactionSource = remember { MutableInteractionSource() }, indication = null ) { - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "tap SEND (locked/paused) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " + "kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" ) @@ -2327,17 +2566,18 @@ fun MessageInputBar( imageVector = TelegramSendIcon, contentDescription = "Send voice message", tint = Color.White, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(20.dp) ) } } else { Box( modifier = Modifier - .size(48.dp) + .size(recordingActionButtonBaseSize) .graphicsLayer { - scaleX = circleScale - scaleY = circleScale - clip = false + scaleX = recordingActionVisualScale + scaleY = recordingActionVisualScale + shadowElevation = 8f + shape = CircleShape } .clip(CircleShape) .background(PrimaryBlue), @@ -2347,23 +2587,28 @@ fun MessageInputBar( imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, contentDescription = null, tint = Color.White, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(19.dp) ) } } } } } - if (!isVoiceRecording) { + if (!isRecordingPanelComposed || keepMicGestureCapture) { Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) .padding(horizontal = 12.dp, vertical = 8.dp) + .zIndex(1f) + .graphicsLayer { + // Keep gesture layer alive during hold, but never show base input under recording panel. + alpha = if (isRecordingPanelComposed) 0f else 1f + } .onGloballyPositioned { coordinates -> - normalInputRowHeightPx = coordinates.size.height - normalInputRowY = coordinates.positionInWindow().y - }, + normalInputRowHeightPx = coordinates.size.height + normalInputRowY = coordinates.positionInWindow().y + }, verticalAlignment = Alignment.Bottom ) { IconButton( @@ -2401,7 +2646,7 @@ fun MessageInputBar( onViewCreated = { view -> editTextView = view }, onFocusChanged = { hasFocus -> if (hasFocus) { - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "tap INPUT focus=true voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" ) @@ -2471,15 +2716,26 @@ fun MessageInputBar( val down = awaitFirstDown(requireUnconsumed = false) val tapSlopPx = viewConfiguration.touchSlop var pointerIsDown = true + var armingCancelledByMove = false var maxAbsDx = 0f var maxAbsDy = 0f pressStartX = down.position.x pressStartY = down.position.y + keepMicGestureCapture = true + rawSlideDx = 0f + rawSlideDy = 0f slideDx = 0f slideDy = 0f + dragVelocityX = 0f + dragVelocityY = 0f + lastDragDx = 0f + lastDragDy = 0f + lastDragEventTimeMs = System.currentTimeMillis() + didCancelHaptic = false + didLockHaptic = false pendingRecordAfterPermission = false setRecordUiState(RecordUiState.PRESSING, "mic-down") - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "mic DOWN mode=$recordMode state=$recordUiState " + "voice=$isVoiceRecording kb=$isKeyboardVisible ${inputHeightsSnapshot()}" ) @@ -2497,52 +2753,97 @@ fun MessageInputBar( } } - var finished = false - while (!finished) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } - ?: event.changes.firstOrNull() - ?: continue - - if (change.changedToUpIgnoreConsumed()) { - pointerIsDown = false - pendingLongPressJob?.cancel() - pendingLongPressJob = null - pendingRecordAfterPermission = false - when (recordUiState) { - RecordUiState.PRESSING -> { - val movedBeyondTap = - maxAbsDx > tapSlopPx || maxAbsDy > tapSlopPx - if (!movedBeyondTap) { - toggleRecordModeByTap() - setRecordUiState(RecordUiState.IDLE, "short-tap-toggle") - } else { - setRecordUiState(RecordUiState.IDLE, "press-release-after-move") - } + fun finalizePointerRelease( + rawReleaseDx: Float, + rawReleaseDy: Float, + source: String + ) { + keepMicGestureCapture = false + pointerIsDown = false + pendingLongPressJob?.cancel() + pendingLongPressJob = null + pendingRecordAfterPermission = false + when (recordUiState) { + RecordUiState.PRESSING -> { + val movedBeyondTap = + maxAbsDx > tapSlopPx || maxAbsDy > tapSlopPx + if (!movedBeyondTap) { + toggleRecordModeByTap() + setRecordUiState(RecordUiState.IDLE, "$source-short-tap-toggle") + } else { + setRecordUiState(RecordUiState.IDLE, "$source-press-release-after-move") } - RecordUiState.RECORDING -> { - inputJumpLog( - "mic UP -> send (unlocked) mode=$recordMode state=$recordUiState" - ) + } + RecordUiState.RECORDING -> { + // iOS parity: + // - dominant-axis release evaluation + // - velocity gate (-400 px/s) + // - fallback to distance thresholds. + var releaseDx = rawReleaseDx.coerceAtMost(0f) + var releaseDy = rawReleaseDy.coerceAtMost(0f) + if (kotlin.math.abs(releaseDx) > kotlin.math.abs(releaseDy)) { + releaseDy = 0f + } else { + releaseDx = 0f + } + val cancelOnRelease = + dragVelocityX <= velocityGatePxPerSec || + releaseDx <= -releaseCancelThresholdPx + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source mode=$recordMode state=$recordUiState releaseDx=${releaseDx.toInt()} " + + "releaseDy=${releaseDy.toInt()} vX=${dragVelocityX.toInt()} vY=${dragVelocityY.toInt()} " + + "cancel=$cancelOnRelease" + ) + if (cancelOnRelease) { + if (isVoiceRecording || voiceRecorder != null) { + cancelVoiceRecordingWithAnimation("$source-cancel") + } else { + setRecordUiState(RecordUiState.IDLE, "$source-cancel-without-recorder") + } + } else { if (isVoiceRecording || voiceRecorder != null) { stopVoiceRecording(send = true) } else { - setRecordUiState(RecordUiState.IDLE, "release-without-recorder") + setRecordUiState(RecordUiState.IDLE, "$source-without-recorder") } } - RecordUiState.LOCKED -> { - inputJumpLog( - "mic UP while LOCKED -> keep recording mode=$recordMode state=$recordUiState" - ) - } - RecordUiState.PAUSED -> { - inputJumpLog( - "mic UP while PAUSED -> stay paused mode=$recordMode state=$recordUiState" - ) - } - RecordUiState.IDLE -> Unit } - resetGestureState() + RecordUiState.LOCKED -> { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source while LOCKED -> keep recording mode=$recordMode state=$recordUiState" + ) + } + RecordUiState.PAUSED -> { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source while PAUSED -> stay paused mode=$recordMode state=$recordUiState" + ) + } + RecordUiState.IDLE -> Unit + } + resetGestureState() + } + + var finished = false + while (!finished) { + val event = awaitPointerEvent() + val trackedChange = event.changes.firstOrNull { it.id == down.id } + val change = trackedChange + ?: event.changes.firstOrNull() + ?: continue + val allPointersReleased = event.changes.none { it.pressed } + val releaseDetected = + change.changedToUpIgnoreConsumed() || !change.pressed || + (trackedChange == null && allPointersReleased) + + if (releaseDetected) { + val releaseDx = + if (trackedChange != null) change.position.x - pressStartX else rawSlideDx + val releaseDy = + if (trackedChange != null) change.position.y - pressStartY else rawSlideDy + val source = + if (trackedChange == null) "mic UP fallback-lost-pointer" + else "mic UP" + finalizePointerRelease(releaseDx, releaseDy, source) finished = true } else if (recordUiState == RecordUiState.PRESSING) { val dx = change.position.x - pressStartX @@ -2551,33 +2852,49 @@ fun MessageInputBar( val absDy = kotlin.math.abs(dy) if (absDx > maxAbsDx) maxAbsDx = absDx if (absDy > maxAbsDy) maxAbsDy = absDy - } else if (recordUiState == RecordUiState.RECORDING) { - // Only RECORDING processes slide gestures - // LOCKED/PAUSED: no gesture processing (Telegram: return false) - val dx = change.position.x - pressStartX - val dy = change.position.y - pressStartY - slideDx = dx - slideDy = dy - lockProgress = ((-dy) / lockDragThresholdPx).coerceIn(0f, 1f) - - if (dx <= -cancelDragThresholdPx) { - inputJumpLog( - "gesture CANCEL dx=${dx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" - ) - stopVoiceRecording(send = false) - setRecordUiState(RecordUiState.IDLE, "slide-cancel") - resetGestureState() - finished = true - } else if (dy <= -lockDragThresholdPx) { - view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) - lockProgress = 1f - slideDx = 0f // reset horizontal slide on lock - slideDy = 0f + val totalDistance = kotlin.math.sqrt(dx * dx + dy * dy) + if (!armingCancelledByMove && totalDistance > preHoldCancelDistancePx) { + armingCancelledByMove = true + pendingLongPressJob?.cancel() + pendingLongPressJob = null + pendingRecordAfterPermission = false + keepMicGestureCapture = false setRecordUiState( - RecordUiState.LOCKED, - "slide-lock dy=${dy.toInt()}" + RecordUiState.IDLE, + "pre-hold-move-cancel dist=${totalDistance.toInt()}" ) } + } else if (recordUiState == RecordUiState.RECORDING) { + // iOS parity: + // raw drag from touch + smoothed drag for UI (0.7 / 0.3). + val rawDx = (change.position.x - pressStartX).coerceAtMost(0f) + val rawDy = (change.position.y - pressStartY).coerceAtMost(0f) + rawSlideDx = rawDx + rawSlideDy = rawDy + + val nowMs = System.currentTimeMillis() + val dtMs = (nowMs - lastDragEventTimeMs).coerceAtLeast(1L).toFloat() + dragVelocityX = ((rawDx - lastDragDx) / dtMs) * 1000f + dragVelocityY = ((rawDy - lastDragDy) / dtMs) * 1000f + lastDragDx = rawDx + lastDragDy = rawDy + lastDragEventTimeMs = nowMs + + slideDx = (slideDx * dragSmoothingPrev) + (rawDx * dragSmoothingNew) + slideDy = (slideDy * dragSmoothingPrev) + (rawDy * dragSmoothingNew) + lockProgress = 0f + + if (!didCancelHaptic && rawDx <= -releaseCancelThresholdPx) { + didCancelHaptic = true + view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) + } + if (rawDx <= -cancelDragThresholdPx) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "gesture CANCEL dx=${rawDx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" + ) + cancelVoiceRecordingWithAnimation("slide-cancel") + finished = true + } } change.consume() } @@ -2585,6 +2902,7 @@ fun MessageInputBar( pendingLongPressJob?.cancel() pendingLongPressJob = null if (recordUiState == RecordUiState.PRESSING) { + keepMicGestureCapture = false setRecordUiState(RecordUiState.IDLE, "gesture-end") resetGestureState() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index 23a13d7..15ef92d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.findViewTreeLifecycleOwner import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -160,7 +163,7 @@ fun SwipeBackContainer( // Alpha animation for fade-in entry val alphaAnimatable = remember { Animatable(0f) } - // Drag state - direct update without animation + // Drag state var dragOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } @@ -177,6 +180,7 @@ fun SwipeBackContainer( val context = LocalContext.current val view = LocalView.current val focusManager = LocalFocusManager.current + val lifecycleOwner = view.findViewTreeLifecycleOwner() val dismissKeyboard: () -> Unit = { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) @@ -187,21 +191,16 @@ fun SwipeBackContainer( focusManager.clearFocus(force = true) } - // Current offset: use drag offset during drag, animatable otherwise + optional enter slide - val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value - val enterOffset = - if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { - enterOffsetAnimatable.value - } else { - 0f - } - val currentOffset = baseOffset + enterOffset + fun computeCurrentOffset(): Float { + val base = if (isDragging) dragOffset else offsetAnimatable.value + val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { + enterOffsetAnimatable.value + } else 0f + return base + enter + } // Current alpha: use animatable during fade animations, otherwise 1 val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f - - // Scrim alpha based on swipe progress - val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f val sharedOwnerId = SwipeBackSharedProgress.ownerId val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer val sharedProgress = SwipeBackSharedProgress.progress @@ -239,6 +238,21 @@ fun SwipeBackContainer( } } + fun forceResetSwipeState() { + isDragging = false + dragOffset = 0f + clearSharedSwipeProgressIfOwner() + scope.launch { + offsetAnimatable.snapTo(0f) + if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { + enterOffsetAnimatable.snapTo(0f) + } + if (shouldShow && !isAnimatingOut) { + alphaAnimatable.snapTo(1f) + } + } + } + // Handle visibility changes // 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if // LaunchedEffect is cancelled by rapid isVisible changes (fast swipes). @@ -292,10 +306,34 @@ fun SwipeBackContainer( } } - DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } } + DisposableEffect(lifecycleOwner) { + if (lifecycleOwner == null) { + onDispose { } + } else { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + forceResetSwipeState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + } + + DisposableEffect(Unit) { + onDispose { + forceResetSwipeState() + } + } if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return + val currentOffset = computeCurrentOffset() + val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f) + val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f + Box( modifier = Modifier.fillMaxSize().graphicsLayer { @@ -346,13 +384,15 @@ fun SwipeBackContainer( var totalDragY = 0f var passedSlop = false var keyboardHiddenForGesture = false + var resetOnFinally = true // deferToChildren=true: pre-slop uses Main pass so children // (e.g. LazyRow) process first — if they consume, we back off. // deferToChildren=false (default): always use Initial pass // to intercept before children (original behavior). // Post-claim: always Initial to block children. - while (true) { + try { + while (true) { val pass = if (startedSwipe || !deferToChildren) PointerEventPass.Initial @@ -365,6 +405,7 @@ fun SwipeBackContainer( ?: break if (change.changedToUpIgnoreConsumed()) { + resetOnFinally = false break } @@ -443,6 +484,13 @@ fun SwipeBackContainer( ) change.consume() } + } + } finally { + // Сбрасываем только при отмене/прерывании жеста. + // При обычном UP сброс делаем позже, чтобы не было рывка. + if (resetOnFinally && isDragging) { + forceResetSwipeState() + } } // Handle drag end diff --git a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt index 77ecebd..c6b468c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt @@ -92,16 +92,8 @@ fun MyQrCodeScreen( val scope = rememberCoroutineScope() var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) } - - // Auto-switch to matching theme group when app theme changes - LaunchedEffect(isDarkTheme) { - val currentTheme = qrThemes.getOrNull(selectedThemeIndex) - if (currentTheme != null && currentTheme.isDark != isDarkTheme) { - // Map to same position in the other group - val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3 - selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5) - } - } + // Local dark/light state — independent from the global app theme + var localIsDark by remember { mutableStateOf(isDarkTheme) } val theme = qrThemes[selectedThemeIndex] @@ -272,7 +264,7 @@ fun MyQrCodeScreen( Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, + color = if (localIsDark) Color(0xFF1C1C1E) else Color.White, shadowElevation = 16.dp ) { Column( @@ -299,16 +291,17 @@ fun MyQrCodeScreen( ) { IconButton(onClick = onBack) { Icon(TablerIcons.X, contentDescription = "Close", - tint = if (isDarkTheme) Color.White else Color.Black) + tint = if (localIsDark) Color.White else Color.Black) } Spacer(modifier = Modifier.weight(1f)) Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, - color = if (isDarkTheme) Color.White else Color.Black) + color = if (localIsDark) Color.White else Color.Black) Spacer(modifier = Modifier.weight(1f)) var themeButtonPos by remember { mutableStateOf(Offset.Zero) } IconButton( onClick = { - // Snapshot → toggle theme → circular reveal + // Snapshot → toggle LOCAL theme → circular reveal + // Does NOT toggle the global app theme val now = System.currentTimeMillis() if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) { lastRevealTime = now @@ -319,11 +312,10 @@ fun MyQrCodeScreen( revealActive = true revealCenter = themeButtonPos revealSnapshot = snapshot.asImageBitmap() - // Switch to matching wallpaper in new theme - val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3 - val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2) + val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3 + val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2) selectedThemeIndex = newIndex - onToggleTheme() + localIsDark = !localIsDark scope.launch { try { revealRadius.snapTo(0f) @@ -337,11 +329,8 @@ fun MyQrCodeScreen( revealActive = false } } - } else { - // drawToBitmap failed — skip } } - // else: cooldown active — ignore tap }, modifier = Modifier.onGloballyPositioned { coords -> val pos = coords.positionInRoot() @@ -350,9 +339,9 @@ fun MyQrCodeScreen( } ) { Icon( - imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars, + imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars, contentDescription = "Toggle theme", - tint = if (isDarkTheme) Color.White else Color.Black + tint = if (localIsDark) Color.White else Color.Black ) } } @@ -360,7 +349,7 @@ fun MyQrCodeScreen( Spacer(modifier = Modifier.height(12.dp)) // Wallpaper selector — show current theme's wallpapers - val currentThemes = qrThemes.filter { it.isDark == isDarkTheme } + val currentThemes = qrThemes.filter { it.isDark == localIsDark } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), @@ -394,7 +383,7 @@ fun MyQrCodeScreen( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } Icon(TablerIcons.Scan, contentDescription = null, - tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f), + tint = if (localIsDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f), modifier = Modifier.size(22.dp)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt index 66402a9..0555844 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt @@ -43,10 +43,10 @@ data class AppIconOption( ) private val iconOptions = listOf( - AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.ic_launcher_foreground, Color(0xFF1B1B1B)), - AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color(0xFF795548)), - AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color(0xFF42A5F5)), - AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color(0xFFFFC107)) + AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.rosetta_icon, Color(0xFF1B1B1B)), + AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color.White), + AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color.White), + AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White) ) @Composable @@ -173,9 +173,8 @@ fun AppIconScreen( .background(option.previewBg), contentAlignment = Alignment.Center ) { - // Default icon has 15% inset built-in — show full size - val iconSize = if (option.id == "default") 52.dp else 36.dp - val scaleType = if (option.id == "default") + val imgSize = if (option.id == "default") 52.dp else 44.dp + val imgScale = if (option.id == "default") android.widget.ImageView.ScaleType.CENTER_CROP else android.widget.ImageView.ScaleType.FIT_CENTER @@ -183,10 +182,10 @@ fun AppIconScreen( factory = { ctx -> android.widget.ImageView(ctx).apply { setImageResource(option.iconRes) - this.scaleType = scaleType + scaleType = imgScale } }, - modifier = Modifier.size(iconSize) + modifier = Modifier.size(imgSize) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt index 347f189..fde1308 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt @@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.splash import androidx.compose.animation.core.* import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* @@ -64,7 +66,11 @@ fun SplashScreen( Box( modifier = Modifier .fillMaxSize() - .background(backgroundColor), + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { }, contentAlignment = Alignment.Center ) { // Glow effect behind logo diff --git a/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png new file mode 100644 index 0000000000000000000000000000000000000000..e6fcf21b0a6ef4771999f880460ce9a77e9c718b GIT binary patch literal 12147 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 q@*%H{FGUxV^Pqo^m+tgydKhd8`em0rX7@Xe% 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png new file mode 100644 index 0000000000000000000000000000000000000000..7638c9c884c1fbb7689aa0b48a6313d92d3788ad GIT binary patch literal 9733 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 vQ9X%yT10!@wu}D1{ClGR*WTY84FO - + diff --git a/app/src/main/res/drawable/ic_calc_foreground.xml b/app/src/main/res/drawable/ic_calc_foreground.xml index 41c843b..4f7b0bc 100644 --- a/app/src/main/res/drawable/ic_calc_foreground.xml +++ b/app/src/main/res/drawable/ic_calc_foreground.xml @@ -1,38 +1,7 @@ - - - - - - - - - - - - - - - + + + + diff --git a/app/src/main/res/drawable/ic_notes_background.xml b/app/src/main/res/drawable/ic_notes_background.xml index fdeeb63..4a213bb 100644 --- a/app/src/main/res/drawable/ic_notes_background.xml +++ b/app/src/main/res/drawable/ic_notes_background.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_notes_foreground.xml b/app/src/main/res/drawable/ic_notes_foreground.xml index 6acf37d..311c95a 100644 --- a/app/src/main/res/drawable/ic_notes_foreground.xml +++ b/app/src/main/res/drawable/ic_notes_foreground.xml @@ -1,52 +1,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/app/src/main/res/drawable/ic_weather_background.xml b/app/src/main/res/drawable/ic_weather_background.xml index 275abac..4a213bb 100644 --- a/app/src/main/res/drawable/ic_weather_background.xml +++ b/app/src/main/res/drawable/ic_weather_background.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_weather_foreground.xml b/app/src/main/res/drawable/ic_weather_foreground.xml index 7bd9a2d..14f4d2b 100644 --- a/app/src/main/res/drawable/ic_weather_foreground.xml +++ b/app/src/main/res/drawable/ic_weather_foreground.xml @@ -1,32 +1,7 @@ - - - - - - - - - - - - - - - - + + + +