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 0000000..e6fcf21 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png new file mode 100644 index 0000000..569e2cc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png differ 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 0000000..7638c9c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png differ diff --git a/app/src/main/res/drawable/ic_calc_background.xml b/app/src/main/res/drawable/ic_calc_background.xml index 697e55f..4a213bb 100644 --- a/app/src/main/res/drawable/ic_calc_background.xml +++ b/app/src/main/res/drawable/ic_calc_background.xml @@ -1,4 +1,4 @@ - + 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 @@ - - - - - - - - - - - - - - - - + + + +